何时使用领域驱动设计

By | 2021年8月12日

何时使用领域驱动设计?其实当你的应用程序架构设计是面向业务的时候,你已经开始使用领域驱动设计了。领域驱动设计既不是架构风格(Architecture Style),也不是架构模式(Architecture Pattern),它也不是一种软件开发方法论,所以,是否应该使用领域驱动设计,以及什么时候使用领域驱动设计,这个问题本身就比较复杂(或者说这并不是一个好问题)。或许,更精确的提问方式应该是:“我应该选择什么样的架构风格来构建我的系统?”。现在我们先不急着回答这个问题,还是回到领域驱动设计的话题上,来回顾一下领域驱动设计里的基本概念。

领域驱动设计

很多人都了解测试驱动开发(TDD)、功能驱动开发(FDD)、API驱动开发(ADD)和行为驱动开发(BDD),那么什么又是领域驱动设计(DDD)呢?DDD的第三个D为什么是“设计”而不是“开发”呢?领域驱动设计最开始提出来的目的是为了简化业务人员与开发团队之间的沟通,以保证开发出来的软件产品不仅能够很好地解决业务领域问题并满足客户的需求,而且还能够简化或解决传统软件开发过程中遇到的各种问题(比如需求变更、横向或纵向扩展性差等等)。因此,通用语言(ubiquitous language)就是领域驱动设计中最重要最核心的概念:它能够确保代码的组织方式能够直接反映业务模型和业务逻辑,并且在整个业务系统中,对于同一个业务概念使用相同的代码表述(比如银行系统中的Account对象)。从通用语言的定义出发,领域驱动设计对于业务领域建模提供了一些指引,具体表现为引入了实体(Entity)、值对象(Value Object)、服务(Service)、聚合(Aggregate)、聚合根(Aggregate Root)、工厂(Factory)和仓储(Repository)。这里我就不打算深入讨论这些概念了,就简单回顾一下吧。

领域建模三剑客:实体、值对象和服务

在进行领域建模时,领域驱动设计引入了三个概念:实体、值对象和服务。实体和值对象都能够反映真实世界中的一个业务概念,两者的区别是,实体通过特定的标识符(ID)来确定一个个体,而值对象则是通过对象本身各个字段的值来确定一个个体。例如,某班的学生信息,学生(Student)就是一个实体,在进行领域建模的时候,一般会使用学号作为学生的ID,因为没有任何一个或者一组学生身上的属性能够唯一确定一个学生:姓名不行,出生日期不行,身份证号也不行(撇开有可能重号不说,用身份证号来标识学生会带来信息泄露问题);再比如学生的联系地址(Address)则是一个值对象,因为系统可以通过国家、省份、城市、街道和门牌号这些值的组合来唯一确定一个地址。

为实体设计一个合理的标识符(ID)策略,通常情况下并不是一件简单的事情:标识符需要具备全局唯一、生成高效、存储友好、意义鲜明这些基本特质,所以,Guid并不是一个很好的选择:它全局唯一、生成高效,然而并非存储/索引友好,而且是一串字符加数字和横杠,不代表任何意义。很多应用系统会有专门的服务来产生满足条件的标识符,比如销售系统很有可能会有单独的分布式服务来生成一个由订单日期、客户ID、订单流水号以及校验码组成的一长串字符串来用作订单编号。总而言之,为领域模型中的实体对象实现一个标识符的生成机制可以有很多种方法,这里也不进一步展开了,但是你会发现,领域驱动设计在这里只告诉你,实体需要一个ID,如何实现?这不是领域驱动设计的讨论范畴,因此也就回答了上面“第三个D为什么是‘设计’而不是‘开发’”的问题。

由于领域模型中的对象都是对业务概念的真实反映,所以,对象不仅会有状态,而且还会有行为,应该尽可能地将业务行为设计到合理的领域模型对象上,而不是将领域模型对象全部都设计成POCO/POJO,然后将所有业务行为都塞到Transaction Script里。例如:学生会有写作业的行为,因此,doHomeWork(Homework homework)方法就应该设计在“学生”实体上。然而,有些情况下,某些业务行为很难归结到某个实体或者值对象上,一个经典的例子就是银行业务里的转账(transfer)方法,它并不是某个银行账户(Account)的行为,可能是银行的行为,也可能是用户的行为,在这种情况下,领域驱动设计引入了服务的概念:在服务上定义从领域角度无法归结到任何一种模型对象上的行为。由此可见,服务是领域建模中的一部分,也是领域模型的重要组成部分。

