要点
- 在选择系统架构时,伸缩性和领域复杂性是两个重要的考虑点。
- 模块化单体存在 JAR 包地狱(JAR hell)问题,不过借助一些工具可以缓解这个问题。
- 单体内的模块(像微服务一样)需要处理自己的数据,不过模块到 RDBMS 之间的简单映射会导致数据库难以维护。有一些模式可用于处理这方面的问题。
- 对于模块化单体来说,底层的技术平台需要尽量处理好横断面(cross-cutting)问题,让开发人员可以专注在复杂的业务领域上。Apache Isis(以下简称 Isis)很适合用来处理这方面的问题,它采用了六边形架构(hexagonal architecture),而且实现了裸对象模式(naked objectes pattern)。
- 开源应用 Estatio(基于 Isis)是一个很好的模块化单体示例。它可以帮助你判断你的领域是否适合使用单体(或单体先行)架构。
在文章的第一部分,我们比较了单体(更准确地说是模块化单体)和微服务架构之间的优缺点。同时,我们还讨论了可维护性、事务性、复杂性、伸缩性、灵活性以及开发效率。
我们得出的结论是,架构的选择取决于实际情况。图1 列出了两个重要考虑点。
(点击放大图像)
图 1:伸缩性和领域复杂性
如果领域相对简单,而且要达到“互联网规模”,那么采用微服务架构会比较合适。不过如果采用微服务架构,需要在前期定义好每个微服务的职责和接口。
如果领域相对复杂,而且规模有限(比如只在企业内使用),那么采用模块化单体会比较合适。随着你对领域的深入了解,对单体职责的重构会相对简单。
对于复杂的大规模系统,我认为进行伸缩性优化不是一个明智的做法。相反,我们可以先构建一个模块化的单体来解决复杂的领域问题,然后随着规模的增长,逐步向微服务架构迁移。这种方式避免了在一开始就使用很高的成本来实现微服务架构,在等到规模增长到一定程度(有了一定的利润)之后,根据业务情况追加投入。这是一种混合的架构方式:先从单体开始,在必要时再抽离成微服务。
实现微服务架构是一件极具挑战性的事情,不过构建一个模块化的单体也不能掉以轻心。在文章的第一部分,我们指出了一些潜在的问题。
- 模块化单体必须由设计良好的模块组成,不过依然可能出现循环依赖问题。同时还可能出现 JAR 包地狱问题,我们稍后将会对此展开说明。
- 虽说每个模块应该负责处理自己的数据,但可以从“战术”上将多个模块的数据保存在同一个数据存储引擎里,但要确保不要让数据库成为一个“大泥球”。
- 模块间的同步交互带来更好的用户体验。不过,这些模块必须具备独立演化的能力。演化较慢的模块不应该依赖经常发生变更的模块。
- 为了让开发团队专注在领域问题上,需要让平台或框架尽可能多地处理横断面问题。不过尽管如此,业务逻辑仍然可能从领域层渗透到相邻的呈现层或者持久化层。
在第二部分,我们将介绍如何解决上述的几个问题,我们还会将一个真实的基于 JVM 的模块化单体作为例子,它使用了一个开源框架来管理横断面问题。
无循环依赖和 JAR 包地狱问题
在模块化单体里,我们需要划清模块的边界。
第一种方式是利用编程语言的特性对模块的功能进行分组,比如包(Java)或者命名空间(.NET)。不过这种方式并不能将模块完全与应用的其他部分完全区别开,而且并不能保证包或命名空间不会出现循环依赖。如果只使用这种方式进行模块化,最终得到的很可能不是一个模块化单体,而是一个大泥球。
相反,我们需要更严格的结构化,并且可以使用工具来强化模块间的无循环依赖关系。在 Java 平台上,可以使用 Maven 来管理多模块项目,而在.NET 平台上,可以使用包含了多个 C#项目或 F#项目的单个 Vistual Studio 解决方案。这些代码被编译到一起,构建工具(Maven 或 Visual Studio)可以确保模块间不存在循环依赖。
第二种方式有一个缺点,因为所有的代码都存在同一个代码库里,并被编译到一起,所以它们拥有相同的版本号,并且需要进行整体的测试。但在实际当中,不同模块的演化速度是不一样的。对于变更缓慢的代码来说,没有必要进行持续的构建和测试。
第三种方式是将模块移动到自己的代码库,并拥有独立的版本号。在.NET 平台,我们可以将每个模块打包成 NuGet 包,而在 Java 平台,我们可以把模块打包成 Maven 模块。对于使用这些模块的主应用来说,这些模块与第三方的依赖包是不一样的。
不过,第三种方式也可能出现循环依赖。例如,假设customers 1.0 模块依赖了addresses 1.0 模块,如果开发人员创建了addresses 1.1,并且依赖了customers 1.0,那么customers 和addresses 之间就形成了循环依赖,这当然不是一件好事。
为了解决这个问题,我们要确定依赖的方向性:是要让customers 依赖addresses,还是反过来?我们可以遵循稳定性依赖原则:应该让不稳定(经常发生变更)的模块依赖稳定(很少发生变更)的模块。在我们所给出的例子里,问题变成:哪个比较不稳定,是customers 还是addresses?如果搞错了依赖的方向,可以使用依赖反转原则进行重构。
依赖的方向性很容易识别。有些模块只包含了引用数据,比如tax rate 表或者currency。其他模块几乎不包含或者只包含少量的引用数据,比如counterparties 和fixedassets,或者instruments。另一个例子是“文件柜”,它只用于存储文档或者通信资料。应该让其他模块依赖上述的几个模块,而不是反过来。
我们还可以使用一些更加科学的方式,通过版本控制的历史数据计算每个模块的相对波动数量。
我们可以将稳定的模块移动到它们自己的代码库里,一旦它们被移到自己的代码库里,其他应用就可以重用它们。
实际上,我们只要求模块间具有稳定的接口,至于接口背后的实现是否稳定并不重要。将不稳定的实现代码移出主代码库是有好处的,因为这样可以避免出现主代码库的代码波动。不过这要求模块间具有正式定义的接口。
为了锦上添花,在出现循环依赖时,我们需要提前得到告警,最好是在构建阶段或者持续集成管道里。这是一个可实现的目标。
让我们回到之前的例子,customers 1.0 依赖了addresses 1.0,同时addresses 1.1 依赖了customers 1.0。因为应用会指向每个模块的最新版本,我们就会知道customers 1.0 和addresses 1.1 之间存在循环依赖。
这个依赖聚合问题通常被称为“JAR 包(或DLL 库)地狱”。图2 展示了一个很常见的例子,一个应用使用了两个库,这两个库反过来使用了具有版本冲突的基础库。
(点击放大图像)
图2:依赖聚合冲突
这个应用在JVM 上运行时,会抛出运行时链接错误。在一般情况下,JVM 只会加载某个版本的类一次。
为了解决这个问题,可以使用Maven 的 Enforcer Plugin 标记依赖聚合问题,在必要情况下可以让构建失效。开发人员可以在 pom.xml 文件的
如果使用了 NuGet 3.x,那么“就近”依赖原则可以达到类似的效果。
有些项目经常性地发布主版本并删除弃用 API,比如 Guava ,图 2 中所示的单体因此无法正常运行。对于这种情况,我们必须通过更新到最新版本来解决依赖冲突问题。如何不允许这么做,或许可以尝试影子化(重新打包)依赖。如果还是不允许,那么只能重构代码来解决冲突问题,甚至考虑移除依赖。
OSGi 应用(基于 JVM)通过使用不同的类加载器来加载每个模块链,从而避免了这个问题。不过,虽然 OSGi 有一定的市场,但它只是一种例外,我们不能将其视作规则。而且在 Java 9 发布 Jigsaw 模块系统之后,或许 OSGi 会失去它现有的地位。不过 Jigsaw 也并非银弹:它并没有尝试解决依赖聚合问题,而是把这个问题留给了构建工具去解决(比如 Maven)。
总结一下,使用 Maven 的 Enforcer Plugin 解决依赖聚合问题,如果出现了冲突,可以在
数据
与微服务架构一样,模块化单体里的每个模块负责持久化自己的数据。在大多数情况下,这些模块会使用一个关系型数据库存储它们的实体:关系型数据库在企业 Web 应用领域仍然占据着重要地位。这些表被聚集到单个 RDBMS 里,从而可以使用事务。
在模块实体到数据库的映射方面,因为每个模块都有自己的包或命名空间,所以每个模块需要被映射到数据库的 schema 上(模块的实体映射成 schema 的表)。模块或 schema 的名字应该成为父表标识字段的值(例如在映射继承关系时)。
领域对象模型和关系型数据库之间的一个关键区别在于实体间关系的表示方式。在内存里,通过对象指针表示对象间的关系,而在数据库里则使用外键。如图 3 所示,类(左边)到表(右边)之间的直接映射会导致实际的依赖关系方向在数据库里出现反转。
(点击放大图像)
图3:类间关系与表间关系
持有Customer 实体的是Customers 表和Addresses.customer_id 字段(因为这个外键与Customer.addresses 相关)。即使在代码里没有循环依赖,但是到了数据库里,它们却变成了一个大泥球。
不过我们可以解决这个问题。为了将Customer 信息保存在相同的schema 里,我们要把外键移到一个连接表里,如图4 所示。性能的损失其实是微不足道的。
(点击放大图像)
图4:连接表
虽然我认为我们不应该这样处理同一个模块实体的表关系,但如果大家坚持要使用连接表,那我也没有什么可说的。
对象间的多态关联更加复杂。例如,我们可能希望将Documents 添加到所有的领域对象里。如图5 所示,我们可以引入Paperclip(一个接口)的概念,然后使用具体的实现作为连接表。
(点击放大图像)
图5:多态关联
每个Paperclip 被映射到两张表,一张在documents schema 里,一张在它的实现schema 里,比如PaperclipsForCustomer。Paperclips.discriminator 字段指定了具体的实现类型。
这种映射的好处在于,在数据库里我们仍然可以维护所有表间的引用完整性,同时,在代码里我们可以正常地使用Paperclip 接口。
这种模式解决了数据库的结构耦合问题,但并不一定能解决行为耦合问题。在文章的第一部分,我们指出,开发人员可以直接使用SELECT 语句从模块A 里查询模块B 的数据。那么这个问题该如何解决?
在我所参与的单体系统里,我们既要保证数据库的表间交互,又要禁止临时的SELECT 查询。在我参与的另一个.NET 单体项目里,我们使用了 Entity Framework ,每个模块对应一个独立的 DB 上下文,这样也可以解决结构化问题。EF 只管理模块或 DB 上下文内的外键,所以我们使用了之前提到的多态连接模式来处理模块间的关系。在 Java 平台,我们使用了 DataNucleus (实现了 JDO 和 JPA API),每个模块有自己的持久化上下文。
你也许会问:对于不能使用 ORM 框架的场景该如何处理?可以这么说,花点时间学习如何使用 ROM 是值得的,虽然可能派不上用场。在我所参与两个单体项目里,我们面对的是一些特殊的情况,比如从多个模块获取大量的数据,我们使用视图将多个相关模块的表 JOIN 起来。ORM 框架并不知道也不关心实体是被映射到表还是视图上。这是对性能的优化:视图可以统一高效地处理业务数据。在代码发生变更时,视图的定义也能够被追踪到:当我们为了满足新的需求而打破模块边界时,我们可以看到我们所做的变更。
事务性(同步性)
业务需求导致多个模块发生变更,这是很常见的事情。例如,假设我们要在票据系统里执行票据操作,一般情况下只会修改票据模块的状态,比如创建 Invoice 和 InvoiceItem 对象。不过,如果有些客户要求使用邮件发送他们的票据,那么就会涉及到创建 Document 对象(文档模块)和 Communication 对象(通信模块)。
在微服务架构里,服务之间没有事务,也就是说,我们必须使用消息来协调这些变更。因此,系统只有最终一致性,如果出现了问题,需要使用补偿操作来“回退”变更。在某些系统里,这种最终一致性的行为容易让用户和开发人员感到困惑。例如, CQRS 模式对读写进行了分离,一个服务写入的数据不会立即被另一个服务读取到。
而对于单体来说,如果票据模块、文档模块和通信模块存在于同一个 RDBMS 里,那么我们就可以依赖 RDBMS 的事务机制来确保变更状态的原子性。从用户角度来看,一切都保持一致,不存在令人困惑的中间状态,无需做出任何补偿操作。对于开发人员来说,他们可以立即读取到写入数据库的数据。
同步行为可以在其他方面为用户体验带来改进。假设每一个 Customer 都有一个关联的 EmailAddresses 集合,并且其中的一个邮件地址用于发送票据。如果用户想要删除这个地址,票据模块要否决这个删除操作,因为这个邮件地址“正在使用中”。也就是说,我们想要在模块间使用强制的引用检查约束。
要在微服务里支持这种约束是很复杂的,而在单体里我们就可以很轻松地做到。我们可以使用一个内部事件总线,客户模块通过它广播删除邮件地址的意向,其他模块里的订阅者可以否决这个变更。
public class Customer { ... @Action(domainEvent = EmailAddressDeletedEvent.class) public void delete(EmailAddress ea) { ... } }
清单 1:Customer 要删除邮件地址,触发一个事件
订阅者代码如下。
public class InvoicingSubscriptions { @Subscribe public void on(Customer.EmailAddressDeletedEvent ev) { EmailAddress ea = (EmailAddress)ev.getArg(0); if(inUse(ea)) { ev.veto(“Email address in use by invoicing”); } } ... }
清单 2:删除事件的订阅者
底层的平台在执行删除之前,会将 EmailAddressDeletedEvent 发送到内部的事件总线上。如果这个邮件地址在使用当中,订阅者可以否决这个删除操作。
另一种方案是为客户模块声明一个服务提供者接口(SPI),其他模块可以实现这个接口。
public class Customer { ... public void delete(EmailAddress ea) { ... } public String validateDelete(EmailAddress ea) { return advisors.stream() .map(advisor -> advisor.cannotDelete(ea)) .filter(reason -> reason != null) .findFirst().orElse(null); } public interface DeleteEmailAddressAdvisor { String cannotDelete(EmailAddress ea); } @Inject List<DeleteEmailAddressAdvisor> deleteAdvisors; }
清单 3:Customer 删除邮件地址的动作,包含了一个验证步骤和一个 SPI
一个实现了 SPI 的类。
public class Invoicing implements DeleteEmailAddressAdvisor { public void cannotDelete(EmailAddress ea) { if(inUse(ea)) { return “Email address in use by invoicing”; } return null; } ... }
清单 4:实现了 SPI 的票据模块
validateDelete 是一个守护方法,在 delete 方法之间调用。它用于判断邮件地址的删除操作是否允许被执行。这个方法遍历所有注入的 SPI 实现,如果 SPI 返回一个非空值,说明了不能删除邮件地址的原因,那么就不能执行删除操作。
还有另外一种使用场景。从图 5 我们可以看到,不同的模块通过别针将文档附加到它们的实体里。或许有人认为文档模块可能会提供“附加”操作,但实际上,只有那些存在别针的实体才能执行这个操作。文档模块通过向内部事件总线发布事件或者通过 SPI 来发现哪些实体暴露了“附加”操作。
@Mixin public class Object_attach { private final Object context; public Object_uploadDocument(Object ctx) { this.context = ctx; } public Object attach(Blob blob) { Document doc = asDocument(blob) paperclipFactory().attach(context, doc); } public boolean hideAttach() { return paperclipFactory() == null; } public interface PaperclipFactory { boolean canAttachTo(Object o) void attach(Object o, Document d); } PaperclipFactory paperclipFactory() { return paperclipFactories.stream() .filter(pf -> pf.canAttach(context)) .findFirst().orElse(null); } @Inject List<PaperclipFactory> paperclipFactories; }
清单 5:通过 Mixin 将文档附加到任意的对象上
Object_attach 类充当了 mixin 或 trait 的角色,为所有对象提供了附加动作。不过,(通过方法隐藏)这个动作不会在 UI 上显示,除非有能够将文档附加到特定领域模型对象的 PaperclipFactory 来充当 mixin 的上下文。
平台的选择
不管你选择了单体还是微服务,你都需要某种平台或者框架来运行它们。
对于微服务架构来说,在选择平台时主要关注网络方面的问题:它要保证服务间的交互畅通无阻(协议、消息编码、同步 / 异步、服务发现、回路断路器、路由器,等等),而且能够让整个系统运行起来(Docker Compose,等等)。用于实现每个微服务的语言就没有那么重要了,只要开发出来的应用能够打成可部署的包,比如部署在 Docker 容器里(当然,如果选择了某种开发语言,项目团队必须具备相应的技能进行初期的开发以及后期的维护和支持)。
对于单体架构来说,也需要一个共享的平台,不过此时会更多地关注开发语言和生态系统。至少,需要决定是使用 Java 平台还是.NET 平台。在选定平台之后,或许还需要选择一些框架,比如 Java EE 或者 Spring。
单体的优势在于它能处理复杂的领域逻辑,而底层平台要尽可能地处理好技术和横断面问题,比如安全、事务和持久化(当然还有其他方面的东西)。另外,业务模块不应该依赖技术模块,我们要尽可能地接近六边形架构。
单体平台也需要提供工具,用于业务模块之间的解耦。对于单体来说,它的解决方案与微服务类似:使用事件总线。不同之处在于,单体的事件总线是在进程内部,而且具有事务性。
一个(模块化)单体示例
我们使用一个真实的案例来说明模块化单体,作为文章的结尾。
这个应用程序叫作Estatio,是 Eurocommercial Properties 的一个票据系统。Eurocommercial Properties 是一家实业公司,目前在欧洲的 3 个国家运营着 34 家购物中心。Estatio 的源代码可以在 Github 上找到。
(点击放大图像)
图6:Estatio 的界面截图
Estatio 的底层使用了 Isis 框架,它是一个基于 JVM 的全栈框架,用于处理所有常见的横断面问题,比如安全、事务和持久化。除此之外,它还遵循裸对象模式,通过Web UI 或REST API 自动渲染领域对象。一个ORM 框架被用于将领域对象映射到一个持久化层,而Isis 将领域对象映射到呈现层。
因为UI 是通用的,所以在不改变领域对象模型的情况下可以持续地对UI 进行改进和增强。例如,上一个版本使用 Bootstrap 对 Isis 的视图进行了改进。在这个版本里进行更新的每一个应用都使用了这个改进过的视图。新版本添加了地图、日历和 Excel 导出功能,而且框架会在需要它们的地方自动将它们渲染出来。
与业务领域对象的交互贯穿了 UI 和整个横断面,Isis 解决了所有相关的问题。例如,Isis 为每一个调用操作或属性变更创建备忘命令(序列化到 XML),在事务完成后将命令发布到事件总线上,比如 Apache Camel 。命令里包含了审计信息,为每个领域对象的每一个变更提供了完整的可追踪性。
框架在内部构建元模型(metamodel,类似 ORM 框架),元模型不仅可以用于 UI 和 REST API,它还有其他用途。例如,通过导出一个 Swagger 接口文件可以实现基于 REST API 的自定义 UI,而一个强大的安全模块定义了与领域对象类型有关的加色和权限。元模型可以生成“.po”文件,然后用于国际化。为了强化架构标准,我们还可以为元模型定义验证器,例如,模块里的每一个实体都应该被正确地映射到正确的数据库 schema 上。
框架已经处理了这么多问题,开发人员就可以专注在领域上,确保对它们进行了适当的模块化,便于长期的维护。为了避免模块间的耦合,框架提供了对 mixin 的支持,一个领域对象的渲染可以包含来自其他多个模块的状态和行为,而无需与这些模块发生耦合。将文档附加到任意一个对象就是很好的例子,清单 5 的代码与 Isis 的编程模型很相似。
内部的事件总线也很重要。一个模块发送事件,另一个模块订阅事件,而不需要它们之间发生直接的调用。清单 1 和清单 2 的代码解释了 Isis 是如何支持事件总线的。
支持多态关联(图 5)的持久化模式也很重要。这些模式通过多种开源模块(在 Incode Catalog 里已列出)实现,支持 documents、notes、aliases、classifications 和 communications 这些子领域。
Isis 的插件集合里还有很多其他模块,它们解决了安全、审计和事件发布方面的问题。对Isis 视图的扩展(地图、日历、PDF,等)也属于这些插件集合。
为了让业务子领域和插件更容易被理解和使用,它们还提供了示例和集成测试。这些插件的潜在使用者可以参考这些例子,看看是否能满足他们的需求。
Isis 拥有大型的生态系统,事实胜于雄辩。技术平台应该让开发团队专注在核心领域上,并将领域拆分成模块。所以,如果你去检查一下 Estatio 的代码,你就会发现它是由很多独立的模块组成的。图 7 展示了这些模块间的依赖关系(关系图使用 Structure 101 生成)。
(点击放大图像)
图7:Estatio 的模块
在图7 的左边部分,每个方块代表了一个独立的Maven 模块,连线代表了模块间的依赖关系。
底部是工具模块(DOM 设置、计算器),或包含了引用数据的模块(国家、货币、索引、税、计费)。
在中间我们可以看到party、financial、asset、assetfinancial 和bankmandate 模块:这些模块的结构和它们的数据都不会经常发生变更。budgeting、invoice 和lease 是系统的核心,它们依赖其他的大部分子模块。
图7 的右边也差不多,不过lease 模块包含了一个子包。在这里我们可以看到一些双向依赖,说明这里的代码需要做一些改进。当然,这里也有一些向外的依赖,说明这个模块做了太多的事情。要知道,没有一个软件系统是完美的。不过,尽管lease 是系统里最大的一个模块,但是从概念上来看,它对我们来说已经足够小了(“lease 就是租户和房东之间的协议,用于计算票据”)。
Estatio 已经有 5 年的历史了,它仍然在发展,以便支持更多的使用场景。尽管它的使用场景在扩展,但它的代码库却在收缩,Isis 插件集合里和 Incode Catalog 里的主要模块被强制分离出 Estatio,而且我们希望在未来分离出更多的模块。如果你现在要拉取代码,你可能发现它与之前图片里给出来的结构又有所不一样了。这也正是我们所期望的,这个应用将会长期存活下去,并保持演化。
结论
在文章的第一部分,我们比较了模块化单体和微服务架构,探讨了两者的优势和不足。
我们问了这样的一个问题:“你将会选择哪一个架构,微服务还是单体?”作为回答,我们问了另外一个问题:“你想要的是什么?”如果领域复杂性的风险高过无法伸缩的风险,那么就应该使用模块化单体。但愿我们在这里描述的各种技术和模式能够有所帮助。
不管采用何种架构,技术平台都是很重要的。重复发明轮子毫无意义,Isis 可以让你专注于解决领域的复杂性问题,帮助你理清模块边界,并解决几乎所有的横断面问题(包括呈现层)。
我们还介绍了开源应用 Estatio,它使用了 Isis 作为底层平台,展示了一个真实的模块化单体。
不管是单体还是微服务,它们都不是银弹。关于“我应该选择哪一个”这类问题的答案通常都是“这取决于……”。如果有人敢拍胸脯,那么他一定是在欺骗你。从系统的伸缩性和复杂性出发,然后做出决定。
关于作者
Dan Haywood是一个独立咨询顾问,他擅长领域驱动设计和裸对象模式,并因此为人们所熟知。他是 Apache Isis 项目的贡献者,Isis 是一款用于构建行业应用后端的框架,并实现了裸对象模式。Dan 作为技术顾问在基于.NET 平台的爱尔兰政府决策性裸对象系统上工作了 13 年以上,这个系统现在成为政府主要的社会福利管理系统。他还在 Eurocommercial Properties 工作了 5 年,开发了 Estatio 这个开源的基于 Apache Isis 的房地产管理系统。读者可以关注 Dan 的 Twitter 和 Github 主页。
评论