本文是基于“微服务架构设计模式”这本书的总结和提炼,将其中的关键知识点结合个人的开发实践进行结合提炼,并对部分话题进一步挖深讲透,参杂了部分个人理解。
单体服务VS微服务
单体架构也称之为单体系统或者是单体应用。就是一种把系统中所有的功能、模块耦合在一个应用中的架构方式。单体架构特点:1)打包成一个独立的单元(导成一个唯一的 jar 包或者是 war 包);2)以一个进程的方式来运行,MVC架构就是典型的单体架构。
单体架构的优缺点如下:
优点
- 应用的开发很简单:IDE和其他开发工具只需要构建这一个单独的应用程序。
- 易于对应用程序进行大规模的更改:可以更改代码和数据库模式,然后构建和部署。
- 测试相对简单直观:开发者只需要写几个端到端的测试。
- 部署简单明了: 开发者唯一需要做的就是把war文件复制到安装了Tomacat的服务器上。
缺点
- 随着业务的迭代,单体系统会逐渐庞大和复杂,以至于任意一个开发都很难理解和cover它的全部。
- 开发速度变慢:IDE工具会变慢,构建部署时间长。多人协作冲突的概率变高,每一次改动影响面会变大。总之从代码提交到实际部署交付的周期会变长。
- 难以扩展:单体应用多个高并发请求会导致物理资源(如CPU、内存等)出现单点瓶颈。
- 迭代困难:需要长期依赖某个可能已经过时的技术栈。
微服务是一种架构风格。一个大型的复杂软件应用,由一个或多个微服务组成。系统中的各个微服务可被独立部署,各个微服务之间是松耦合的。每个微服务仅关注于完成一个业务域的事情。微服务特点:1)系统是由多个服务构成;2)每个服务可以单独独立部署;3)每个服务之间是松耦合的。服务内部是高内聚的,外部是低耦合的。
微服务的优缺点如下:
优点
- 使大型的复杂应用程序可以持续交付和持续部署。
- 每个服务都相对较小并容易维护。
- 服务可以独立部署和独立扩展,系统迭代容易。
- 微服务架构可以实现团队的自治,团队协作容易,每个服务团队可以独立于其他团队开发、部署和扩展。开发速度相对单体应用更快。
- 每个微服务都可以有独立的存储和服务器,从而整个系统的吞吐能力会指数增长。
缺点
- 运维成本过高,部署数量较多,需要协调更多的开发团队。
- 接口需要兼容多版本,
- 一个需要改动的服务工程会比较多
- 分布式系统带来更高的复杂性,需要处理分布式事务,需要有更好的发布平台和分布式跟踪平台等。
微服务架构 与 SOA的异同
SOA(Service Oriented Architecture,面向服务的架构)是一种设计方法,其中包含多个服务, 服务之间通过相互依赖最终提供一系列的功能。一个服务通常以独立的形式存在于操作系统进程中。各个服务之间 通过网络调用。
微服务是SOA发展出来的产物,它是一种比较现代化的细粒度的SOA实现方式。
SOA往往采用全局数据模型并共享数据库,而每个微服务都有自己的数据模型和数据库;SOA是较大的单体应用,微服务是较小的服务。SOA之间的通信采用的是类似ESB(Enterprise Service Bus)只能管道,采用例如SOAP、WS等重量级协议,而微服务往往采用RPC或者REST这种轻量级的协议。
讨论「微服务和SOA的差别」的意义远不如讨论「微服务和单体系统的差别」更大,因为他们的区别实在有点微妙。
微服务架构其实和 SOA 架构类似,微服务是在 SOA 上做的升华,微服务架构强调的一个重点是“业务需要彻底的组件化和服务化”,原有的单个业务系统会拆分为多个可以独立开发、设计、运行的小应用。这些小应用之间通过服务完成交互和集成。下面这个公式很好的描述两者关系:
微服务架构 = 80%的SOA服务架构思想 + 100%的组件化架构思想 + 80%的领域建模思想
微服务如何拆分以及如何设计
微服务的物理拆分--将一个大需求拆分为多个子系统
跟所有的软件开发过程一样,一开始我们需要拿到领域专家或者现有应用的需求文档。跟所有的软件开发一样,定义架构也是一项艺术而非技术。下面定义应用程序架构的三步式流程。
- 第一步是识别业务系统操作。将应用程序的需求提炼为各种关键请求。描述服务之间协作方式的架构场景。
- 第二步是确定如何分解服务。有几种策略可供选择。一种源于业务架构学派的策略是定义与业务能力相对应的服务。另一种策略是围绕领域驱动设计的子域来分解和设计服务。但这些策略的最终结果都是围绕业务概念而非技术概念分解和设计的服务。
- 第三步是确定每个服务的API。为此,你将第一步中标识的每个系统操作分配给服务。服务可以完全独立地实现操作。
识别业务系统操作
定义应用程序架构的第一步是定义业务系统操作。起点是应用程序的需求,包括用户故事及其相关的用户场景(请注意,这些与架构场景不同)。第一步创建由关键类组成的抽象领域模型,这些关键类提供用于描述系统操作的词汇表。第二步确定系统操作,并根据领域模型描述每个系统操作的行为。
领域模型主要源自用户故事中提及的名词,系统操作主要来自用户故事中提及的动词。你还可以使用名为事件风暴(Event Storming)的技术定义领域模型,每个系统操作的行为都是根据它对一个或多个领域对象的影响以及它们之间的关系来描述的。
根据业务能力进行服务拆分
创建微服务架构的策略之一就是采用业务能力进行服务拆分。业务能力是一个来自于业务架构建模的术语。业务能力是指一些能够为公司(或组织)产生价值的商业活动。特定业务的业务能力取决于这个业务的类型。例如,保险公司业务能力通常包括承保、理赔管理、账务和合规等。在线商店的业务能力包括:订单管理、库存管理和发货,等等。
- 业务能力定义了一个组织的工作。组织的业务能力通常是指这个组织的业务是做什么,它们通常都是稳定的。与之相反,组织采用何种方式来实现它的业务能力,是随着时间不断变化的。
- 识别业务能力。一个组织有哪些业务能力,是通过对组织的目标、结构和商业流程的分析得来的。每一个业务能力都可以被认为是一个服务。
- 从业务能力到服务。一旦确定了业务能力,就可以为每个能力或相关能力组定义服务。
根据子域进行服务拆分
Eric Evans在他的经典著作中(Addison-Wesley Professional,2003)提出的领域驱动设计是构建复杂软件的方法论,这些软件通常都以面向对象和领域模型为核心。领域模型以解决具体问题的方式包含了一个领域内的知识。它定义了当前领域相关团队的词汇表,DDD也称之为通用语言(Ubiquitous language)。领域模型会被紧密地映射到应用的设计和实现环节。在微服务架构的设计层面,DDD有两个特别重要的概念,子域和限界上下文。
子域是领域的一部分,领域是DDD中用来描述应用程序问题域的一个术语。识别子域的方式跟识别业务能力一样:分析业务并识别业务的不同专业领域,分析产出的子域定义结果也会跟业务能力非常接近。
DDD把领域模型的边界称为限界上下文(bounded context)。限界上下文包括实现这个模型的代码集合。当使用微服务架构时,每一个限界上下文对应一个或者一组服务。换一种说法,我们可以通过DDD的方式定义子域,并把子域对应为每一个服务,这样就完成了微服务架构的设计工作。
关于根据子域进行服务拆分可以参考我的这篇文章,这篇文章是以一个在线问诊场景,描述了如何从需求落地到微服务。
医疗场景交易平台战略设计&战术落地思考
微服务的逻辑拆分--架构风格
微服务将一个大型的复杂软件应用拆分为一个或多个微服务系统 。每一个微服务系统的内部代码组织方式就是架构风格。常见的架构风格有MVC三层架构以及现在提倡的六边形架构,下面对这两种架构进行介绍总结。
分层式架构风格
架构的典型例子是分层架构。分层架构将软件元素按“层”的方式组织。每个层都有明确定义的职责。分层架构还限制了层之间的依赖关系。每一层只能依赖于紧邻其下方的层(如果严格分层)或其下面的任何层。
可以将分层架构应用于前面讨论的四个视图中的任何一个。流行的三层架构是应用于逻辑视图的分层架构。它将应用程序的类组织到以下层中:
-
表现层:包含实现用户界面或外部API的代码。
-
业务逻辑层:包含业务逻辑。
-
数据持久化层:实现与数据库交互的逻辑。
分层架构是架构风格的一个很好的例子,但它确实有一些明显的弊端:
-
单个表现层:它无法展现应用程序可能不仅仅由单个系统调用的事实。
-
单一数据持久化层:它无法展现应用程序可能与多个数据库进行交互的事实。
-
将业务逻辑层定义为依赖于数据持久化层:理论上,这样的依赖性会妨碍你在没有数据库的情况下测试业务逻辑。
此外,分层架构错误地表示了精心设计的应用程序中的依赖关系。业务逻辑通常定义数据访问方法的接口或接口库。数据持久化层则定义了实现存储库接口的DAO类。换句话说,依赖关系与分层架构所描述的相反。
关于架构风格的六边形
六边形架构是分层架构风格的替代品。如下图所示,六边形架构风格选择以业务逻辑为中心的方式组织逻辑视图。应用程序具有一个或多个入站适配器,而不是表示层,它通过调用业务逻辑来处理来自外部的请求。同样,应用程序具有一个或多个出站适配器,而不是数据持久化层,这些出站适配器由业务逻辑调用并调用外部应用程序。此架构的一个关键特性和优点是业务逻辑不依赖于适配器。相反,各种适配器都依赖业务逻辑。
业务逻辑具有一个或多个端口(port)。端口定义了一组操作,关于业务逻辑如何与外部交互。例如,在Java中,端口通常是Java接口。有两种端口:入站和出站端口。入站端口是业务逻辑公开的API,它使外部应用程序可以调用它。入站端口的一个实例是服务接口,它定义服务的公共方法。出站端口是业务逻辑调用外部系统的方式。出站端口的一个实例是存储库接口,它定义数据访问操作的集合。
业务逻辑的周围是适配器。与端口一样,有两种类型的适配器:入站和出站。入站适配器通过调用入站端口来处理来自外部世界的请求。入站适配器的一个实例是Spring MVC Controller,它实现一组REST接口(endpoint)或一组Web页面。另一个实例是订阅消息的消息代理客户端。多个入站适配器可以调用相同的入站端口。
出站适配器实现出站端口,并通过调用外部应用程序或服务处理来自业务逻辑的请求。出站适配器的一个实例是实现访问数据库的操作的数据访问对象(DAO)类。另一个实例是调用远程服务的代理类。出站适配器也可以发布事件。
六边形架构风格的一个重要好处是它将业务逻辑与适配器中包含的表示层和数据访问层的逻辑分离开来。业务逻辑不依赖于表示层逻辑或数据访问层逻辑。
由于这种分离,单独测试业务逻辑要容易得多。另一个好处是它更准确地反映了现代应用程序的架构。可以通过多个适配器调用业务逻辑,每个适配器实现特定的API或用户界面。业务逻辑还可以调用多个适配器,每个适配器调用不同的外部系统。六边形架构是描述微服务架构中每个服务的架构的好方法。
分层架构和六边形架构都是架构风格的实例。每个都定义了架构的构建块(元素),并对它们之间的关系施加了约束。六边形架构和分层架构(三层架构)构成了软件的逻辑视图。现在让我们将微服务架构定义为构成软件的实现视图的架构风格。
服务拆分的规范
微服务拆分之后,工程会比较的多,如果没有一定的规范,将会非常混乱,难以维护。
首先人们经常问的一个问题是,服务拆分之后,原来都在一个进程里面的函数调用,现在变成了A调用B调用C调用D调用E,会不会因为调用链路过长而使得相应变慢呢?
服务拆分的规范一:服务拆分最多三层,两次调用
服务拆分是为了横向扩展,因而应该横向拆分,而非纵向拆成一串的。也即应该将商品和订单拆分,而非下单的十个步骤拆分,然后一个调用一个。
纵向的拆分最多三层:
基础服务层:用于屏蔽数据库,缓存层,提供原子的对象查询接口,有这一层,为了数据层做一定改变的时候,例如分库分表,数据库扩容,缓存替换等,对于上层透明,上层仅仅调用这一层的接口,不直接访问数据库和缓存。
组合服务层:这一层调用基础服务层,完成较为复杂的业务逻辑,实现分布式事务也多在这一层
Controller层:接口层,调用组合服务层对外
服务拆分的规范二:仅仅单向调用,严禁循环调用
微服务拆分后,服务之间的依赖关系复杂,如果循环调用,升级的时候就很头疼,不知道应该先升级哪个,后升级哪个,难以维护。
因而层次之间的调用规定如下:
基础服务层主要做数据库的操作和一些简单的业务逻辑,不允许调用其他任何服务。
组合服务层,可以调用基础服务层,完成复杂的业务逻辑,可以调用组合服务层,不允许循环调用,不允许调用Controller层服务
Controller层,可以调用组合业务层服务,不允许被其他服务调用
如果出现循环调用,例如A调用B,B也调用A,则分成Controller层和组合服务层两层,A调用B的下层,B调用A的下层。也可以使用消息队列,将同步调用,改为异步调用。
服务拆分的规范三:将串行调用改为并行调用,或者异步化
如果有的组合服务处理流程的确很长,需要调用多个外部服务,应该考虑如何通过消息队列,实现异步化和解耦。
例如下单之后,要刷新缓存,要通知仓库等,这些都不需要再下单成功的时候就要做完,而是可以发一个消息给消息队列,异步通知其他服务。
而且使用消息队列的好处是,你只要发送一个消息,无论下游依赖方有一个,还是有十个,都是一条消息搞定,只不过多几个下游监听消息即可。
对于下单必须同时做完的,例如扣减库存和优惠券等,可以进行并行调用,这样处理时间会大大缩短,不是多次调用的时间之和,而是最长的那个系统调用时间。
服务拆分的规范四:接口应该实现幂等
微服务拆分之后,服务之间的调用当出现错误的时候,一定会重试,但是为了不要下两次单,支付两次,需要所有的接口实现幂等。
幂等一般需要设计一个幂等表来实现,幂等表中的主键或者唯一键可以是transaction id,或者business id,可以通过这个id的唯一性标识一个唯一的操作。
也有幂等操作使用状态机,当一个调用到来的时候,往往触发一个状态的变化,当下次调用到来的时候,发现已经不是这个状态,就说明上次已经调用过了。
状态的变化需要是一个原子操作,也即并发调用的时候,只有一次可以执行。可以使用分布式锁,或者乐观锁CAS操作实现。
服务拆分的规范五:接口数据定义严禁内嵌,透传
微服务接口之间传递数据,往往通过数据结构,如果数据结构透传,从底层一直到上层使用同一个数据结构,或者上层的数据结构内嵌底层的数据结构,当数据结构中添加或者删除一个字段的时候,波及的面会非常大。
因而接口数据定义,在每两个接口之间约定,严禁内嵌和透传,即便差不多,也应该重新定义,这样接口数据定义的改变,影响面仅仅在调用方和被调用方,当接口需要更新的时候,比较可控,也容易升级。
服务拆分的规范六:规范化工程名
微服务拆分后,工程名非常多,开发人员,开发团队也非常多,如何让一个开发人员看到一个工程名,或者jar的名称,就大概知道是干什么的,需要一个规范化的约定。
例如出现pay就是支付,出现order就是下单,出现account就是用户。
再如出现compose就是组合层,controller就是接口层,basic就是基础服务层。
出现api就是接口定义,impl就是实现。
pay-compose-api就是支付组合层接口定义。
account-basic-impl就是用户基础服务层的实现。
微服务架构中的业务逻辑设计
代码模型结构
贫血模型是指使用的领域对象中只有setter和getter方法(POJO),所有的业务逻辑都不包含在领域对象中而是放在业务逻辑层。有人将我们这里说的贫血模型进一步划分成失血模型(领域对象完全没有业务逻辑)和贫血模型(领域对象有少量的业务逻辑),我们这里就不对此加以区分了。充血模型将大多数业务逻辑和持久化放在领域对象中,业务逻辑(业务门面)只是完成对业务逻辑的封装、事务和权限等的处理。
充血模型的层次结构和上面的差不多,不过大多业务逻辑和持久化放在Domain Object里面,Business Logic只是简单封装部分业务逻辑以及控制事务、权限等,这样层次结构就变成Client->(Business Facade)->Business Logic->Domain Object->Data Access。
优点是面向对象,Business Logic符合单一职责,不像在贫血模型里面那样包含所有的业务逻辑太过沉重。
胀血模型是基于充血模型上取消Service层,只剩下domain object和DAO两层,在domain object的domain logic上面封装事务。
在这四种模型当中,失血模型和胀血模型应该是不被提倡的。而贫血模型和充血模型从技术上来说,都已经是可行的了。事务封装还是尽量放在Service层(我们的manage层)。胀血模型将对象的序列化行为封装到领域层,即domain object会调用domain acess层,同时domain access层又依赖domain object的结构,所以胀血模型中domain object层会和domain access层双向依赖。
我们平时做 Web 项目的业务开发,大部分都是基于贫血模型的 MVC 三层架构,称为传统的开发模式。之所以称之为“传统”,是相对于新兴的基于充血模型的DDD 开发模式来说的。基于贫血模型的传统开发模式,是典型的面向过程的编程风格。相反,基于充血模型的 DDD 开发模式,是典型的面向对象的编程风格。不过,DDD 也并非银弹。对于业务不复杂的系统开发来说,基于贫血模型的传统开发模式简单够用,基于充血模型的 DDD 开发模式有点大材小用,无法发挥作用。相反,对于业务复杂的系统开发来说,基于充血模型的 DDD 开发模式,因为前期需要在设计上投入更多时间和精力,来提高代码的复用性和可维护性,所以相比基于贫血模型的开发模式,更加有优势。基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比,主要区别在 Service层。在基于充血模型的开发模式下,我们将部分原来在 Service 类中的业务逻辑移动到了一个充血的 Domain 领域模型中,让 Service 类的实现依赖这个 Domain 类。不过,Service 类并不会完全移除,而是负责一些不适合放在 Domain 类中的功能。比如,负责与 Repository 层打交道、跨领域模型的业务聚合功能、幂等事务等非功能性的工作。基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比,Controller 层和Repository 层的代码基本上相同。这是因为,Repository 层的 Entity 生命周期有限,Controller 层的 VO 只是单纯作为一种 DTO。两部分的业务逻辑都不会太复杂。业务逻辑主要集中在 Service 层。所以,Repository 层和 Controller 层继续沿用贫血模型的设计思路是没有问题的。
事务脚本VS领域建模模式
单业务逻辑比较简单时,失血模型和贫血模型基本一样,所有的业务逻辑集中在service层,编写一个称为事务脚本的方法来处理来自表示层的每个请求,这种设计风格是高度面向过程的,这种方法适用于简单的业务逻辑。
采用事务脚本会随着业务逻辑变得复杂,代码也会难以维护。就像单体应用程序不断增长的趋势一样,事务脚本也存在同样的问题。很多类同时包含状态和行为,通过将用户的状态和行为收敛到对象领域模型上,实现逻辑上的高内聚,同时代码逻辑也会更高复用。
事务脚本模式是实现简单业务逻辑的好方法。但是在实现复杂的业务逻辑时,应该考虑使用面向对象的领域模型模式。
关于DDD的一些理论基础参考我的另一篇文章 领域驱动设计理论基础
发布领域事件
设计服务的业务逻辑的好方法是使用DDD聚合。DDD聚合很有用,因为它们把领域模块化,消除了服务之间对象的直接引用,并确保每个ACID事务都在服务内。
创建或更新聚合时应发布领域事件。领域事件具有广泛的用途。可以参考我的另一篇文章分布式事务总结中事件表部分。
微服务之间的交互方式总结
在单体应用中,各模块之间的调用是通过编程语言级别的方法或者函数来实现的。而基于微服务的分布式应用是运行在多台机器上的;一般来说,每个服务实例都是一个进程。因此,服务之间的交互必须通过进程间通信(IPC)来实现。
交互模式
当为某个服务选择 IPC 时,首先需要考虑服务之间的交互问题。客户端和服务器之间有很多的交互模式,我们可以从两个维度进行归类。
第一个维度是这些交互式是同步还是异步:
• 同步模式:客户端请求需要服务端即时响应,甚至可能由于等待而阻塞。
• 异步模式:客户端请求不会阻塞进程,服务端的响应可以是非即时的。
第二个维度是一对一还是一对多:
• 一对一:每个客户端请求有一个服务实例来响应。包括:请求/响应,通知(也就是常说的单向请求)、 请求/异步响应。
• 一对多:每个客户端请求有多个服务实例来响应。包括:发布/ 订阅模式,发布/异步响应模式。
IPC 技术
现在有很多不同的 IPC 技术。服务间通信可以使用同步的请求/响应模式,比如基于 HTTP 的 REST 或者 Thrift。另外,也可以选择异步的、基于消息的通信模式,比如 AMQP 或者 STOMP。此外,还可以选择 JSON 或者 XML 这种可读的、基于文本的消息格式。当然,也还有效率更高的二进制格式,比如 Avro 和 Protocol Buffer。在讨论同步的 IPC 机制之前,我们先了解异步的 IPC 机制。
基于消息的异步通信
使用消息模式的时候,进程之间通过异步交换消息消息的方式通信。客户端通过向服务端发送消息提交请求,如果服务端需要回复,则会发送另一条独立的消息给客户端。由于异步通信,客户端不会因为等待而阻塞,相反会认为响应不会被立即收到。
消息通过渠道发送,通过渠道接收。
消息由数据头(例如发送方这样的元数据)和消息正文构成。消息通过渠道发送,任何数量的生产者都可以发送消息到渠道,同样,任何数量的消费者都可以从渠道中接受数据。频道有两类,包括点对点渠道和发布/订阅渠道。点对点渠道会把消息准确的发送到从渠道读取消息的用户,服务端使用点对点来实现之前提到的一对一交互模式;而发布/订阅则把消息投送到所有从渠道读取数据的用户,服务端使用发布/订阅渠道来实现上面提到的一对多交互模式。
基于消息的异步通信的经典实现就是基于MQ,目前互联网使用的MQ主要是Rocketmq,关于Rockemq的使用参考我另一篇文章Rocketmq原理&最佳实践
基于请求/响应的同步 IPC
使用同步的、基于请求/响应的 IPC 机制的时候,客户端向服务端发送请求,服务端处理请求并返回响应。一些客户端会由于等待服务端响应而被阻塞,而另外一些客户端可能使用异步的、基于事件驱动的客户端代码,这些代码可能通过 Future 或者 Rx Observable 封装。然而,与使用消息机制不同,客户端需要响应及时返回。这个模式中有很多可选的协议,但最常见的两个协议是 REST 和 RPC。
首先我们来了解 REST。当前很流行开发 RESTful 风格的 API。REST 基于 HTTP 协议,其核心概念是资源典型地代表单一业务对象或者一组业务对象,业务对象包括“消费者”或“产品”。REST 使用 HTTP 协议来控制资源,通过 URL 实现。譬如,GET 请求会返回一个资源的包含信息,可能是 XML 文档或 JSON 对象格式。POST 请求会创建新资源,而 PUT 请求则会更新资源。REST 之父 Roy Fielding 曾经说过:REST 提供了一系列架构系统参数,作为整体使用,强调组件交互的扩展性、接口的通用性、组件的独立部署、以及减少交互延迟的中间件,它强化安全,也能封装遗留系统。使用基于 HTTP 的协议有如下好处:1)HTTP 非常简单并且大家都很熟悉。2)可以使用浏览器扩展(比如 Postman)或者 curl 之类的命令行来测试 API。3)内置支持请求/响应模式的通信。4)HTTP 对防火墙友好。5)不需要中间代理,简化了系统架构。
不足之处包括:1)只支持请求/响应模式交互。尽管可以使用 HTTP 通知,但是服务端必须一直发送 HTTP 响应。2)由于客户端和服务端直接通信(没有代理或者缓冲机制),在交互期间必须都保持在线。3)客户端必须知道每个服务实例的 URL。
使用REST的一个挑战是,由于HTTP仅提供有限数量的动词,因此设计支持多个更新操作的REST API并不总是很容易。避免此问题的进程间通信技术是RPC。RPC有几个好处:1)设计具有复杂更新操作的API非常简单。2)它具有高效、紧凑的进程间通信机制,尤其是在交换大量消息时。3)支持客户端和用各种语言编写的服务端之间的互操作性。
RPC也有几个弊端:1)与基于REST/JSON的API机制相比,使用基于RPC的API需要做更多的工作。2)RPC是REST的一个引人注目的替代品,但与REST一样,它是一种同步通信机制,因此它也存在局部故障的问题。
更多关于RPC的明细参考我的另一篇文章RPC详解&跨语言RPC实践
服务发现
假设你正在编写一些调用具有REST API的服务的代码。为了发出请求,你的代码需要知道服务实例的网络位置(IP地址和端口)。在物理硬件上运行的传统应用程序中,服务实例的网络位置通常是静态的。例如,你的代码可以从偶尔更新的配置文件中读取网络位置。但在现代的基于云的微服务应用程序中,通常不那么简单,现代应用程序更具动态性。
服务实例具有动态分配的网络位置。此外,由于自动扩展、故障和升级,服务实例集会动态更改。因此,你的客户端代码必须使用服务发现。
由于无法使用服务的IP地址静态配置客户端,应用程序必须使用动态服务发现机制。服务发现在概念上非常简单:其关键组件是服务注册表,它是包含服务实例网络位置信息的一个数据库。
服务实例启动和停止时,服务发现机制会更新服务注册表。当客户端调用服务时,服务发现机制会查询服务注册表以获取可用服务实例的列表,并将请求路由到其中一个服务实例。
常见的服务发现中间件有zookeeper和consul,两者的原理基本类似。关于consul可以参考我的另一篇文章consul入门篇
分布式事务问题
提起微服务架构,不可避免的两个话题就是服务治理和分布式事务。数据库和业务模块的垂直拆分为我们带来了系统性能、稳定性和开发效率的提升的同时也引入了一些更复杂的问题,例如在数据一致性问题上,我们不再能够依赖数据库的本地事务,对于一系列的跨库写入操作,如何保证其原子性,是微服务架构下不得不面对的问题。
针对分布式系统的特点,基于不同的一致性需求产生了不同的分布式事务解决方案,追求强一致的两阶段提交、追求最终一致性的柔性事务和事务消息等等。各种方案没有绝对的好坏,抛开具体场景我们无法评价,更无法能做出合理选择。在选择分布式事务方案时,需要我们充分了解各种解决方案的原理和设计初衷,再结合实际的业务场景,从而做出科学合理的选择。
关于分布式事务问题可以参考我的另一篇文章,里面有对分布式事务进行系统性阐述,分布式事务总结
事件溯源&CQRS
事件溯源
事件溯源是构建业务逻辑和持久化聚合的另一种选择,它将聚合以一系列事件的方式持久化保存,每个事件代表聚合的一次状态变化。应用通过重放事件来重新创建聚合的当前状态。它的好处有:1)保留聚合的历史记录(审计和监管);2)可靠地发布领域事件(微服务架构)。它的弊端是:1)有一定学习曲线;2)查询事件存储库通常很困难,这需要CQRS模式。
传统持久化技术的问题
对象与关系的阻抗失调:关系数据库的表格结构模式与领域模型及其复杂关系的图状结构之间,存在基本的概念不匹配问题。
缺乏聚合的历史:只存储聚合的当前状态,聚合更新后先前的状态丢失,实现审计功能将非常繁琐且容易出错。
事件发布是凌驾于业务逻辑之上:不支持发布领域事件,开发人员必须自己处理事件生成的逻辑。
事件溯源原理
事件溯源通过事件来持久化聚合,事件溯源采用基于领域事件的概念来实现聚合的持久化,将每个聚合持久化为数据库中的一系列事件。应用程序从事件存储中检索并重放事件来加载聚合:
- 加载聚合的事件
- 使用其默认的构造函数创建聚合实例
- 调用apply()方法遍历事件
事件代表状态的改变,事件必须包含执行状态更改所需要的数据,聚合方法都和事件相关。
业务逻辑通过调用聚合根上的命令方法来处理对聚合的更新请求。命令方法通常会验证其参数,而后更新一个或多个聚合字段。
基于事件溯源的应用程序的命令方法则会生成一系列事件,并应用于聚合以更新其状态。
使用乐观锁处理并发更新
乐观锁通常使用版本列来检测聚合自读取以来是否已更改。只有当前版本和应用程序读取聚合时版本一致,此UPDATE语句才会成功。
事件溯源和发布事件
可以将事件溯源作为可靠的事件发布机制。将这些持久化保存的事件传递给所有感兴趣的消费者。使用轮询或者日志拖尾技术(binlog监听)来发布事件
使用快照提升性能
长生命周期的聚合可能有大量事件,可定期持久保存聚合状态的快照。应用通过加载最新快照以及仅加载快照后发生的事件来快速恢复聚合状态。
幂等方式的消息处理
基于关系型数据库事件存储库的幂等消息处理:将message ID插入PROCESSED_MESSAGES表,作为插入EVENTS表的事件的事务的一部分,以检测和丢弃重复消息。
基于非关系数据库事件存储库的幂等消息处理:NOSQL的事件存储库事务模型功能有限,简单的解决方案是消息的ID存储在处理它时生成的事件中,通过验证聚合的所有事件中是否有包含该消息的ID来做重复检测。
领域事件的演化
事件的结构经常随着时间的推移而变化,应用程序可能需要处理多个事件版本。
服务的领域模型随着时间的推移而发展,向事件添加字段,不大可能影响接收方,但更改字段名词等操作不向后兼容。
通过向上转换来管理结构的变化,事件溯源应用可以使用类似Flyway的方法处理向后兼容的更改。从事件存储库加载事件时,将各个事件从旧版本更新为新版本。
事件溯源的好处
- 可靠地发布领域事件
- 保留聚合的历史
- 最大程度避免对象与关联的“阻抗失调”问题
- 为开发者提供一个“时光机”
事件溯源的弊端
- 有一定学习曲线
- 基于消息传递的应用程序的复杂性(消息代理确保至少一次成功传递,这意味着非幂等的事件处理程序必须检测并丢弃重复事件)
- 处理事件的演化有一定难度
- 删除数据存在一定难度
- 查询事件存储库很有挑战性
使用 CQRS 实现查询
使用API组合模式进行查询
每个微服务只负责一个业务子域的上下文,只有这个子域的数据,因此很多查询需要从多个服务中获取数据。最常用的就是API组合模式进行查询。涉及两类角色:API组合器和数据提供方服务。
由谁担任API组合器角色:
1)客户端担任,但这对于防火墙之外客户以及通过较慢网络访问的服务,此选择不实用。
2)API Gateway中实现,API查询提供方服务,检索数据,组合结果并向客户端返回响应。
3)API组合器,将多个客户端和服务使用的查询操作实现为独立的服务,可实现API Gateway无法完成的复杂的聚合逻辑。应使用响应式编程模式,尽可能并行调用服务,最大限度地缩短查询操作的响应时间
API组合模式的弊端
- 增加了额外的开销:需要调用多个服务和查询多个数据库,这带来了额外的开销。
- 带来了可用性降低的风险:随着调用的服务的数量增多,整个查询链路的可用性是所有数据提供服务的可用性相乘。
- 缺乏事务数据一致性:一个写操作涉及到多个微服务,可能某些微服务还没有完全结束,此时的查询可能会出现多个服务之间的数据不一致。
使用CQRS模式
使用API组合模式检索分散在多个服务中的数据会导致昂贵、低效的内存中连接(如某些服务并不存储用于过滤的属性)。
拥有数据的服务将数据存储在不能有效支持所需查询的表单或数据库中(如无法执行有效的地理空间查询)。
鉴于隔离(避免过多的职责导致过载服务)考虑,拥有数据的服务不一定是会实现查询操作的服务。
CQRS模式使用事件来维护从多个服务复制数据的只读视图,借此实现对来自多个服务的数据的查询。
CQRS模式将命令和查询职责隔离。将持久化数据模型和使用数据的模块分为两部分:命令端和查询端。命令端模块和数据模型实现CUD操作,查询端模块和数据模型实现查询。查询端通过订阅命令端发布的事件,使其数据模型与命令端数据模型保持同步。见下图:
CQRS的利弊
CQRS的优势:
- 在微服务架构中高效地实现查询,有效地实现了检索多个服务所拥有地数据的查询。
- 高效地实现多个不同的查询类型,通过宽表避免了多次RPC调用和内存Join。
- 在基于事件溯源技术的应用中实现了查询,通过订阅由基于事件溯源的聚合发布的事件流,可以保持最新的聚合的一个或多个视图。
- 更进一步地实现问题隔离。通过将命令和查询分离,让操作更加单纯,利于维护。
CQRS的弊端
- 更加复杂的架构
- 处理数据复制导致的延迟,一种解决方案是采用命令端和查询端API为客户端提供版本信息,使其能够判断查询端是否过时。
外部API模式
外部API的设计难题
Web应用在防火墙内部运行,它们通过高带宽、低延迟的局域网访问服务。其他客户端在防火墙之外运行,通过较低带宽、较高延迟的互联网或移动网路访问。
应用程序扮演API组合器的角色,调用多个服务并组合结果,存在如下问题:
- 多次客户端请求导致用户体验不佳
- 缺乏封装导致前端开发做出的代码修改影响后端
- 服务可能选用对客户端不友好的进程间通信
- 同样存在API组合低效的问题,但更大的问题是第三方开发人员需要一个稳定的API,API旧版本可能需要永远维护。
API Gateway模式
直接访问服务的API客户端会导致很多问题,更好的方法是API Gateway,即实现一个服务,该服务是外部API客户端进入基于微服务应用程序的入口点,它负责:
- 请求路由
- API组合
- 协议转换
- 能够为每一个客户端提供它们专用的API
- 其他边缘功能(身份验证、访问授权、速率限制、缓存、指标收集、请求日志)
API Gateway的架构具有分层模块化架构,如API层和公共层,API层由一个或多个独立的API模块组成。每个API模块为特定客户端实现API。公共层实现共享功能,如边缘功能。
API Gateway若由一个单独团队维护,这种集中式的瓶颈与微服务架构理念背道而驰。更好的方法或许是让客户端团队拥有他们的API模块,而API Gateway团队负责开发公共模块和API Gateway的运维。部署流水线必须完全自动化。
API Gateway的职责不明确。后端前置模式为每个客户端定义一个单独的API Gateway。每个客户端团队都拥有自己的API Gateway。API Gateway团队拥有并维护共享层。每个端的团队拥有并维护属于他们的API。
API Gateway的好处是客户端不必调用特定服务,而是与API Gateway通信,减少往返次数,简化了代码。弊端是存在成为开发瓶颈的风险,开发人员必须更新API Gateway才能对外公开服务的API,更新过程要尽可能轻量化,必要时使用后端前置模式。
开发自己的API Gateway
API Gateway的设计难题
1)性能和可扩展性.所有的外部请求必须首先通过API Gateway。影响性能和可扩展性的关键设计决策是API Gateway应用使用同步还是异步I/O
2)使用响应式编程抽象。按顺序调用服务,服务响应时间过长,尽可能同时调用所有服务,但编写可维护的并发代码存在挑战。可使用响应式方法,如CompleteFutures、Monos、RxJava等。
3)处理局部故障。通过多实例的负载均衡以及断路器模式。
目前开源的主流API Gateway有:Netflix Zuul和Spring Cloud Gateway。
使用GraphQL实现API Gateway
实现支持多种客户端的REST API的API Gateway非常耗时,你可能需要考虑使用基于图形的API框架,如GraphQL。
API由映射到服务的基于图形的模式组成,客户端发出检索多个图形节点的查询。基于查询的API框架通过从一个或多个服务检索数据来执行查询。
基于GraphQL(一种标准)的API Gateway可使用Node.js Express Web 框架和Apollo GraphQL服务器,用js编写。它可以由三部分组成:
- GraphQL模式:定义服务器端数据模型及其支持的查询
- 解析器函数:解析函数将模式的元素映射到各种后端服务。
- 代理类:代理类调用应用程序的服务。
执行GraphQL
使用GraphQL的主要好处是它的查询语言为客户端提供了对返回数据的令人难以置信的控制。客户端通过向服务器发出包含查询文档的请求来执行查询。简单情况下,查询文档包含查询的名称,参数值及要返回结果的对象字段。
当GraphQL服务器执行查询时,必须从一个或多个数据存储中检索所请求的数据。通过将解析函数附加到模式定义的对象类型字段,可以将GraphQL模式与数据源相关联。GraphQL通过调用解析器函数检索数据,以此实现API组合模式。
GraphQL通过递归调用Query文档中指定的字段解析器函数来执行查询。首先,它执行查询解析器,然后递归调用结果对象层次结构中字段的解析器。
测试
将代码扔给QA团队,手动测试,效率很低,在交付流程中才进行测试为时已晚。使用微服务的一个关键动机是提高可测试性,微服务架构的复杂性要求编写自动化测试,以缩短交付(代码投入生产环境)周期。
什么是测试
测试的目的是验证被测系统的行为。测试用例是用于特定目标的一组测试输入、执行条件和预期结果,一组相关的测试用例集构成一个测试套件。
每个自动化测试都是通过测试类中一个测试方法实现。测试包括四个阶段:设置——初始化测试环境,这是运行测试的基础;执行——调用被测系统;验证——验证测试的结果;清理——清理测试环境。
被测系统在运行时常会依赖另一些系统,依赖的麻烦在于它们可能把测试复杂化,减慢测试速度。解决方案使用测试替身,该对象负责模拟依赖项的行为。测试替身分为stub(代替依赖项向被测系统发送调用的返回值),mock(用来验证被测系统是否正确调用来依赖项,也扮演stub的角色)。
根据范围分类,测试分为以下类型:
- 单元测试:主要测试业务逻辑,测试服务的一小部分,例如类
- 集成测试:验证服务与它依赖方的通话,验证服务是否可以与基础设施服务或其他服务进行交互。
- 组件测试:服务的验收测试,单个服务的验收测试
- 端到端测试:应用程序的验收测试,整个应用程序的测试
微服务带来的质量挑战
系统依赖性增加:将单体应用转成微服务,虽然增加了缩放能力和灵活性,但是引入了更多的依赖,使系统整体变的更复杂,使测试环境的搭建配置以及校验指标更加难以掌控。
并行开发障碍:系统依赖性的增加还会给微服务的并行开发工作造成影响,需要等待其他微服务测试环境部署完毕,才能实现集成、测试。微服务数量越多,需要考虑的对象就越是广泛
影响传统测试方法:传统测试方法往往通过UI测试进行验证,而微服务的测试方案更加复杂。不仅需要验证各独立微服务,还需要检查整体业务的执行路径。
为服务编写单元测试
单元测试有以下两种类型:
- 独立型单元测试: 使用针对类的依赖性的模拟对象隔离测试类,常用于领域服务(Service),控制器类、入站和出站消息网关的测试。对外部依赖项进行测试替身。
- 协作型单元测试: 测试一个类及其依赖项,常用于实体、值对象、Sagas的测试。
类的职责及其在架构中的角色决定了要使用的单元测试类型。控制类和服务类通常使用独立型单元测试。领域对象(例如实体和值对象)通常使用协作型单元测试。
领域服务的单元测试
领域服务的方法调用实体和存储库并发布领域事件,测试这种 类的有效方法是独立型单元测试,它可以模拟存储库和消息传递类等依赖项。单元测试分三个阶段:
1)配置服务依赖项的模拟对象
2)调用服务方法
3)验证服务方法返回的值是否正确,以及是否已正确调用依赖项
事件和消息处理程序的单元测试
每个测试实例都是消息适配器,向消息通道发送消息,并验证是否正确调用了服务模拟。而消息传递的基础设施是基于桩的,因此不涉及消息代理。测试可以使用Eventuate Tram Mock Messaging框架。
单元测试不会验证服务是否与其他服务正确交互,为了验证服务是否正确地与其他服务交互,必须编写集成测试。
集成测试
为了确保服务按预期工作,必须编写测试来验证服务是否可以正确地与基础设施服务和其他服务进行交互。一种方法是启动所有服务并通过其API进行测试。更有效的策略是编写集成测试,针对不同类型的适配器采用不同的测试验证方法,比如:1)针对基于REST的请求/响应,直接验证http请求和响应。2)针对发布/订阅适配器,通过测试验证/模拟对应的领域事件;3)针对异步请求/响应,验证命令消息和恢复消息。
针对持久化层的集成测试
执行持久化集成测试每个阶段的行为如下:
设置:通过创建数据库结构设置数据库,并将其初始化为已知状态。也可能开始执行一些必要的数据库事务
执行:执行数据库操作。
验证:对数据库的状态和从数据库中检索的对象进行断言。
拆解:可选阶段,可以撤销对数据库所作的更改。
关于如何配置在持久化集成测试中的使用的数据库,可以使用Docker方案解决。
针对基于REST的请求/响应式交互的集成测试
良好的集成测试策略是使用消费者驱动的契约测试。契约用于验证两端的适配器类。
针对发布/订阅式交互的集成测试
与测试REST交互的方式类似,不同的是每个契约都指定了一个领域事件。通过验证是否触发生成对应的领域事件,或是否正确调用了其模拟的依赖项来验证。
针对异步请求/响应式交互的集成契约测试
消费者端测试验证命令消息代理类是否发送了结构正确的命令消息,并正确处理回复消息。提供者测试由Spring Cloud Contract代码生成。每种测试方法对应一份契约。它将契约的输入消息作为命令消息发送,并验证回复消息是否与契约输出消息匹配。
组件测试
组件测试指单独测试服务。验收测试是针对软件组件的面向业务的测试。它们从组件客户端而非内部实现角度描述所需的外部可见行为。这些测试源自用户故事或用例。
使用Gherkin编写验收测试
使用Java编写验收测试有挑战性,更好的方法是使用Gherkin,用类似英语场景定义验收测试。可自动将场景转换为可运行的代码。情景具有given-when-then结构。
使用Cucumber执行Gherkin的测试规范
Cucumber是Gherkin的测试自动化框架。你可以编写一个步骤定义类,类包含一组方法,方法定义了每个given-when-then步骤的具体含义。
进程内组件测试
使用常驻内存的桩和模拟代替其依赖性运行服务。编写更简单,速度更快,但不测试服务的可部署性。
进程外组件测试
将服务打包为生产环境就绪的格式(如Docker容器镜像),并作为单独的进程运行。进程外组件测试使用真实的基础设施服务,如数据库、消息代理,但对应用程序服务的任何依赖项使用桩。好处是提高测试覆盖率,测试内容更接近部署的内容;缺点是编写起来更复杂,执行更慢。
端到端测试
端到端测试位于测试金字塔顶端。开发这类测试缓慢、脆弱且耗时。应尽量控制端到端测试数量。
编写用户旅程测试,模拟用户在应用程序中的旅程,并验证相对较大的应用程序功能片段的高级行为。如可编写完成所有若个测试的单个测试,而不是单独测试这些步骤。这可以显著减少编写测试数量并缩短测试执行时间。
端到端测试与组件测试实现类似,使用Gherkin编写并使用Cucumber执行。
网友评论