生命周期双子星:工厂和仓储

有了领域对象,自然就需要管理对象的生命周期,在介绍工厂和仓储之前,先看一下与领域对象相关的两个抽象概念:聚合与聚合根。聚合是能够表达一个完整的领域概念(或者说业务概念)的实体和值对象的组合,如果用UML类图来表示聚合,应该选择使用组合模式。不难理解,聚合里的所有实体和值对象都有相同的生命周期,它们被同时创建,也被同时销毁。对于每一个聚合,必定有一个实体其本身就代表了整个聚合的业务意义,比如“销售订单”聚合可以由“销售订单”实体、“销售订单明细”实体以及“联系地址”值对象组成,而其中的“销售订单”实体就代表了整个聚合的业务意义,像这样的实体,我们称之为聚合根。当然,有些聚合仅包含一个实体,而这个聚合的聚合根就是这个实体本身。所有与生命周期相关的操作都应该发生在聚合根上。

在领域驱动设计中,工厂负责创建聚合,而仓储负责聚合的持久化、激活以及销毁,这些操作都是应用在聚合根上。同样,领域驱动设计并没有讨论工厂和仓储应该如何实现,然而基于它们本身的特点,在实际中我们更多地会选择一些创建型模式来实现工厂,而选择一些数据持久化机制(比如数据库)来实现仓储。就仓储的实现而言,我们基本上会结合底层的数据存储技术选型来决定仓储的设计,甚至会将其抽象成仓储设计模式。在不同的架构风格下,仓储的职责也会有所不同:传统分层架构下,仓储是有查询职责的,因为它需要基于聚合根来重建整个聚合,然而,在基于事件的CQRS架构中,仓储的查询职责变得非常薄弱,这是由于读写分离造成的。

以上基本上对领域驱动设计的基础性内容进行了回顾,如果你的项目正在,或者将要遵循上面的这些概念和指引进行业务分析与领域建模,或者在进行需求分析的时候,你的团队也在不停地考虑如何在软件中设计你所要面对的这些业务对象,并且在不停地梳理相关的领域知识,那么恭喜你,你已经步入了领域驱动设计的正轨。当然,在领域模型建立的过程中,你会发现很多问题,比如你会发现,银行账户与互联网登录账户都叫“账户”,但它们却是完全不同的东西;你甚至会发现,虽然都是“银行账户”,但在不同的场景下它所表述的意义完全不同(例如用于支付的支付账户与用户的定期账户是两码事),对于这些问题,领域驱动设计也提出了相应的解决方案,比如引入“界定上下文(Bounded Context)”的概念,而这一概念也刚好契合了目前最流行的软件架构风格:微服务架构风格,下文再深入讨论。

接下来你可以考虑本文刚开始的问题:我应该选择什么样的架构风格来构建我的系统。

软件系统架构风格

通常情况下,我们会选择一种软件架构风格来实现软件系统,而在开发的过程中,我们还会应用很多开发模式并且引入一些开发方法论,比如在模型持久化部分,我们会选择仓储模式,而在构建领域对象模型时,又有可能用到访问者模式,我们还会选择使用敏捷开发方法论来指导我们的日常开发任务等等。由此可见,软件系统架构风格并非是一种模式,简单地说,架构风格决定了系统将由哪些组件组成,以及这些组件之间的关系如何,而架构模式则表述了如何实现这些组件以及处理它们之间的关系。

《面向模式的软件体系结构(卷一):模式系统》一书中,将软件设计模式分为三种:体系结构模式设计模式以及惯用法。体系结构模式也就是架构模式,常见的有黑板模式、分层模式、MVC、发布者/订阅者、Proactor/Reactor、命令查询职责分离(CQRS)等等。这些模式的共同特点是,它们对软件系统的基本组织进行描述,这包括各种组件以及组件之间、组件与环境之间的相互关系的定义,并决定了软件系统设计与演进的原则。设计模式更多的是在组件内部,对于对象及其之间的关系以及它们之间的行为与协作提供一定的设计准则,从而使得组件的设计满足面向对象的SOLID原则。惯用法则是与特定编程语言相关的一种常用模式,比如在C#中,对于单例模式(Singleton)有它自己的独特的实现方式,这种方式依赖于C#中静态字段是线程安全的语言特性,而这种实现方式却并不能用在C++中。

