编译 | 核子可乐、Tina
微服务体系下,我们需要重新审视以往的架构设计原则。
2000 年,Robert C. Martin 给架构师们总结出了一套原则来指导大家进行软件设计,Michael Feathers 随后按首字母将其总结成 SOLID 原则。从那时起,面向对象的 SOLID 设计原则就不断出现在相关书籍当中,并成为业界广为人知的指导方针:单一职责原则、开 / 闭原则、里氏替换原则、接口隔离原则、依赖倒置原则。
在过去的这二十年里,软件开发领域一直在快速演进,特别是近几年云原生和微服务的发展,在微服务体系下,“SOLID 原则是否适合现代软件工程”引起了广泛讨论。
无论是支持 SOLID 原则的还是不支持的,他们都一致认为 SOLID 原则不再像以前那样被大多数程序员普遍使用。
其中一篇是 Daniel Orner 最近发表的,他认为 SOLID 原则仍然是现代软件架构的基础,但许多 SOLID 真正关心的事情,比如类和接口、数据隐藏和多态,已经不再是程序员每天要处理的事情,因此他建议重新定义原始的 SOLID 原则。
从事编程已有 30 多年并在大学里教授硕士课程的 Paulo Merson 态度更为鲜明,他认为虽然 SOLID 原则有利于 OOP,但并不完全适用于微服务:SOLID 设计范式中处理的元素(类、接口、层次结构等)与常规分布式系统中的元素、特别是微服务中的元素存在着本质区别。
同时 Paulo Merson 分析了微服务设计的全过程,对应提出了一套新的原则“IDEALS”,希望能够帮助大家充分理解微服务架构,更娴熟地驾驭这股新兴的技术力量。
适用于微服务的架构设计原则
几年前,有人问我“SOLID 原则适不适用于微服务?”当时我正好在教授微服务设计课程,所以经过一番思考,我给出的答案是“部分适用”。
几个月后,我决定整理出一套专门针对微服务架构的基本设计原则,而且希望能跟 SOLID 一样上口好记。为什么非得弄出个原则不可?因为在开发行业当中,我们在微服务解决方案的设计和实施方面已经投入了六年多的时间。在此期间,越来越多的工具、框架、平台乃至支持产品都围绕微服务架构建立起极其丰富的技术格局。而选择越多样,新手微服务开发者在自己的项目里越可能被淹没在众多设计决策与技术选项当中。如果能够整理出一组核心原则,无疑将帮助开发人员把自己的设计决策朝着正确的微服务发展方向推进一大步。
虽然 SOLID 原则中也有部分内容适用于微服务,但面向对象终究是一种设计范式,它处理的元素(类、接口、层次结构等)与常规分布式系统中的元素、特别是微服务中的元素仍存在着本质区别。
因此,我们提出了以下微服务设计原则:接口隔离、可部署性、事件驱动、可用性高于一致性、松散耦合、单一职责。
这些原则当然不可能涵盖微服务解决方案的整个设计决策范围,但至少已经触及到创建现代微服务系统的那些关键问题与成功因素。下面,我们将一同了解为什么符合这“IDEALS”原则的微服务才是好的微服务。
接口隔离
最初的接口隔离原则强调 OO 类应该使用“胖”接口。更确切地讲,不要把客户可能需要的所有方法塞进同一个类接口,而是应该提供多个单独接口来满足每种类型客户的特定需求。
微服务架构其实是面向服务架构的一种特殊化形式,其中接口(即服务契约)的设计一直非常重要。从 2000 年初开始,SOA 文献就规定出一切服务客户端都应遵循的规范模型或规范模式。但 SOA 身上带着种种旧时代的气息,并不能匹配我们当下在服务契约设计和处理方式上出现的复杂变化。在微服务时代,同一服务逻辑往往对接多个客户端程序(前端),这也正是我们在微服务架构中强调接口隔离的主要原因。
在微服务中实现接口隔离
微服务接口隔离的目标,是确保每种类型的前端都能对接最匹配其需求的服务契约。例如,移动原生应用希望调用以短 JSON 表示的数据响应端点;Web 应用则使用完整 JSON 表示;旧版桌面应用程序在调用时同样需要完整表示、但要求使用 XML 格式。另外,不同的客户端往往会使用不同的协议。例如,外部客户端可能希望使用 HTTP 来调用 gRPC 服务。
我们当然不可能将同一服务契约(即规范模型)强加给所有类型的服务客户端,而应选择“隔离接口”以确保不同类型的客户端总能匹配它们实际需要的服务接口。但具体要怎么实现?目前最流行的解决方案就是使用 API 网关。这种网关可以实现消息格式转换、消息结构转换、协议桥接、消息路由等功能。还有另一种流行的替代方案,即面向前端的后端(BFF)模式。在这种情况下,我们会为每种类型的客户端设置一个 API 网关——也就是让每个客户端拥有不同的 BFF,如下图所示。
可部署性(开发者侧)
纵观整个软件发展历史,设计工作一直集中在关于实现单元(模块)的组织方式以及运行时元素(组件)的交互设计决策身上。架构策略、设计模式乃至其他设计策略强调的就是如何在各个层级中组织软件元素、避免过度依赖、为某些类型的组件分配特定角色或关注点,并为“软件”空间中的其他设计决策提供指导。但对于微服务开发人员来说,还有其他一些超越了常规软件元素的关键设计决策需要考量。
作为开发人员,我们早就意识到将软件正确打包并部署至适当的运行时拓扑中的重要意义。然而,我们从来没有像现在这样高度关注微服务的部署与运行时监控。这第二条原则被称为“可部署性”,这方面技术与设计决策已经成为决定微服务成败的关键。而它的主要意义基于这样一个简单事实——微服务架构显著增加了需要部署的单元数量。
因此,IDEALS 中的“D”提醒微服务开发人员,他们还需要保证软件及其后续版本能够始终满足用户的随时使用需求。总之,可部署性设计包括:
- 配置运行时基础设施,包括容器、pod、集群、持久性、安全性及网络等。
- 对微服务进行规模伸缩,或者将其从一个运行时环境迁移至另一环境。
- 加快提交 构建 测试 部署流程。
- 最大限度减少当前版本更替时的停机时间。
- 同步相关软件的版本变化。
- 监控微服务的健康状况,以快速识别并修复故障。
实现良好的可部署性
自动化是实现高效部署的关键。要实现自动化,我们需要正确选择工具与技术,这也正是自微服务架构出来以来变化最大的领域所在。因此,微服务开发人员应该在工具与平台方面拓展思路,并始终以怀疑的态度审视不同选择带来的助益与挑战。
下面来看开发者们在一切微服务解决方案中都应认真考虑的可部署性策略与技术:
- 容器化与容器编排:容器化微服务能够降低跨平台及跨云环境时的复制与部署难度;编排平台则提供共享资源与机制,用于实现路由、扩展、复制、负载均衡等功能。Docker 与 Kubernetes 已经成为当前容器化与容器编排层面的客观行业标准。
- 服务网格(Service mesh):此类工具可用于实现流量监控、策略执行、身份验证、RBAC、路由、断路器、消息转换等功能,帮助容器编排平台完成通信。目前流行的服务网格方案包括 Istio、Linkerd 以及 Consul Connect。
- API 网关:通过拦截对微服务的调用,API 网关产品提供一系列丰富的功能,包括消息转换与协议桥接、流量监控、安全控制、路由、缓存、请求节流以及 API 配额与断路等。这方面的典型方案包括 Ambassador、Kong、Apiman、WSO2 API Manager、Apigee 以及 Amazon API Gateway 等。
- 无服务器架构:通过将服务部署至遵循 FaaS 范式的无服务器平台,大家可以回避由容器编排带来的大部分复杂性与运营成本。AWS Lambda、Azure Functions 以及 Google Cloud Functions 都是无服务器平台的典型选项。
- 监控工具:在将微服务分布在我们的本地和云基础设施之后,对关于系统运行状态的问题进行预测、检测与通知就变得至关重要。目前市面上有多种监控工具可以选择,例如 New Relic、CloudWatch、Datadog、Prometheus 以及 Grafana。
- 日志整合工具:微服务往往会轻松将部署单元的数量提升至新的数量级,因此我们需要专门的工具整合这些组件的日志输出,并灵活使用搜索、分析与警报生成等功能。这方面的高人气工具包括 Fluentd、Graylog、Splunk 及 ELK(Elasticsearch、Logtstash 以及 Kibana)。
- 跟踪工具:这些工具可以检测您的微服务,之后生成、收集并可视化跨服务调用的运行时跟踪数据。它们还能帮助您发现性能问题,甚至帮助您了解整体架构。目前的主流跟踪工具包括 Zipkin、Jaeger 以及 AWS X-Ray。
- DevOps:无论是基础设施配置还是事件处理,只有保证开发人员与运营团队密切沟通并协作,微服务架构才能真正发挥良好效力。
- 蓝 - 绿部署与金丝雀发布:这些部署策略能够在发布微服务新版本时实现零或近零停机,并在发现问题时快速切换回原始版本。
- 基础设施即代码(IaC):这一实践能够尽可能减少构建 - 部署周期中的人为交互,加快流程速度、降低出错几率与审计难度。
- 持续交付:这是缩短提交到部署周期、同时保持解决方案质量稳定的必要实践。传统的 CI/CD 工具包括 Jenkins、GitLab CI/CD、Bamboo、GoCD、CircleCI 以及 Spinnaker。最近,新的 GitOps 工具(例如 Weaveworks 与 Flux)也加入战团,尝试将 CD 与 IaC 结合起来。
- 外部化配置:这一机制允许将配置属性存储在微服务部署单元之外以降低管理难度。
事件驱动
微服务架构的主要作用就是创建(后端)服务,而这些服务通常会使用以下三种通用型连接器之一:
- HTTP 调用(指向 REST 服务)
- 使用 gRPC 或 GraphQL 等特定平台组件技术的 RPC 类调用
- 通过消息代理队列的异步消息
前两者通常为同步性质,其中 HTTP 调用的使用频率明显更高。通常,服务需要调用其他服务组合,而且多数情况下服务组合内的交互具有同步性质。相反,如果我们创建(或适配)参与的服务以接入并接收来自队列 / 主题的消息,则需要创建一个事件驱动架构。(有些朋友可能对消息驱动和事件驱动之间的区别存在争议,本文则基本不做区分,会交替使用这两个术语来引出 Apache Kafka、RabbitMQ 及 Amazon SNS 等消息代理产品)。
事件驱动架构的一大核心优势,在于显著提高了系统的可扩展性与吞吐量。而这一优势之所以能够实现,是因为消息发送方不会因阻塞而等待响应,而且多个接收方可以通过发布 - 订阅的方式并行使用同一消息 / 事件。
事件驱动型微服务
IDEALS 中的“E”强调的是应尽量在微服务建模中引入事件驱动性质,这样才能更好地满足当今软件解决方案的可扩展性与性能需求。此外,这种设计也有助于松散耦合的实现,因为消息发送者与接收者(双方均为微服务)将彼此独立、互不了解。另外,因为这种设计能够应对微服务的暂时中断,在后续重新处理排队消息,所以系统可靠性也将得到提升。
当然,事件驱动型微服务(也称反应式微服务)同样会带来一定挑战。由于处理是异步激活且并行发生,因此可能需要设置同步点与相关标识符。我们还需要在设计中考虑消息错误和丢失问题,包括在必要时引入事件纠正与数据变更撤销机制(例如 Saga 模式)。对于由事件驱动架构承载的面向用户交易,应认真考虑用户体验,确保最终用户了解当前进展和事故细节。
可用性高于一致性
CAP 定理本质上提供了两种选择:要可用性,还是要一致性。我们看到业界付出了巨大努力才让大家获得了用一致性换可用性的选项,这就是最终一致性机制。原因很简单:如今的最终用户耐心很差,根本不可能忍受糟糕的可用性。想象一下每年购物节时的网店,如果我们在产品浏览时显示的库存数量与购买时更新的实际库存之间硬性保持强一致性,那么数据变更将产生大量开销。而且一旦某些涉及库存更新的服务暂时无法访问,那么目录就无法显示库存信息,后续下单、结算等服务也将随之瘫痪!相反,如果我们选择可用性优先(即接受偶尔不一致的风险),用户则可以根据稍稍过时的库存数据进行购买。没错,终归会有几百或者几千分之一的用户因为结账时库存信息不正确而被迫取消订单,我们可以向他们发邮件道歉。但从用户整体和业务运作的角度来看,这种情况终究要好对全体用户都承受更慢的访问速度或者更低的访问稳定性。所以可用性高于一致性,没有争议。
当然,也有部分业务操作确实需要强一致性。但正如 Pat Helland 所指出,在马上得到答案和得到正确答案之间,大多数人想要的其实是马上得到答案。
具有最终一致性的高可用性
在微服务当中,实现可用性的主要策略在于数据复制。我们可以采用不同的设计模式,也可以在必要时把多种模式组合使用:
- 服务数据复制模式:当微服务需要访问属于其他应用的数据(而且无法通过 API 调用获取数据)时,则使用此种基本模式。我们会创建数据副本,使其随时可供微服务使用。这种解决方案还需要配合数据同步机制(例如 ETL 工具 / 程序、发布订阅消息、物化视图等),负责定期或基于触发器保证副本与主数据保持一致。
- 命令查询职责分离(CQRS)模式:在这里,我们将更改数据(命令)的操作与只读数据(查询)的操作在设计和实现层面区分开来。CQRS 通常以服务数据复制为基础执行查询,用以提高性能和自治性。
- 事件溯源模式:我们并不会将对象的当前状态存储在数据库内,而是只存储影响到该对象的、纯附加的、不可变的事件序列。当前状态则通过重放这一系列事件获得,这样就能建立起数据的“查询视图”。因此,事件溯源通常需要建立在 CQRS 设计基础之上。
日常工作中常用的 CQRS 设计如下图所示。我们可以使用运行在集中式 Oracle 数据库上的 REST 服务处理操作更改数据的 HTTP 请求(这种情况下,该服务仍使用各微服务对应的数据库)。只读 HTTP 请求会转入不同的后端服务,再由这些后端服务从基于 Elasticsearch 文本的数据存储处读取数据。定期执行 Spring Batch Kubernetes cron 作业以根据 Oracle DB 上执行的数据变更操作,对 Elasticsearch 存储内容进行更新。这种设置使得两套数据存储之间始终保持最终一致性;而且即使 Oracle DB 或者 cron 作业失效,查询服务也仍然可用。
松散耦合
在软件工程中,耦合是指两个软件元素之间的相互依赖程度。对基于服务的系统而言,传入耦合主要涉及服务用户如何与服务进行交互。我们知道这种交互应该通过服务契约进行。另外,契约不应与实现细节或特定技术紧密耦合。服务属于能够被不同程序调用的分布式组件。有时,服务托管方甚至不清楚所有服务用户具体在哪里(公共 API 服务就是最典型的示例)。因此,一般应尽量避免变更契约。如果服务契约与服务逻辑或者技术紧密耦合,那么当逻辑或技术需要演进时,就很容易影响到业务的正常运行。
服务通常需要与其他服务或其他类型的组件进行交互,由此产生传出耦合。这种交互的存在会直接影响到服务自治的运行时依赖关系。如果服务的自治性较低,则其行为的可预测性也将保持在较低水平:在理想情况下,服务的实际速度、可靠性与可用性由其需要调用的最慢、最不可靠且可用性最差的组件决定。
服务间的松散耦合策略
IDEALS 原则中的“L”提醒我们要注意服务间的耦合关系,特别是微服务间的耦合情况。我们可以使用并组合多种策略以管理传入与传出松散耦合。此类策略示例包括:
- 点对点发布 - 订阅:这些构建块消息传递模式及其变体有助于实现松散耦合,因为发送方与接收方互不了解;其中响应式微服务(例如 Kafka 消费方)的契约将充当消息队列的名称与消息结构。
- API 网关与 BFF:这类解决方案规定了一个中间组件,用于处理服务契约与客户端想要查看的消息格式与协议间的一切差异,从而实现双边解耦。
- 契约优先设计:通过这种独立于任何现有代码的契约设计方法,我们能够避免创建出与特定技术或实现紧密耦合的 API。
- 超媒体:对于 REST 服务,超媒体能帮助前端保持独立于服务端点之外的存在定位。
- Façade 与 Adapter/Wrapper 模式:微服务架构中这些 GoF 模式的变体可以规定内部组件甚至服务,并防止不良耦合在微服务实现中传播。
- 每微服务对应一数据库模式:通过这种模式,不仅能让微服务获得自治权,同时也避免其与共享数据库进行直接耦合。
单一职责
最初的单一职责原则(SRP)旨在强调 OO 类应具有内聚功能。但在同一个类里包含多个职责会自然导致紧密耦合,进而衍生出难以扩展的脆弱设计成果,很可能在变更期间发生意外不到的宕机。而且如大家所知,这是一项说起来容易、但正确实现难度极高的设计原则。
单一职责的概念也可以扩展到微服务架构内的服务内聚性层面。微服务架构规定各个部署单元应该只包含一项服务或者几项内聚服务。如果某个微服务中充斥着大量不够内聚的服务,则必然受到传统单体式应用问题的影响。一旦过于臃肿,微服务在功能和技术堆栈方面将变得难以发展。另外,由于众多开发人员会在同一部署单元中处理多个移动部件,因此持续交付也将变得无法为继。
另一方面,如果在微服务划分上粒度过细,则可能需要多次微服务交互才能满足用户请求。在最糟糕的情况下,数据变更可能会分布在不同的微服务中,进而催生出过于复杂的分布式事务场景。
对微服务进行正确的粒度划分
微服务设计真正趋于成熟的一个重要体现,就是创建出既不过粗、也不过细的微服务成果。这里的解决办法不在于任何工具或技术,而在于适当的领域建模。我们可以通过多种方式为后端服务建模,并划定出正确的微服务边界。目前业界流行的微服务范围设计方法是领域驱动设计(DDD)原则。简单来讲:
- 服务(例如 REST 服务)所能具有的 DDD 聚合范围。
- 微服务所能具有的 DDD 有界上下文范围。该微服务中的服务应对应于有界上下文内的聚合。
- 对于微服务间通信,我们可以使用:当异步消息满足需求时,则使用领域事件;当请求 - 响应连接器更适合时,则使用某种防腐层调用 API;当微服务需要来自其他 BC 的大量数据时,使用具有最终一致性的数据复制机制。
总 结
IDEALS 原则为大多数典型微服务设计提供了指导意见。当然,这些只是指导方针、绝非必然保证微服务设计成功的魔药或者神咒。与以往技术实践一样,我们同样需要对开发质量保持良好理解,并在设计决策中深刻体会不同选项间的利弊权衡。此外,我们还应了解有助于实现设计原则的设计模式与架构策略,同时更好地掌握市面上可用的技术方案。
几年以来,我一直在使用 IDEALS 原则设计、实施并部署微服务。我也曾在设计研讨会和演讲中与来自不同组织的数百名软件开发人员讨论过这些原则,特别是每条原则背后的思路与策略。希望本文提出的 IDEALS 原则能够帮助大家充分理解微服务架构,更娴熟地驾驭这股新兴的技术力量。