@xuemingdeng
2017-03-27T21:28:30.000000Z
字数 10062
阅读 538
摘要:
在微服务时代,“单体”变成了一个肮脏的词汇。不过,按照模块化进行设计的单体更适合用在复杂的领域,比如企业应用。这一系列的文章包含了两个部分,第1部分对微服务和单体架构的关键不同点进行了比较,列出了每种架构的优点和缺点。
正文:
要点
- 单体架构和微服务架构都有其存在的道理,不过单体必须进行模块化才能得以持续发展。或许,单体更加适用于复杂的领域(企业应用),而微服务更加适用于业务领域较为简单的互联网应用。
- 采用微服务架构意味着要放弃事务和模块(服务)间的引用完整性。实现微服务需要付出更大的成本。
- 两种架构都需要平台为其提供支持。对于微服务来说,平台需要为其解决网络的复杂性问题(比如需要提供回路断路器)。而对于单体来说,平台需要处理横断面(cross-cutting)相关的技术问题,这样开发人员才能专注在复杂的领域逻辑上。
- 以单体为先导的架构(先构建模块化的应用,在将来可以拆分成微服务)要求定义好模块的边界、接口和职责。
在IT行业工作了一定时日的人大都习惯了行业技术的潮起潮落,这似乎已经成为一个无法摆脱的模式。在过去的几年,InfoQ上的很多文章和演讲都涉及到了微服务,微服务架构获得了广泛的关注。与此同时,“单体”却似乎变成了一个肮脏的词汇。单体应用难以维护和伸缩,是一个名副其实的“大泥球”。
这篇文章要为单体打一场保卫战。不过事先声明,我所说的单体并不是指那种堆砌了大量代码的单体应用,而是指由多个模块组成的应用。这类应用通常由第三方的开源组件和自己开发的组件组成。这篇文章不打算为老旧的单体撑腰,而是为“模块化单体”打一场保卫战。我们稍后会说明模块化的重要性。
任何一种架构都存在权衡,关键要看具体的上下文环境。我所经历的两个单体系统都是企业内部的Web应用。在过去的13年里,我参与开发了一个大型的基于.NET平台的政府福利管理系统。而在最近的5年,我还参与开发了一个基于Java的票据系统。这两个系统都是单体,它们的大部分业务逻辑都部署在单个Web应用里。我敢说,大部分InfoQ的读者都在做着类似的事情。
在文章的第一部分,我将介绍微服务和单体之间的关键区别,它们各有自己的优点和缺点。而在第二部分,我将详细解释一些用于实现单体模块化的模式,并以我正在开发的Java单体为例(代码可以在Github上找到)。
我们先从可维护性(其实我想说的是模块化)说起。
可维护性(模块化)
一个重要的系统,不管采用何种架构,对于业务方来说,都需要重大的投入。我所参与的系统对于业务各方来说具有战略性的意义,并且需要运行数十年。所以它们必须具有可维护性,必须能够适应变化。而模块化是实现这一目标的重要途径。
如何实现模块化取决于所采用的技术。模块的源代码需要彼此独立,在编译时还要加入额外的元数据。模块需要定义好依赖项和接口,也就是API和SPI。在我参与的Java系统里,模块就是使用Maven构建的JAR包,而在另一个.NET系统里,模块是C#项目(DLL)或NuGet包。
为什么模块化如此重要?因为模块化能够确保代码易于理解,确保功能的封装性,确保系统组件间的交互具有约束性。如果系统的任意对象都可以随意进行交互,那么在发生变更时,开发人员就很难做到完全规避潜在的风险。我们把应用拆分成足够小的模块,开发人员就可以很好地理解模块的作用域、功能和职责。另外,模块必须暴露出稳定的接口(模块内部的实现可以发生变更),模块的实现可以各自演化。从长远来看,适当的关注点分离可以保持代码的可维护性。
在进行模块化时,我们要遵循无环依赖原则,确保模块间的依赖是单向的。稍后我们会讨论如何遵循这个原则。不管我们使用何种工具来达到这个目的,它们都要被纳入到持续集成的构建管道里,任何违反该约束的代码都不能被接受。
我们还要按照模块将代码分组,让不太稳定的代码依赖较为稳定的代码,这也就是稳定性依赖原则。同一个模块里的类发生变更的几率应该是一样的。如果模块发生变更,应该是基于单一的理由,这也就是单一职责原则。如果我们遵循了这些原则,模块最终会将业务或者技术职责封装在一起。作为开发人员,在做出变更时,我们就会知道哪些模块包含了哪些代码。
模块的代码没有必要被保存在同一个代码仓库,比如那些第三方开源模块的代码就不在你的代码仓库里。反过来说,为每个模块创建单独的代码仓库也不是个好主意,至少在一开始不应该这么做。在一个复杂的领域,模块的职责可能会发生快速的变更,过早地分离代码可能会产生事与愿违的效果。
那么模块代码应该在什么时候移到自己的代码仓库里?如果你认为某个模块会被其他应用重用,它有自己的发布周期和独立的版本计划,那么就可以考虑把它移出来。另外,出于对可追踪性的考虑,分离模块有助于识别单体应用中发生变更的部分(不同的发布版本之间),我们可以集中对发生变更的部分进行用户验收测试。另外一个更现实的原因,是为了减少代码仓库HEAD版本的竟用。太多的代码提交给持续集成管道带来了压力。如果代码库不能及时构建和测试,那么就无法实现持续集成约束,也就无法保证架构的完整性。
技术层面的模块最适合被移到自己的代码仓库里,比如审计、认证和授权、邮件、文档(PDF)渲染、文档扫描,等等。或许还可以根据标准的业务子领域来拆分,比如笔记和评论、通信频道、文档、别名或通信。图1展示了我们在进行功能模块化和部署时的选择图谱。我们先从实现一个特性开始,作为核心领域的一部分(第1步),在职责显化之后进行逐步的模块化(第2步和第3步)。最后,我们将功能移到它们各自的服务里,作为独立的服务来部署(第4步和第5步),这个阶段与之前不一样的地方在于,我们要决定服务间的通信该用同步的方式还是异步的方式。如果应用里的每一个特性都走到了这一步,我们就拥有了一个“纯净”的微服务架构。
图1:功能的打包和部署选项,单体对比微服务
单体和微服务之间有一个关键的区别,对于模块职责的变更,单体比微服务具有更高的宽容度。
- 如果领域很复杂(应该使用领域驱动设计方法),就不应该过早地确定模块的边界,因为它们的职责不可能被定义清楚。如果你不是这方面的专家,那么你就失去了获得深层洞察力的机会,这种洞察力有助于构建强大的领域。而即使你有了这种洞察力,将系统重构成微服务也仍然会是件耗时耗力的事情。
- 反过来说,如果你对领域有了很好的理解,就很容易确定模块或服务的边界。在这种情况下,微服务架构的介入就是件水到渠成的事情。
不过人们在这方面的看法并不完全一样。Martin Fowler在他的文章“单体先行”里提倡上述的做法,但他的一些同事却持相反的看法。
循环依赖和无环依赖
构建模块化的单体意味着要做出很多方面的决策,比如,如何表示模块间的边界,如何确定依赖的方向性,如何加强依赖间的约束。
Structure101这个工具可以帮助我们做到以上几点,借助这个工具,我们可以将已有代码的包或命名空间映射成“逻辑”模块,并选择性地在持续集成管道里强制执行上述的规则。因此,只需通过改变映射,我们就可以在不移动代码的情况下改变模块的边界。反过来说,除非我们要按照Structure101的标准来审视代码,或者开发人员要在提交了破坏持续集成管道的代码之后才能意识到他们打破了依赖约束,否则我们并不需要很明显的模块边界。
经常性地移动代码是一种最直接的方式,它不要求使用额外的工具。例如,(在JVM上)创建独立的Maven模块(图1里的第2步)。Maven本身可以强制依赖约束,不管是在进入持续集成管道之前,还是进入到管道之后。在.NET平台上,这种方式就变成了独立的C#项目(不是单个C#项目里不同的命名空间),它们作为NuGet包被其他模块引用。
或许我们还需要自定义检查规则,以便确保架构具有依赖约束。例如,在我所参与的.NET项目里,每个模块都由一个Api C#项目和一个Impl C#项目组成。如果没有遵循这个原则,构建就会失败。我们还要求Impl项目只能引用其他的Api项目。如果Impl项目直接引用了其他Impl项目,构建也会失败。
因此,进行到第2步算得上是迈出了具有实际意义的一步,不过我们要走得更远,我们希望把模块移动到它们自己的代码库里(第3步)。不过,做出这个举动要十分小心。因为每个模块的构建过程相互独立,有可能会出现循环依赖。
例如,customers 1.0模块依赖addresses 1.0模块,那么customers就处于addresses的“上层”。如果开发人员创建了一个新的addresses 1.1模块,这个模块引用了customers 1.0,那么原先的层级关系就被打破了,customers和addresses模块之间形成了循环依赖。
微服务架构也有类似的问题。如果customers和addresses都是微服务,那么上述的情况会再次发生。这种情况下,想要独立地更新任何一个服务都会变得很困难。最后的结果就是,整个系统将成为一个分布式的单体。
对于单体来说,至少还可以使用Maven这样的构建工具来识别这些问题。在文章的第2部分,我们将会详细地说明这方面的内容。如果采用了微服务架构,我们需要做更多的事情(可用的工具并不多)才能识别出这类问题,并将它们逐个解决。
这就告诉我们,对于单体来说,不要急于进入第3步(模块的独立代码库),我们要保证任何可以独立出来的模块已经具备了稳定的接口。微服务架构要求每个微服务必须是独立的,而且有自己的代码库。在一开始就要小心地划分职责、确定接口和依赖关系。如果我们对业务领域不是很了解,就很难做到这些。
数据
在微服务架构里,每个服务负责处理自己的数据。微服务经常被提及的一个优势是,每个模块可以选择适合自己的持久化技术,比如RDBMS、NoSQL、键值存储引擎、事件存储引擎、全文检索引擎,等等。
如果一个服务(我们把它叫作消费者服务)需要使用其他服务(数据所有者)的数据,消费者服务可以向数据所有者索要数据,或者将数据复制一份给消费者服务。不过这两种方式都有其缺点。对于前一种来说,服务间存在临时的耦合关系(数据所有者必须处于可用状态),而后一种需要额外的精力和基础设施才能实现。不过有一种情况是需要尽量避免的,就是不要在服务间共享数据库。这种情况不属于微服务架构,而是分布式单体。
在一个模块化的单体里,每个模块也需要负责自己的数据,也可以使用自己的持久化技术。反过来说,也可以在多个模块中使用相同的持久化技术,例如关系型数据库,它仍然在企业系统里占据重要的地位。如果一个模块需要其他模块的信息,可以调用这些模块的API,不需要复制数据,也不用担心这些模块是否处于运行状态。
在多个模块中使用相同的持久化技术,就可以将它们的表放进同一个RDBMS。我们不用担心RDBMS的伸缩性问题,RDBMS实际上比我们认为的具有更强的伸缩性(稍后我们会讨论伸缩性的话题)。
把模块数据放在一起有很多好处。我们使用普通的SELECT和JOIN(可能是视图或存储过程)查询就能满足BI和报表需求(需要从多个模块获取数据)。集中数据还简化了批处理,在这些需要用到批处理的地方,出于对效率的考虑,我们将业务功能(比如存储过程)和数据部署在一起。集中数据还能简化一些运维任务,比如数据库备份和数据库完整性检查。
对于微服务架构来说,这些事情就会变得复杂得多。例如,微服务中的BI和报表要求在客户端进行联合操作,还需要通过事件总线交换信息,然后将数据保存成某种物化视图。这个过程当然是可以实现的,只是比单个视图或存储过程需要更多的工作量。
不过,集中数据很容易在RDBMS中形成一个“大泥球”。我们一不小心就会创建出很多外键(也就是结构化耦合),我们还要面对一些风险,比如开发人员直接通过SELECT进行跨模块查询(也就是行为耦合)。在文章的第2部分,我们将详细介绍如何解决这些问题。
集中数据有助于事务管理,接下来我们将介绍这个好处。
事务性(同步)
经常会有一个业务操作需要改变多个模块的状态。例如,我从在线商店订购了一个电视机,那么在线商店的仓储、订单管理和物流模块都会受到影响(可能还有更多的模块)。
在微服务架构里,因为每个服务都有自己的数据存储,所以数据的变更是独立进行的,它们之间通过消息通信,确保付了钱的用户能够收到电视机(也要避免没有付钱的用户收到电视机)。如果在过程中发生了错误,需要做一些补偿来“回退”这些变更。如果在线商店收到了货款却无法发货,那么就需要通过其他方式将货款退回给用户。
在某些领域,比如电商,不同子领域间的交互一般具有异步性。用户知道支付和物流是两个独立的过程,如果当中出现了问题,那么未完成的操作需要被回退。
还有另一种领域,假设内部票据系统的用户需要执行一个票据操作,一般来说,这个操作只会在票据模块内进行。不过,如果客户要求通过邮件发送他们的票据,那么就涉及到跨模块的票据创建和邮件通信。这种情况下,就涉及到多个模块的状态变更。
在微服务架构里,票据的创建和邮件通信可以异步进行。如果用户想要查看通信的结果,我们需要某种通知机制,告诉用户何时可以查看结果。
相比之下,在单体架构里,如果票据数据、文档和邮件通信模块都部署在同一个RDBMS中,那么我们就可以依赖RDBMS的事务来确保所有状态变更的原子性。假设处理过程的效率足够高,那么用户只需要等待数秒时间。
在我看来,这种用户体验会更好,而且设计也更简单(更低的维护成本)。如果处理过程需要较长的时间,我们可以将某些处理过程移到后台进行异步处理。
同步行为还能在其他几个方面提升用户体验。假设客户有多个邮件地址,其中一个用于发送票据。如果系统操作人员想要删除这个邮件地址,票据模块需要阻止这个删除操作,因为这个地址还在“使用”当中。换句话说,我们需要强制保证模块间的引用完整性约束。
在微服务架构里,实现完整性约束的方式更为复杂。一种实现方式是让客户服务询问其他使用邮件地址的服务是否允许删除这些数据。这种方式需要先找出这些服务,然后向它们发起询问。但如果其中的某个服务不可用该怎么办?客户服务可能将邮件地址“逻辑”删除,票据服务在必要的时候通过补偿操作恢复这些数据。这种方式或许可行,但容易让人感到困惑。一般来说,基于异步通信的设计容易导致竟态条件,所以在使用时需要谨慎对待。
相反,设计良好的单体能够简单地处理上述情况。在文章的第2部分,我们将介绍一些设计方案。这些方案遵循这样的一种原则,即模块间必须是无耦合的,模块间的交互需要在进程里进行。
复杂性(异步)
在一个模块化的单体里,模块被部署在相同的进程空间。因此,模块间的通信无非就是通过方法调用。
而微服务架构的服务间通信需要通过网络进行。
- 如果服务间的交互是同步的,可以使用REST,为此需要作很多技术选型:使用何种数据格式(XML还是JSON),使用何种编码(HAL、Siren、JSON-LD或者自定义的编码),如何将HTTP方法映射到业务逻辑,是使用“HATEOAS”还是简单的基于HTTP的RPC,等等。还需要对REST API进行文档化:Swagger、RAML、API Blueprint,等等。
或许还有其他方面的因素需要考虑,比如是否使用GraphQL。
服务间的同步交互必须具有容错能力,否则就又变成了分布式单体。也就是说,服务间需要预先做好准备,如果被调用的服务不可用,需要有某种回退机制。
- 如果服务间交互是异步的,那么还需要做一些额外的选型:数据格式(除了XML、JSON,还有protobuf),如何定义不同类型的消息语义,如何演化消息类型,交互应该是一对一还是一对多,交互应该是单向的还是双向的,如何组织事件流,是否应该使用saga来管理状态变更,等等。
除此之外,还需要决定使用何种消息总线:AMQP/Rabbit、ActiveMQ、NSQ、Akka Actor,等等。有些消息总线与编程语言是绑定在一起的,所以还要对服务的开发语言进行约束。
除了网络交互,微服务架构还需要其他方面的支持:聚合日志、监控、服务发现(隐藏服务的实际物理端点)、负载均衡和路由。这些组件的重要性不能被低估,否则,在出现问题的时候,比如当用户要结账时,就无法知道进程间是怎么交互的。
换句话说,微服务架构涉及到很多技术问题,而这些技术其实并不是为了解决业务问题。现在有很多开源的软件库可供我们使用,不过即使是这样,我们仍然要做很多工作,而且对于大多数应用来说是过度工程化了。
当然,这并不是说单体就不需要平台的支持。单体可以处理复杂的领域,重点是,有了平台的支持,开发人员可以专注在领域的开发上,不需要操心横断面的技术问题。一些成熟的框架已经简化了事务、安全和持久化方面的工作。
单体自身也存在一些问题。最大的一个问题是,随着时间的推移,呈现层、领域层和持久化层之间会发生渗透,有可能再次成为大泥球。六边形架构(haxagonal architecture)强调呈现层和持久化层要依赖领域层,但是反过来就不行。但是人们一般不会完全遵循架构模式,单体系统的层间渗透就变得很普遍,特别是UI层。
在文章的第2部分,我们将看到可以使用一些框架防止层间渗透,它们把UI呈现层看成另一个横断面关注点,也就是裸对象模式(naked objects pattern)。这也意味着负责处理复杂领域的开发人员可以专注在核心业务逻辑的开发上。
伸缩性(效率)
迁移到微服务架构的一个主要原因是微服务能够带来更好的伸缩性。不管是单体还是微服务系统,流经某些模块或服务的流量总是比其他的要大。在微服务架构里,每个微服务就是一个独立的操作系统进程,所以它们能够进行独立的伸缩。例如,如果票据服务成为整个系统的瓶颈,那么就可以多部署几个票据服务实例。现在有很多Docker容器编排框架(Kubernetes、Docker Swarm、DC/OS和Apache Mesos),如果说它们还不够成熟,最起码它们在走向成熟的路上,只不过我们需要投入时间去学习如何使用它们。
单体需要通过部署多个实例进行伸缩,需要使用更多的内存。不过,即使是这样,有时候也难以解决伸缩性问题。例如,如果伸缩性问题存在于数据库上,那么增加更多的单体实例只会让事情变得更糟。还需要注意的是,单体系统里不能出现只允许单个实例运行的情况,这样会导致无法伸缩,所以如果存在这样的问题,需要将其修复。
另外,从网络和资源方面来看,微服务不如单体来得高效,微服务间的网络交互需要额外的处理时间,而在单体里,只有进程内的方法调用。而且微服务会使用更多的内存,因为每个微服务都需要有自己的JVM或.NET运行时。
单体就像把所有鸡蛋放在一个篮子里。对于关键性的模块或服务,架构师会使用合适(昂贵)的技术栈来获得必要的可用性。对于单体来说,所有的代码必须被部署在这样的技术栈上,但这样可能会造成成本的上升。对于微服务来说,架构师可以把不是很重要的服务部署在比较便宜的硬件上。
也就是说,高可用解决方案变得不那么昂贵了,这要归功于Docker容器技术和编排工具(Kubernetes等)的崛起。微服务架构和单体架构都能从这些技术中得到好处。
灵活性(实现)
在单体系统里,所有的模块都需要使用相同的编程语言编写,或者至少能够在相同的平台上运行。除此之外,还有其他方面的限制。
基于JVM的编程语言有很多,它们使用各种各样的编程范式:Java、Groovy、Kotlin、Clojure、Scala、Ceylon和JRuby。这些语言都有自己的社区,而且都很活跃。通过使用Eclipse Xtext或者JetBrains MPS还能构建自己的DSL。
在.NET平台上,可用的编程语言就少一些。C#是一门优秀的面向对象编程语言,而F#是非常好的函数式编程语言。JetBrains的Nitra可以用于创建.NET平台的DSL。
在微服务架构里,编程语言的选择具有很大的灵活性,因为每个服务都运行在自己的进程空间里,所以理论上它们可以使用任何一种语言编写。JVM或者.NET,或者Haskell、Go、Rust、Erlang、Elixir,等等。而且,微服务的粒度较细,所以很容易使用不同的语言重新实现一个微服务。
不过,是否有必要使用多种语言来实现一个系统呢?或许,对于一小部分服务来说,如果编程语言的某些特性恰好与它们的领域问题相吻合,那么就可以使用这些语言进行开发。使用太多的编程语言只会让系统变得难以开发和维护。
实现当中总会存在一些约束。如果服务间交互是同步的,那么就需要使用回路断路器,而且要确保服务具有一定的弹性。Netflix为JVM平台开源了一些工具,不过如果是其他平台,可能需要自己开发类似的工具。如果服务间交互是异步的,那么要确保所使用的语言有合适的适配器,可以用于从事件总线上接收消息或向事件总线发送消息。
在实际开发当中,需要使用“小众”编程语言来开发的模块是很少的。对于这些模块来说,可以使用特定的语言进行开发,然后把它们通过内存(如果可能)或者网络链接到系统里。而对于其他大部分模块,最好使用主流的JVM或.NET语言来实现。
(开发)效率
软件开发是一项相当耗费劳动力的工作,所以需要提升开发人员的生产效率。微服务可以带来生产效率的提升,因为系统的每个部分都是轻量级的,不过有时候这样会过于简单化了。
开发人员可以在他们的IDE里快速地加载微服务的代码,可以很快地运行和测试微服务。不过开发人员需要编写额外的代码用于服务间的交互,而且要让整个系统运行起来(为了进行集成测试),需要做很多协调工作。Docker Compose或者Kubernetes在这方面可以起到一些作用。
对于模块化的单体,开发人员也可以专注在单个模块的开发上。事实上,如果模块有自己的代码库,新的特性可以进行单独的开发和测试,所以这方面的好处与微服务是相似的。
如果模块没有自己单独的代码库,那么单体架构需要为开发人员提供一些可能性,让他们可以选择性地启动必要的相关模块来测试他们所负责的模块。开发人员的开发体验与微服务也是相似的。反过来说,如果单体架构无法提供这种可能性,就会严重影响到开发效率。大型的单体需要数分钟时间来启动,这也会影响到测试的执行时间。
结论
文章的第1部分对单体和微服务架构进行了比较,总结了各自的优缺点。
在某种程度上说,模块化的单体和微服务架构有点相似,它们都是按照模块化来设计的。它们之间的不同点在于,在部署阶段,单体进行整体的部署,而微服务则一路下来保持模块的特性。这个不同点所影响到的面是很广的。
在决定采用何种架构时,可以问自己一个问题:“你想要达到的最优效果是什么?”图2列出了两个需要重点考虑的点。
图2:伸缩性和领域复杂性
如果领域相对简单,而且要达到“互联网规模”,那么采用微服务架构会比较合适。不过采用微服务架构要求在前期定义好每个微服务的职责和接口。
如果领域相对复杂,而且规模有限(比如只在企业内使用),那么采用模块化单体会比较合适。随着你对领域的深入了解,对单体职责的重构会相对简单。
对于复杂的大规模系统,我认为进行伸缩性方面的优化不是一个明智的做法。相反,我们可以先构建一个模块化的单体来解决复杂的领域问题,然后随着规模的增长,逐步向微服务架构迁移。这种方式避免了在一开始就使用很高的成本来实现微服务架构,在等到规模增长到一定程度(有了一定的利润)之后,根据业务情况追加投入。这是一种混合的架构方式:先从单体开始,在必要的时候再抽离成微服务。
如果你采用了“单体先行”的架构,那么你会发现两种架构之间的相似性。
单体里的模块和微服务里的模块都只负责处理自己的持久化数据。不同之处在于,集中部署的模块可以利用数据存储引擎所提供的事务和引用完整性检查。
单体和微服务的模块之间都需要定义好交互接口。不同之处在于,单体的模块间交互式在进程内进行的,而微服务需要通过网络。
只要记住这两点,在你需要将模块化单体转成微服务时,事情会变得容易得多。
尽管如此,构建模块化的单体仍然需要谨慎对待。在文章的第2部分,我们将会介绍一些构建模块化单体的模式,并例举了一个运行在JVM平台上的例子。
关于作者
Dan Haywood是一个独立咨询顾问,他擅长领域驱动设计和裸对象模式,并因此为人们所熟知。他是Apache Isis项目的贡献者,Isis是一款用于构建行业应用后端的框架,并实现了裸对象模式。Dan作为技术顾问在基于.NET平台的爱尔兰政府决策性裸对象系统上工作了13年以上,这个系统现在成为政府主要的社会福利管理系统。他还在Eurocommercial Properties工作了5年,开发了Estatio这个开源的基于Apache Isis的房地产管理系统。读者可以关注Dan的Twitter和Github主页。