与架构模式相比,架构风格并不关心真正的业务领域是什么,以及软件系统需要解决什么样的业务问题。无论你是开发ERP系统,还是开发购物网站,你都可以选择微服务架构,只是不同领域所需要的微服务不同罢了。常见的软件系统架构风格有:经典分层架构(N-Tier)、事件驱动架构(EDA)以及微服务架构(Microservices)。随着云计算的普及和推进,也衍生出了一些与云计算、人工智能以及大数据处理相关的架构风格,比如基于微软Azure云平台的Web-Queue-Worker架构Big data架构以及Big Compute架构。那么,我到底应该选择什么样的架构风格呢?在不同的架构风格下,领域驱动设计又如何运用呢?下面就对比较常见和流行的经典分层架构、事件驱动架构以及微服务架构做一些介绍。

经典分层架构(N-Tier Architecture)

这是一种为人熟知的架构风格,基本上所有开发人员都知道,软件系统需要分层设计。比较传统的常见的分层方式就是分三层:界面层、业务逻辑层以及数据访问层,各层之间会有数据传输对象(DTO)完成数据交互,以此隔离不同层内部的实现细节。领域驱动设计则将应用系统分为四层:用户界面层应用层领域层基础设施层

  • 用户界面层:这一层比较好理解,就是直接面向用户的这一层,比如前端单页面应用或者基于MVC框架开发的前端应用。如果你的应用系统仅提供API,那么API这一层也属于用户界面层
  • 应用层:根据领域驱动设计的描述,应用层是很薄的一层,它主要负责协调下层的执行任务,并隔离领域层与用户界面层。如果你选择采用经典分层架构,并开始实践领域驱动设计,那么在应用层你可以实现一些诸如Coordinator或者Workflow这样的组件,它们不参与任何领域或者业务相关的操作,仅仅负责协调。最常见的一种实现就是在应用层引入事务处理,有时候甚至还会跨资源实现分布式事务
  • 领域层:你的领域模型所涉及的所有对象都会出现在这一层,如上文所述,领域层对象需要尽量避免贫血模型,开发团队与领域专家一起完成领域层的设计与开发任务
  • 基础设施层:所有与技术细节相关的基础设施组件都属于这一层,因此,系统所依赖的数据库存储以及外部服务,都属于基础设施层。此外还有面向切面(Aspect-Oriented)的组件,比如异常处理模块、缓存模块、安全模块等等,也都属于基础设施层

在早10年以前,微软的西班牙团队在Github上开源了一套完整的基于领域驱动设计实践的分层架构案例:Microsoft NLayerApp,然而非常可惜的是,这个项目目前已经找不到了,但我仍然保留了一些资料,下图就是这个NLayerApp的架构图:

上图中红色部分代表的是用户界面层;天蓝色部分代表的是应用层;蓝色部分代表的是领域层;而绿色部分则代表基础设施层,整个软件的架构是非常清晰的,这就是一个标准的符合领域驱动设计思想的分层架构。在这个案例中,设计者引入了很多体系结构模式,比如领域层的仓储(Repository)模式和规约(Specification)模式、展现层(用户界面层)的MVC模式等,还引入了一些开发方法论,比如面向切面的编程(Aspect Oriented Programming, AOP)。从整个结构上看,它本身也就是一种架构模式:如果你选择分层架构风格,那么你就可以考虑使用上图中类似的结构来开发你的软件系统,比如引入领域模型、仓储模式、查询规约、工作流、MVC等等。当然,分层架构并不一定非要按上图中的这样去设计,你可以抛开领域驱动设计思想,自己根据项目或者产品的特点来实现分层,这是完全没有问题的,只要能够在一定的成本下,满足业务领域的需求就可以了。

