前言
大家都知道,基于单体(Monolith)和微服务(Microservice)架构的争论已经存在多年,正如我们对胖客户端、瘦客户端孰好孰坏的争论一样,有必然的历史演化,也有各自的优缺点。架构师们总是在考虑,我们是要一个中心化、全能多才的单体,还是百花齐放、各自为政的微服务群体,各种基于成本、交互、部署等等的讨论应该不会停下脚步。这里,我们不做过多的深入探讨和介绍,而本文正是这些讨论中的一个很好的例子,供大家思考。
Joakim Tengstrand 近期在他的推特上提到,看到 Robert C. Martin(Bob
大叔)多年前描述的基于 JAR 包微服务的不足之处,于是写下了这篇有趣的称为微单体(micro monolith)的文章,并在Git上发布了基于
Java 和 Clojure 的两种源码包供下载。接下来将逐一介绍微单体的概念、工作方式、各自的优缺点、示例、实践等环节。
微单体是什么
用成千上万或数百万行代码来编写高质量的软件,可能是开发人员所能承担的最具挑战性和最复杂的任务之一。Tengstrand
利用软件模块化这种非常简单的方式,实现了一个方案并期望能有助于完成这项挑战,提供了基于 Java 和 Clojure
的代码供参考。
随着系统变得越来越大,最终会达到一个临界点,作为一个单体(monolith)它变得难以管理。每一行代码的添加,都会让系统变得更加难以理解、变更和重用。虽然微服务(microservice)试图解决这些问题,但也带来了额外的复杂性以及集成的成本。
微单体架构的核心原则是保持硬件、软件和数据紧密地结合在“一个地方”。这样处理可以简化事情,摆脱不必要的协调工作。如果我们从同一个地方直接访问数据,性能也会得到改善。当设计系统时,可以像微服务一样使用小的、孤立的、可组合的构建块(building
blocks),又可以像单体一样通过一个地方执行它们,就能从两方面都达到最优。
从上面的介绍可以看出,单体就像一个胖服务,微单体利用微服务架构的优势将处理工作从逻辑上分拆出去,在此基础上增加一层调度(编排服务)来管理所有的微服务。这样一来,使得业务的完整性和一致性得到了较好的保证,也解决了跨服务集成的问题。用一个简单的例子来描述,单体就好像把所有的文件放在了一个文件夹里,微服务则试图将它们分类并放在不同文件夹中,而微单体的方法是生成了一个叫做
development 的文件夹,里面保存了所有文件的快捷方式(shortcut),这样更易于根据不同的业务场景来管理和访问这些“文件”。
如何工作
在版本控制系统里为每个服务生成一个项目,这样可以得到各自的 JAR 包(假设在JVM上运行,或者类似平台)。它们就是构成系统的构建块,并最终组成整个生产系统。生成一个
development 项目,通过服务把所有源代码连接起来,这样就可以在里面直接运行源代码,就像是只有这一个项目一样。
接下来介绍微单体架构的优缺点:
优点
简单性(关注点分离,代码直接调用,消除了网络 API 的复杂度)
卓越的性能(没有访问服务的网络调用)
模块化、可组合的服务(在不同的系统中重复利用)
跨服务事务的数据一致性(无需考虑最终一致性)
减少 DevOps 和硬件/托管的费用(在单机上运行的系统)
易于测试(可以对整个系统进行一体化测试)
更快和更有效的开发体验(跨服务导航、重构和调试 + 变更无需重建服务)
缺点
必须在所有服务中使用相同的编程语言(*)
development 项目需要一些额外的设置(创建符号链接 symbolic links)
操作系统必须支持符号链接 symbolic links
共享相同路径的资源在所有服务中必须具有唯一的名称(它们有不同的内容)
IDE 内置的版本控制失效(因为微单体架构可能不支持 IDE)
(*) 并没有强迫只使用一种编程语言,目标是要让 development 项目发挥其优势(例如跨服务的重构和调试),这时最好的选择是使用一种语言。其次是混合使用,可以使用在同一个平台(比如
JVM)上运行的语言,像 Java、Scala、JRuby、 Clojure(如果使用本地接口JNI,还可以选择
C),但如果一个服务做了变更,就需要生成一个新的 JAR 包并共享给其他服务。
上面介绍了微单体方法的概念,还没有提到它是如何在实践中工作的,现在让我们通过 Java 和 Clojure
的两个示例来进行演示。所有示例的代码可以在这里找到。注:在实际系统中,它们被存储在单独的资源库里并且彼此隔离,这里为了方便起见,它们被存储在同一个资源库中。
下面 Java 和 Clojure 的例子会实现相同的“解决方案”,利用一个假造的 REST API
来编排一些服务,并暴露findaddresses,douserstuff 和 domoreuserstuff
供调用。
Java - 示例代码
Java 是一种流行语言,这里将展示在面向对象语言里的微单体架构是什么样子的。
在处理 development 项目时,你在大部分时间里会是一个开发人员。虽然所有的服务都被存为各自独立的项目(Git),这里我们通过一个技巧,利用符号链接把所有源码“放到”一个单独的项目中。IDE
并不关心地址是“真正的”还是一个链接,都采用箭头来标记它们(至少在这个例子里是这样的):
项目在本地check out后,这些链接在 Linux 或 Unix 上 马上就能工作。如果是其它平台,可以参考这样类似的脚本,手动地创建
development 项目。
建立了这个项目,通常意义的甚至跨服务的开发环境所具备的调试、重构和搜索等等方面的好处,我们都可以实现。这一点非常强大并且节省了时间,每次代码变更不需要重建服务,这样使得工作流程非常高效和快乐。
依赖性
在设计系统时,需要决定是否允许服务获得外部库(external libraries)具体实现的信息。在本文的例子里,我们选择信息透明,最好在所有服务中使用相同版本的外部库。
另一种选择是通过在内部服务与外部库之间添加接口来集成,这样一个服务就不用知道具体库的信息,比如 log4j-1.2.17.jar,只需要生成一个接口
log4j-api,这样编排服务(orchestrator service)就能把它注入到所需的服务中去了。
编排服务(The orchestrator service)
编排服务就是把所有服务都放到一起的那个服务。一个系统可以有多个编排服务,这里的例子只有一个 RestService,它依赖地址,电子邮件和用户这几个服务,并在
pom.xml 里进行指定。
如果服务 A 需要调用服务 B 里的函数 f,可以通过编排服务把函数 f 注入到服务 A 中。这里没有强制要求一次只注入一个函数,但是这样做可以降低服务间的耦合,增加可变性(changeability)和可测性(testability)。我们将使用“微注入”这个术语,特指一次只注入一个函数的意思。
测试
微单体架构鼓励让测试变得简单、容易,就像微服务一样,要让每个服务的独立测试更方便。相比于微服务, 微单体就像一体化部署在一台机器上一样,它让测试变得更加的简单(比如本例的
REST API)。
例子里包含了一个测试数据生成器,它帮助我们在已知的状态下建立数据库。可以存在一个用户表和一个地址表相关联,然后就得到一个
UserService 和一个 AddressService。测试数据生成器可以方便地设置数据库的已知状态,这有助于编写集成测试。这可以在某个服务中完成并且实现跨服务调用,例如
AddressServiceTest 和 UserServiceTest。
Clojure - 示例代码
Clojure 是一个功能强大的语言,能在 JVM 上运行。下面将展示在 Clojure 这样的函数式语言中如何使用微单体架构。所有
Clojure 代码可以在这里找到。
它的 development 项目看起来是这样的:
Clojure 版本的结构基本类似 Java 版本,但函数都存储在不同命名空间而不是类中,也不需要像Java一样为地址和电子邮件服务添加额外的
API 层。Clojure 版本更加简洁,可以用大约 200 行代码实现 Java 里 400 行代码能完成的工作。
Clojure 里的微注入会更简单,可以用宏注入(inject macro)的方式来注入函数。这里的例子里,在命名空间rest.service
的第8行,就是用函数 email/send-pdf-email! 替换了原来的 user.service/send-pdf-email!。
实践经验
Tengstrand 和他的团队已经把微单体架构应用到了一个真正的生产系统。他们搭建了这样一个架构,每一个服务在
Git上 有各自的资源库。迁移到微单体是非常顺利的,要做的就是丢弃 30% 的代码,并把所有 REST
服务调用替换成简单的函数调用。这个过程中消失的不仅仅是 REST 部分,还有很多复杂的状态和错误处理。
从一开始,就像生产环境一样设置开发环境,每个服务就是一个 Java 存档(JAR 文件)。缺点是每次的服务变更,必须重建这个
JAR 文件,以便它可以供其他服务使用。另一个缺点是,我们必须重新启动 REPL(是的,使用的是Clojure!),这样做耗费了时间并把在
REPL 上工作的一些快乐也带走了。
于是,Tengstrand 的团队想出了新的方法来设置 development 项目,只需启动一次 REPL
,然后可以继续工作而不会被打断,这样一来开发人员就幸福多了。另一件事是,他们意识到服务里有一些灰色标记的死掉(dead)的代码,现在也可以去掉了。
另一个设计上的选择是采用 Datomic 数据库,它真的与微单体架构很适用,既简单又强大。你可以在这里了解它的更多架构细节。
他们使用测试数据生成器来处理几乎所有的服务,让集成测试更简单。之前他们发现可以改进服务中的一些变量和函数的命名,但更改之前必须手动搜索和替换所有的服务,这样做费时又容易出错。最后他们并没有做这些小的改动,而是通过development
项目,利用 IDE 对重构的支持瞬间就做到了变量和函数的重命名。
总结
微单体提出了如何构建系统的一个简单模式,虽然和微服务竞争但并不能完全取代它,因为后者肯定有它的位置。如果有需要的话,随时可以同时使用它们。
最后 Tengstrand 建议,如果在构建系统时非常关注简单性和可组合性,那么一定要尝试下微单体架构,享受这个架构带来的高效,以及测试、生产环境的简洁。 |