在分层架构中应用领域驱动设计也是需要经过严格推敲和思考的,比如在上图中,仓储模式的实现,为什么Repository Contracts(也就是我们平时所说的仓储接口)是设计在领域层,而Repository Implementations则是放在基础结构层?原因很简单:一方面,根据上文所述,仓储的概念就是管理领域聚合的生命周期,因此它是一个领域模型中的概念,而另一方面,在实际实现当中,仓储是需要直接访问数据持久化机制的,而数据持久化机制又是与基础设施相关的组件,所以,仓储的实现部分是需要设计在基础设施层的。于是,领域模型层以及其上层的组件通过仓储接口访问仓储实例,而仓储实例则是在应用程序启动的时候通过依赖注入的形式提供。

Microsoft NLayer App已经不存在了,不过你也可以参考我在很早以前写的一个符合领域驱动设计的多层分布式架构案例:Byteart Retail,虽然目前看起来它所使用的技术相对比较老,但是整个系统的架构和各层组织结构还是非常清晰的,基本上可以比对上图的架构去阅读了解。

至此,你应该对领域驱动设计是如何在分层架构中运用已经有了一定的了解,你会发现,即使是在相对简单的分层架构中,要正确运用领域驱动设计的思想也不是一件容易的事情。你可以退而求其次,仍然选择使用分层架构,在对业务领域、研发团队、项目流程、市场反馈等等各方面进行了综合评估之后,如果你仍然选择了分层架构,而并不觉得它是一种不那么流行的架构风格的话,那么恭喜你,你或许做出了一个正确的选择。

总结起来,分层架构是相对比较简单比较容易理解的一种架构风格,实践技术也都非常成熟,有极为成熟的案例可以参考,如果你的软件系统业务本身并不复杂,而且在将来的一段时间内业务扩展不会特别大(比如为学校图书馆开发一套图书馆管理系统),而你的团队对于分层架构也更为熟悉的话,它的确是一个不错的选择。但是,如果你的软件所要处理的业务比较复杂,而且今后业务会不断扩展变大,那么庞大的业务体量将会使得你的业务逻辑层变得臃肿复杂,从而引起系统难以维护、代码构建时间过长、组件关联错综复杂、系统性能逐渐降低等等一系列问题,在这种情况下,你或许更应该选择微服务架构风格。但不管怎么选,由领域驱动设计所指导的领域建模实践以及相关的体系结构模式,都可以使用在(或者不使用在)你所选择的软件架构之中。

分层架构大致就介绍这么多吧,接下来介绍一下一种比较流行的架构风格:事件驱动型架构。

事件驱动型架构(Event-Driven Architecture)

事件驱动型架构通过采用一种发布者-订阅者(Publisher-Subscriber)或者事件流的模型,以异步的形式表达组件之间的关系。在这种架构中,事件产生方生成并发布事件到事件总线(Event Bus),而事件消费方则侦听事件总线并处理它所关心的事件,事件可以被一个或多个消费者所订阅和消费。因此,在事件驱动型架构中,事件产生方并不依赖于事件消费方,事件消费方之间也没有依赖关系。通常情况下,如果你的软件系统需要执行一些比较耗时的任务,而同时又要保证系统响应度的情况下,可以考虑采用事件驱动型架构。比如,IoT系统通常会采用这种架构,因为数据采集与分析都是比较耗时的操作,客户端可以首先发起一个创建数据处理任务的操作,然后通过轮询的方式获得任务的执行状态。

由于在这种架构中,各组件都是相互独立的,因此,这种架构具有很好的延展性(Scalability)和分布式部署的特性;但是,它也有一些实践上的难点,比如:如何确保事件能够被准确、稳定地分发;如何确保事件能够按照一定的顺序被消费方消费;如何确保事件仅被同一消费方消费一次等等。举个例子:在命令查询职责分离(CQRS)体系结构模式的实践中,当一个聚合需要被创建的时候,比如当需要创建一个Student聚合时,从Command这一方可能会产生并发布两个事件:StudentCreatedEvent和StudentNameChangedEvent,分别表示有一个Student聚合已经被创建,并修改了它的Name属性。那么对于事件的订阅方,肯定是希望首先处理StudentCreatedEvent,然后处理StudentNameChangedEvent,如果顺序反了,那就不对了:Student还没有被创建出来,又谈何修改它的Name属性呢?如果你的消息订阅方只有一个实例在运行,你或许可以通过事件的时间戳或者序列号来确定它们的顺序,然后引入一些类似有限状态机(FSM)的机制来保证消息的顺序消费。但如果(其实是绝大多数情况下)你的消息订阅方有多个实例同时运行,那么类似这样的问题就会变得更加复杂。再比如,很多事件驱动系统中,会通过引入成熟的第三方解决方案来确保事件分发的准确性,以保证当消费方没有确切给出一个信号的时候,事件一直都能够被保存在事件总线上以待下一次派发;而对于事件消费方,也会采用一些幂等设计,来保证事件仅被有效处理一次。

接下来我们看一个案例:一个基于命令查询职责分离(Command Query Responsibility Seggregation)体系结构模式所实现的分布式事件驱动型架构,在这个案例中,你可以了解到领域驱动设计是如何指导其设计并被运用在CQRS体系结构模式当中。CQRS体系结构模式最早是由领域驱动设计先锋Greg Young提出,它的架构图大致如下:

(上图来自2018年1月我在微软MVP论坛上的讲义,主题是《ASP.NET Core下领域驱动设计的实践》)

在CQRS中,所有的操作都是基于事件的,当客户端发起一个请求需要修改领域对象中的某个属性时,客户端会将修改属性的命令消息发送到系统中,命令处理器接收到命令消息之后,会根据聚合根的标识符(ID),从仓储中读取该聚合的所有事件,并根据这些事件重建聚合。在修改了属性之后,领域模型会产生一个事件,然后将这个事件保存到仓储中,与此同时,该事件还会被派送到事件消息总线。这种事件在CQRS模式中称为领域事件(Domain Events),因为它发生在领域层。接下来,事件处理器在收到属性修改的领域事件后,会相应地更新查询数据库;抑或会触发内部的有限状态机,以便在某些情况下当相关联的领域事件全部被接收之后,能够重新产生一条命令,对领域模型进行进一步的修改(比如订单在收到用户的支付之后,状态由WaitForPayment改为Paid)。这种读写分离的架构隔离了领域模型的修改部分与查询部分,使得它们能够以异构的平台和技术被开发和部署,甚至可以以不同的设计策略和资源分配对这两部分进行独立设计。此外,CQRS模式存储了整个系统从运行之初到当前的所有领域事件,也就是说它记录了整个系统从运行之初到当前所发生过的一切事情,这就使系统具有回溯到任何一个状态点的能力,这种机制我们通常称之为事件溯源(Event Sourcing)。

从领域驱动设计的角度,CQRS模式中也包含领域模型、仓储等概念,然而,实现方式与分层架构大不相同:

  1. 领域模型中不包含规约(Specifications),因为“写”端不具备查询功能
  2. 领域模型中聚合本身的行为(也就是方法)仅包含一个职责,就是派发领域事件(Domain Events)。例如,下面就是修改User聚合的Email属性的样例代码,从代码上看,它仅仅是派发了一个事件:

    而User聚合本身也是一个事件订阅者,因此,它在接收到了这个事件后,会更新自己的属性:

    我相信你肯定会有疑问:这不是多此一举么?在ChangeEmail方法中直接设置属性不就行了?然而,答案就是不行,因为当调用方通过User ID来向仓储读取User聚合的时候,仓储会从数据库中读出与这个ID相关的所有事件,然后逐一应用在User对象上,此时,上面的由InlineEventHandler所标识的HandleChangeEmailEvent事件处理方法就会被调用,从而完成对Email属性的设置。我相信你还会有疑问:仓储会读出所有的事件,然后逐一应用在User对象上?那如果与User对象相关的事件特别多,逐一应用这些事件岂不是会影响性能?在CQRS中,这一问题是通过快照解决的,基本思路就是在保存领域事件的时候,每隔一定数量的领域事件对聚合做一次快照,比如每1000个领域事件做一次快照,那么当我们需要恢复第1001个领域事件时,只需要读出这个快照,然后应用剩下的那一个领域事件即可,并不存在性能问题
  3. 领域模型中实体对象的属性都是只读的,因为修改需要通过领域事件来完成
  4. 仓储中不包含查询方法,因此,它仅有两个职责:保存聚合、根据聚合根的ID来读取聚合:
  5. 仓储所依赖的事件存储数据库中仅有一张数据表(或者说一种文档):领域事件表,大致包含这些信息:序列号、领域事件所发生的对象类型、领域事件所发生的对象ID、领域事件类型、领域事件发生时间以及领域事件的具体内容

多年前我也基于CQRS体系结构模式做了一个相对完整的案例:WeText,代码完全公开在Github,虽说使用的技术可能有些过时,但整个架构是事件驱动型的,并在一定程度上实现了CQRS体系结构模式以及领域驱动设计中的基本要素。这个案例的架构图如下,供参考:

(图片来源:本人的开源项目WeText,点击查看大图)

CQRS模式的实现非常复杂,所以大多数情况下它只会被运用在某个界定上下文Bounded Context)中,甚至大多数情况下都不会完整地实现上图所述的整个结构。或许你的业务并不需要保存历史事件,那么你就没必要设计事件存储;或许你的客户端不希望以异步的形式向系统发出命令,那么你有可能就不需要命令消息总线。目前在世界上的确是有完整实现CQRS模式的事件驱动型软件项目,但却是风毛菱角。

同理,在事件驱动型架构中,并不一定需要采用CQRS架构模式(应该说绝大多数情况下不需要),还是那句话,你应该根据项目本身的特点以及研发团队的情况来决定使用哪些架构模式来实现事件驱动型架构,有时候你可能只需要一个非常简单的设计就能满足要求。但是,如果你希望在事件驱动型架构中实践领域驱动设计,那么CQRS应该是你所需要了解并深入学习的一种架构模式,它能更好地帮助你理解领域驱动设计,并在事件驱动型架构中更好地运用它。

下面我们再看看目前最为流行的架构风格:微服务架构。

微服务架构(Microservices Architecture)

在最开始着手软件系统的设计时,你或许不会选择微服务架构,因为在那个时候,微服务架构并不能帮你解决眼前的设计问题。但是当你的业务领域变得十分庞大,而分层架构无法继续支撑你的软件系统时,你可能会考虑采用微服务架构。在微服务架构中,各应用服务之间互相独立,它们可以由不同团队采用异构的平台和技术,以及使用不同的软件开发方法完成开发,这些服务可以使用不同的数据存储系统,甚至可以是一个仅进行数据实时处理而不存储任何数据的计算服务,微服务实例之间可以以同步或者异步的方式进行通讯。不难看出,实践微服务架构的一个难点就是如何去协调各个服务之间的协作,例如如何在分布式的环境中保证数据的一致性;然而,当你真的决定采用微服务架构时,你所遇到的第一个问题就是:如何划分微服务的边界。

在微服务架构的官方网站上给出了四种将应用程序解构成多个微服务的模式:Decompose by business capabilityDecompose by subdomainSelf-contained service以及Service per team。其中与领域驱动设计所对应的模式就是Decompose by subdomain,它要求设计者能够根据软件系统的业务领域来区分子领域,然后应用相关模式来确定微服务的划分,大致流程如下:

  1. 对业务领域进行分析,通过通用语言来描述业务领域中的关键概念和业务行为,并确定整个大的业务领域由哪些子领域(subdomain)构成
  2. 根据这些子领域来确定界定上下文(Bounded Context),每一个界定上下文会有一套独立的领域模型对子领域进行描述,界定上下文中的领域模型不会存在二义性
  3. 在界定上下文中建模,设计好领域模型以及各领域对象之间的关系
  4. 基于建立好的领域模型,划分微服务

在领域驱动设计中,界定上下文(有些文章将其翻译为“有界上下文”,意思相同)是实现通用语言的重要工具,很多情况下,有些词语或者句子在不同的上下文中会有不同的含义,界定上下文就定义了这样一个边界,它能使得在边界内的词语或者句子具有唯一明确的含义而不存在二义性。例如某公司生产产品然后卖给客户(Customer),然后会有另一个团队为这些客户(Customer)提供售后服务或技术支持。那么在这里我们有两个“客户”的概念,对于整个公司来说,它们表示的是同一个概念,然而在不同的上下文中,这个“客户”的概念又有所不同:在销售子领域中,“客户”表示产品销售的对象,因此会更多地关注它对产品的需求以及信用额度、交货方式等等;而在售后服务子领域中,“客户”表示提供服务的对象,因此会更多关注它的历史订单信息以及历史服务工单。从上面的基于Decompose by subdomain的基本流程来看,一旦区分并确定了整个领域中的界定上下文,也就基本上确定了应用系统中大致会有哪些微服务。

从领域模型上分析,界定上下文也不是绝对独立的,应该说绝大多数情况下不是。领域驱动设计引入了“上下文映射(Context Mapping)”来解决跨界定上下文的领域知识的交互。常用的方式可以是使负责不同子领域的团队之间达成共识、通过抽象手段来建立跨多个界定上下文的公共模型(Shared Kernel),或者引入防腐层(Anti-corruption Layer)来达到不同界定上下文之间无缝沟通的目的。这篇文章很好地介绍了这些内容。

这里限于文章篇幅,我仅仅简单地介绍了与领域驱动设计相关的要点,上面讨论的内容中的每一个点都可以继续展开讨论继续分析研究。你是不是已经开始考虑是否真的需要微服务架构了吧?因为是否采用微服务架构风格,以及微服务如何划分,将直接影响到今后你的业务系统的开发和演进是否真的能够帮你解决庞大的业务领域体量所带来的软件开发问题,而不是让你的架构变得逐渐臃肿不堪错误百出难以维护,给你带来无穷无尽的烦恼。

或许你的应用系统并没有那么大的业务领域体量,你也已经将你的业务领域划分成了多个微服务,那么接下来就是开发技术以及开发流程和团队管理的问题了。微服务架构真的有很多优点:由于整个业务领域被划分成多个子领域,由不同的微服务实现,因此这种架构风格具有非常好的延展性,并且可以根据需要来动态调配各个服务的运行资源。另一方面,在微服务架构中,通常都会由不同的团队来负责各个微服务的开发,这些团队可以选择合适的技术,采用自己的代码托管与分支策略,使用不同的软件开发过程来开展开发任务。如果团队采用敏捷开发过程,那么一个相对较小的团队能够更加高效地实践敏捷,使得微服务的开发能够不断向前迭代。微服务架构的另一个优点就是对于云平台的支持,虽然各个服务会采用不同技术运行在不同平台上,然而现在流行的容器化技术可以屏蔽这种应用层技术实现的差异,通过将各个服务封装成容器,使得整个应用系统可以非常方便地部署到云平台,并且非常方便地调用托管的云服务。

由于这种架构上的灵活性和分布式的特点,微服务架构也存在很多挑战:配置管理、服务发现、服务间通信、分布式事务(数据最终一致性的保证)、部署和测试复杂度、安全策略的实现等等,每一个技术难点都有可能成为你成功实践微服务架构的阻力。例如,异步通信是微服务间最为常见的通信机制之一,而大多数情况下,分布式事务就需要依赖于这种异步通信机制,而它一般都是基于事件消息的,所以,除了基本的事件消息框架的实现之外,各个微服务还需要考虑如何参与到这种分布式事务之中:如何在事务成功的时候提交变更,以及如何在事务失败的时候进行补偿操作。Saga体系结构模式就是一种实现跨服务事务的模式,它有两种实现方式:编排式协调式,前者通过微服务之间互通领域事件来实现事务,而后者则是由一个中心化的协调器来接收来自各服务的领域事件,然后根据领域事件的处理结果来决定整个事务应该被接收还是被驳回。当某个事务参与的微服务比较少,并且处理逻辑不复杂的情况下,采用编排式的设计会比较简单;但如果参与的微服务和领域事件比较多,选择协调式的设计会使得结构更加清晰,而且不容易出错。目前有一些开发框架已经很好地实现了或者支持Saga模式,比如.NET下的NServiceBus框架,然而由于其过于复杂,学习成本比较高,因此应用范围也不是特别广。

值得一提的是,微服务架构之下各服务之间隔离度越高越好,虽然微服务架构本身并不强制要求每个服务都有自己的数据库,但是Database per service仍然是一个比较推荐的做法。前端的实现也是如此,开发团队可以有各自的前端开发人员来开发用于当前微服务的前端界面,然后通过某些微前端框架进行整合。

所以,微服务架构看上去比较先进、时尚,但是要想有效、正确地实践微服务架构却不是一件容易的事情。如果你的业务系统并没有大到需要拆分成多个子系统来进行设计,或者你的团队没有大到足以应对由这些微服务带来的技术复杂度,那么,你真的应该考虑一下,采用微服务的架构是否真的利大于弊。架构设计就是如此,没有对错,只有是否合理,整个过程就是平衡与取舍。

以下是微软官方的一个完整的微服务架构的案例:eShopOnContainers,代码开源,其业务领域是一个电商零售网站。它的架构图如下:

(图片来源:微软eShopOnContainers代码库,点击查看大图)

eShopOnContainers支持移动客户端、传统的基于ASP.NET Core MVC的浏览器客户端以及基于Angular的单页面应用(SPA)三种不同的客户端体验;在服务端,eShopOnContainers实现了面向mobile和面向web的两套API网关(API Gateway),所有的API请求都由这两套网关所代理,与后端的不同微服务进行通信。eShopOnContainers采用基于ASP.NET Identity的由IdentityServer4所实现的认证与授权机制,它是一个基于SQL Server数据库的传统的ASP.NET Core的服务。

在基于子领域的划分上,eShopOnContainers将其业务领域分为三个子领域:用于维护商品信息的Catalog子领域、用于处理订单的Ordering子领域以及用于管理购物篮信息的Basket子领域,因此,对应的微服务也就按子领域进行划分,各个微服务所采用的技术也完全不同:

  • Catalog微服务使用传统的Data Service/CRUD API模式,将Entity Framework Core的DbContext以构造器注入的方式注入控制器(Controller),然后在控制器中完成业务操作和数据访问,后台采用SQL Server数据库
  • Ordering微服务使用CQRS体系结构模式,它的运作完全基于领域事件,虽然它并非完全实现CQRS模式的所有细节,但已经足够实现它的业务逻辑,并且它的复杂度也得到了很好的控制,它后台也是采用SQL Server数据库
  • Basket微服务使用基于领域驱动设计的分层模式,它引入了领域模型、仓储等概念,并将仓储的实例通过构造器注入的方式注入控制器,然后让控制器充当领域驱动设计中应用层的角色,完成业务处理和领域模型的重建和持久化,后台采用Redis缓存作为数据持久化机制

这些微服务之间通过RabbitMQ(或者Azure Service Bus)的事件总线(Event Bus)完成通信,以编排式的Saga模式实现了基本的分布式事务,整个后端架构都是容器化的,运行在容器编排集群中(docker-compose或者Kubernetes)。

由此可见,在微服务的架构风格中,领域驱动设计能够被更加灵活地运用,由于不同的微服务是由不同的团队负责开发,因此就可以在不同的微服务中,以不同的程度来引入领域驱动设计的思想以辅助解决业务分析与系统开发中的难点,最终达到整个软件架构的良性发展。

软件架构风格大致就介绍这些吧,涉及的内容确实很多,也没有办法在一篇文章里完全写完,以后有机会再深入补充吧。

总结

读到这里,你应该已经大致了解了什么是领域驱动设计、软件架构模式与软件架构风格的区别是什么、常见的软件架构风格有哪些,以及在不同的软件架构风格下,领域驱动设计是如何对软件的架构设计提供指引并指导模式的合理使用。你还会了解到,很多情况下,对于绝大多数项目而言,或许一个面向数据的CURD服务已经完全能够满足你的应用系统需求,或许你也只需要一个单体架构(Monolithic)就能够解决你眼下乃至几年内的开发痛点,那么在这些情况下,你需要慎重考虑是否真的需要“赶时髦”地引入过于复杂的架构风格和架构模式。然而另一方面,在团队相对比较成熟、对领域驱动设计有一定认知和认同、成本允许的前提下,能够鼓励大家尝试实践领域驱动设计,这也是一件非常好的事情,毕竟有学习有实践才会有进步。所以,何时使用领域驱动设计?应该选择什么样的架构风格?还是你自己来决定吧。

参考阅读

借此机会推荐一些非常优秀的“课外读物”,这些著作的经典程度不亚于《设计模式:可复用面向对象软件的基础》(GoF95)一书之于面向对象分析与设计(OOAD)的经典程度。如果有兴趣,推荐参考阅读:

此外,我自己也有几个Github Repo,虽然很久没有更新了,但当时也是在一定程度上以各种不同的架构风格,采用了不同的架构模式实现了领域驱动设计(在上面文章中也已经提及这些Repo,这里总结一下):

十多年前,我也总结了不少领域驱动设计的文章,完整列表在此,也可以参考了解一下。

(总访问量:854;当日访问量:2)

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据