第 2 章 分布式应用服务的拆分
崔皓
讲述:Alloy大小:16.62M时长:01:12:37
上一章介绍了分布式架构的演化和特征,并针对每一阶段存在的问题进行了进一步的拆分。本章介绍分布式架构中的应用服务拆分。业务引领技术,领域驱动开发遵循从业务到技术的拆分思路,将业务专家头脑中的业务信息转化成领域模型,再由领域模型落地为架构设计。顺着这个思路,先介绍领域驱动设计中领域对象的结构关系,再介绍领域驱动设计的过程,最后介绍领域驱动设计在落地时的架构分层。在本章中,你可以学到以下内容。
领域驱动设计为何能帮助拆分应用服务
领域驱动设计拆分应用服务的思路
领域驱动设计的模型结构
领域驱动设计的分层结构
领域驱动设计的拆分过程
2.1 起因与概念
在介绍领域驱动设计之前,先来看看业务需求是如何落地成代码的。一般项目立项以后,客户方面的业务专家会和项目中的业务分析师一起讨论需求,并将讨论得到的需求编写成需求文档,架构师根据需求文档生成数据库,程序员根据数据库生成实体,然后实现需求中的一个个功能。程序员要实现的所有功能都来自需求文档,然后结合数据库设计进行编码。一旦需求发生变化,首先要修改需求文档,再修改数据库设计,之后程序员才能修改代码。需求可以比作击鼓传花中的花,从业务专家传给业务分析师,再传给技术团队。无形中,业务专家与技术团队之间就会形成鸿沟。他们之间沟通的媒介是需求文档和数据库设计。造成这一结果的原因是,没有一个东西能让业务专家、业务分析师、架构师和程序员等项目参与者站在一个层面,用同一种语言沟通。图 2-1 展示了业务需求落地的过程,其中业务专家、业务分析师、技术团队是串行工作的。
图 2-1 传统的需求落地过程是串行过程,业务专家与技术团队是割裂的
这种以功能为导向的架构,会使整个系统像一个大泥球,将各种功能的业务混杂在一起,只是为了完成用户的需求。性能、可用性、扩展性和伸缩性都存在问题。特别是在业务发展飞快,请求量、并发量飞增的当下,这种单机架构更加不合时宜,因此需要进行应用服务的拆分,用颗粒度更细的应用服务进行组合,从而适应复杂的业务场景。
2004 年埃里克·埃文斯(Eric Evans)发表了《领域驱动设计》(Domain-Driven Design: Tackling Complexity in the Heart of Software)这本书,书中定义和描述了领域驱动设计(Domain-Driven Design,简称 DDD)的概念。这个概念的核心思想是通过领域驱动设计方法定义领域模型,从而确定业务和应用边界,保证业务与代码的一致性。从定义上来看,领域驱动设计提供了一个方法,这个方法用来划分业务和应用边界,目的是保证业务和代码保持一致,也就是让业务专家和技术团队对同一个事物能有同一个理解。如图 2-2 所示,业务专家和技术团队通过领域模型站在同一个层面来理解业务需求。
图 2-2 领域驱动设计的概念
2.2 拆分思路
通过领域驱动设计的定义可以知道,应用服务的拆分源头是需求。企业开发某款软件时,会有一个目的,针对目的会生成需要解决的问题,这些问题就是业务需求。领域驱动设计就是将业务需求转化为架构设计,最后落地到代码。在目的明确的情况下,再来看看有哪些参与者。领域驱动设计的参与者比较广泛,根据项目具体实施情况的不同而不同,有甲方的领域专家(即上文中提到的业务专家),乙方的业务分析师、架构师、程序员、项目经理、产品经理等。这里我们将上述参与者简化为领域专家和技术团队。领域专家,是对企业业务最为精通的人,他们对系统需要解决的问题和需求有着透彻的了解,但通常对业务实现不甚明白。技术团队则侧重于软件架构的搭建与技术实现,他们对类、接口、方法、设计模式、架构等了如指掌,能够用面向对象的思想来思考问题,但是对业务需求的理解深度有限。这样两类参与者,各有优缺点,因此需要通过互补的方式共同完成软件的分析、设计与开发。为了屏蔽业务需求与技术实现之间的差异,他们需要使用同一种语言进行沟通,这种语言就是通用语言。
通用语言就是领域专家和技术团队的沟通工具,它会贯穿领域驱动设计的整个生命周期。企业目标、需解决的问题、业务需求作为领域驱动设计的源头,领域专家和技术团队通过通用语言对其进行分析,得到领域知识。这些领域知识是对业务的一般性描述,例如用户通过浏览网站,选择商品下单,付款以后收到确认付款和准备发货的通知,其中参与者(实体)是用户,业务流程是浏览、下单、付款、收到付款通知,命令有下单、付款,事件有已经下单、已经付款、已经发送通知。得到这些领域知识后,通过抽取的方式形成领域模型。领域模型是一个抽象的概念,其具体形态是一个大的领域,其中包裹着的各种不同的子领域也称为子域。这些子域通过限界上下文的方式进行分割,子域中间又包含领域对象,例如聚合、聚合根、实体、值对象;领域对象之间通过领域事件进行沟通。在抽取完领域模型之后,技术团队会根据这个模型搭建软件架构,并对架构分层,分别是用户接口层、应用层、领域层和基础层,再将每层用代码实现。将上面这些思想整合到一张图中,就是图 2-3。此图介绍了领域驱动设计的拆分思想和过程,首先对业务需求进行分析,将其转换成领域知识,然后通过对领域知识进行抽取形成领域模型。整个过程中领域专家和技术团队都会使用通用语言进行沟通,保证对业务的理解是一致的。
图 2-3 领域驱动设计的拆分思想和过程
图 2-3 指出了使用领域驱动设计对应用服务进行拆分的流程,大致分为:分析、抽取、构建。领域驱动设计中的概念比较多,为了便于大家更好地理解,本章后面将按照以下顺序展开。
(1) 领域模型的结构,这部分主要说明领域模型涉及的概念(例如通用语言、领域、子域、限界上下文、聚合、实体等)以及概念之间的关系,通过讲解概念为后面打基础。
(2) 通过分析业务需求形成应用服务的过程,讲解如何通过业务需求分析出业务流程,再对其抽取形成领域模型,最终产生应用服务。
(3) 如何根据领域模型搭建架构并分层,每个层次承载的职责和功能,以及如何落地成代码架构。
2.3 模型结构
本节先介绍通用语言,这是领域驱动设计的基础。随后,根据包含关系按领域、子域、限界上下文、实体和值类型、聚合和聚合根、领域事件的顺序给大家介绍。
2.3.1 通用语言
通用语言是一种沟通工具,用来描述需要实现的业务场景和领域模型,是领域专家与技术团队沟通的桥梁,它包含自然语言、文档和图表。双方通过通用语言能够对同一个领域知识达成一致,下面简要介绍一下它的特点。
通用语言的使用是有范围限制的。在一定范围内对同一事物的描述和表达应该是一致的,如果超出这个范围,就会有不同的表达。例如对于在电商系统中购买的一件物品来说,交易系统中称其为“商品”,出库系统中称其为“库存”,物流系统中称其为“货物”。也就是说,通用语言的“通用”其实是有一定范围限制的,这个范围就是 2.3.2 节即将提到的限界上下文。在范围一定的情况下,团队所有人对同一事物的称呼都是统一的,这就是通用语言。在这个范围内,领域专家无须使用业务术语,技术团队也无须使用技术术语,大家通过通用语言进行交流,保证了对同一事物的认知具有统一性。
通用语言可以定义实体、命令和事件。由于通用语言描述了业务流程和应用场景,因此可以直接反映在代码中。例如,可以用通用语言中的名词为领域对象命名,比如商品、库存、货物等;可以从通用语言中提取动词作为命令,比如下单、出库;提取的动词还可以为事件命名,比如已下单、已出库。
通用语言可以定义业务过程。针对业务过程,常常会出现技术和业务不一致的情况。假设使用通用语言描述这样一个场景:开学了,同学们需要在学校系统中注册自己的信息。由于学生属于班级,只有对应的班级激活以后,学生才能注册,学生注册要用到用户名和密码。下面我们来看如何用代码实现上面的业务逻辑。
代码与业务不一致的情况。图 2-4 中的代码在获取学生信息以后,直接判断学生是否有资格注册,跳过了班级是否激活的部分。
图 2-4 代码与通用语言不一致
代码与业务一致的情况。图 2-5 中的代码首先获取班级信息,之后判断班级是否激活。在班级激活的情况下,才会获取学生的信息并判断学生是否可以注册。
图 2-5 代码与通用语言一致
从上面的例子可以看出,使用通用语言定义清晰业务流程,可以让技术团队和领域专家站在同一层面上理解业务。
2.3.2 领域、子域和限界上下文
领域从字面上理解就是从事某种专项活动或事情的范围。说白了,领域具体指一种特定的范围或区域。再通俗一点说,领域就是“范围”。领域驱动设计就是为了确定“范围”,这个“范围”可以是业务的范围、系统的范围、服务的范围,甚至是物理资源的范围。只有确定好这些范围,我们才能更好地对分布式架构进行拆分,做到“高内聚,低耦合”。回到业务分析上来,领域指的是业务边界。我们要做的事情就是对业务沿着业务边界进行划分,形成一个个领域模型,再用架构和代码实现这些领域模型,也就是解决从业务到技术的问题。
既然领域就是业务边界,那么业务边界会有大小。同理,由于业务的复杂度和包含性,大的业务边界会包含小的业务边界,被包含在大业务边界(领域)中的小业务边界称为子领域,也叫作子域。如图 2-6 所示,如果把电商平台看成一个领域,那么其中会包含商品系统、订单系统、支付系统和库存系统,这些系统统称电商平台的子域。实际上建立领域与子域概念的过程就是将一个复杂事物拆分的过程,问题越抽象越难以理解,越具体就越好理解。
图 2-6 领域与子域的划分
按照业务边界将业务领域分割以后,形成了一个个子域,这些子域对内都有通用语言作为支撑。在每个子域中,描绘同一个事物时使用的通用语言都是唯一的,不存在二义性。各个子域对于整个系统的重要性是不同的,例如对于电商平台来说,商品系统、订单系统是它的核心业务,就是重要的子域,这类处在核心位置的子域称为核心域。单靠核心域当然无法支撑整个系统的运行,正所谓鲜花也要绿叶衬,那么起到辅助、支撑作用的子域就登场了,例如库存系统,它们为核心域提供支撑,因此称为支撑域。业务的顺利运行还离不开通用系统的支持,例如消息通知和日志等,它们作为技术层面的通用功能,被称作通用域。以上就是领域的三种分类:核心域、支撑域和通用域。它们各司其职,为业务与系统服务。
如果说领域与子域的概念是从业务角度出发告诉我们如何对业务定义边界,那么该如何划分这个边界,又如何将业务边界定义到技术上呢?
答案是限界上下文。这又是什么?我们来拆分一下这个词,“限”表示限制,“界”是边界的意思,“限界”就是限制边界;“上下文”是对话的语境,以一个产品为例,它在生产阶段是“原料和配件”,在销售阶段是“商品”,在物流阶段是“货物”。同样一个东西根据环境的不同被赋予了不同的意义,这个环境就是上下文。合起来,限界上下文指的是通过限制边界的方法来确定业务的上下文,这是一种划分业务边界的方法。在前面提到的领域中包含的子域就是用限界上下文的方法划分出来的。限界上下文就像一把刀,将业务领域分割成不同的子域。这里需要注意,领域是给领域专家和技术团队看的,因此一定要包含业务和技术两个层面的东西。如果对领域从横切面切一刀,就可以将其分为问题空间和解决方案空间。
问题空间是领域在业务层面的表现,从业务的角度会看到分割所得的子域,包括核心域、支撑域、通用域。
解决方案空间是领域在技术层面的表现,这里领域被限界上下文分割。
从理论上讲,业务和技术需要分别对应,在理想情况下子域和限界上下文应该是一一对应的,但也有互相融合的情况。如图 2-7 所示,上面面向业务的问题空间由业务定义的子域组成,下面面向技术的解决方案空间由技术对应的限界上下文组成。
图 2-7 领域模型的两面性:业务性(问题空间)和技术性(解决方案空间)
这也是领域驱动设计能让领域人员和技术团队合作的原因。领域模型针对不同的群体提供了不同的空间,又通过通用语言和限界上下文的方法,将不同空间对应起来。领域专家工作在问题空间,技术团队工作在解决方案空间,限界上下文是分割的工具,两个空间使用通用语言进行沟通,大家站在同一层面上交流。
分布式技术架构需要盯着限界上下文来拆分应用或者服务,限界上下文的边界就是应用服务的边界。限界上下文对事物的定义具有唯一性,例如:商品限界上下文中的商品关注的是产地、属性、价格、销量等信息,库存限界上下文中的商品关注的是库存量、所在仓库等信息。两个不同限界上下文所在的语义环境不一样,关注的焦点也随之不同。另外,针对限界上下文还做进一步拆分,生成更加细致的限界上下文。因此,限界上下文是分布式架构和微服务架构拆分的依据,是业务从问题空间转换到解决方案空间的工具。
2.3.3 实体和值类型
2.3 节始终按照从上到下、从大到小的原则拆分业务领域。最开始只有领域,也就是业务边界,将其拆分成子域,再把子域从问题空间映射到解决方案空间的限界上下文中。限界上下文中包含领域对象,实体就是一种领域对象。
实体
实体中的“实”表示实际存在,“体”表示物体,因此实体就是实际存在的物体。在业务中,哪些“物体”是实际存在的呢?比如商品、订单。在领域驱动设计的架构中,实体是唯一存在的、可持续变化的。由此,引出了实体的两个性质:唯一性和可变性。
唯一性。每个实体在限界上下文中都是唯一存在的,并且可以被唯一标识。就好像在中国,公民使用身份证号作为自己的唯一标识,而出国时,其唯一标识就变成护照了。身份证号和护照都是人这个实体的标识,只是在不同限界上下文(中国,国际)中的表现形式不同。又如当一个用户登录电商系统时,一定会有一个与其唯一对应的 ID,无论他挑选什么商品,商品也都有一个唯一确定的 ID,下单以后有订单号,运输时有物流号,之后如果出现投诉的情况,还会有投诉流水号。
可变性。可变性指的是实体状态和行为的可变。状态和行为来自对业务模型的分析,因此首先要利用通用语言分析业务,并抽取实体、状态和方法。在订单限界上下文中,顾客选择商品放入购物车并提交订单,此时生成订单且订单状态为“未支付”;选择支付方式并且完成支付后,订单状态为“已支付”;仓库接到通知并发货,订单状态为“已发货”;用户收到商品以后确认收货,订单状态为“已确认收货”。由通用语言的描述可以看出,名词订单是实体;已支付、已发货等形容词用于修饰这个实体,可以作为实体的状态,这些状态随着业务流程的推进在不断变化着;提交、支付、发货、收货、确认收货等动词表示行为或者命令。相应地,行为也在随着业务的变化而变化。接下来列举状态和行为的变化。
订单状态:未支付、已支付、已发货、已确认收货。我们在订单实体中定义状态属性。
订单行为:比如提交、支付、发货、确认收货。我们在订单实体中定义方法或领域服务来处理。
细心的朋友或许会发现,在产生行为的同时会产生事件,例如:支付以后会触发支付事件,通知服务接收到这个事件后会向用户发起通知。事件起到了让实体之间相关联的作用,这点会在 2.3.5 节中详细描述。
唯一性和可变性既对立又统一。说实体既唯一又可变,这不是自相矛盾吗?但仔细想想,实体的唯一性指的是在限界上下文中不会存在第二个与之相同的实体。可变性指的是实体在不同的业务场景下,状态是可变的。从技术层面来说,一个实体在内存中只有一个唯一的地址指向它,在内存销毁之前,其他对象都会通过这个地址访问该实体。实体被持久化到数据库中后,对应会有一个 ID 作为主键,主键用于唯一确定这个实体,其他非主键字段才是可以修改的。
值对象
说完了实体,再来看看值对象。“值”,顾名思义是数值,赋值,表示内容;“对象”就是一个东西,而且是唯一存在的。那么值对象就是,用对象的方式来描述值。通过这个定义,我们总结一下值对象的特性:唯一性、集合性、稳定性和可判别。
唯一性。在限界上下文中,对象是唯一存在的,且一个对象可以被其他对象使用。对于商品来说,有生产地址表示它的产地,这个地址一定是唯一存在的,例如中国上海市徐汇区梦想路 201 号。
集合性。值对象是由一个或者多个属性组合而成的。还是举地址的例子,地址由国家、省市、区县、路、门牌号码组成。再如生日由年、月、日组成。
稳定性。值对象一旦生成,在外部是无法修改的。例如商品生产出来以后,会将产地地址赋给它,而作为商品是不能修改产地地址的。因为该商品的产地也会被其他商品使用,一旦这个商品修改了产地地址,其他商品的产地也会随着改变。正确的做法是生成一个新的产地地址,将新地址和新商品关联起来,或者重新选择一个已经存在的其他产地。
可判别。值对象之间是可以比较的。例如商品产地是中国上海市徐汇区梦想路 201 号,库存地址是中国上海市徐汇区梦想路 203 号。比较这两者后,我们发现两个门牌号是不同的,因此可判别的特性也证明了值对象的唯一性。
实体和值对象的关系
实体和值对象都是领域对象,也是分布式架构中的基础对象,它们被用来实现基础的领域逻辑。分析它们的特性后可以发现,值对象和实体在某些场景下可以互换,因此需要根据应用场景具体情况具体分析。如果将商品作为实体,地址作为值对象。如图 2-8 所示,商品实体中的产地就可以引用地址这个值对象。
图 2-8 领域模型中,实体对值对象的引用
领域模型驱动架构设计,业务驱动程序开发,有了领域模型后,代码结构可以设计为如图 2-9 所示的那样。其中把商品实现为 Commodity 类,该类中定义了一个 placeOfProduction 属性,这个属性引用了 Address 类。
图 2-9 实体对值对象的引用
了解了实体和值对象在代码中的表现后,再看看它们在存储层的表现。在设计一般的关系型数据库表时,一个实体通常会对应一张数据库表。如图 2-10 所示,将值对象(产地地址)作为列放在实体(商品)对应的表中,两者的映射关系比较直观。这样做的优点很明显,就是在操作实体的时候可以直接操作值对象。缺点是需要使用很多额外的字段存放值对象信息。
图 2-10 值对象作为列存放在表中
此外,还有一种存放方式是可以减少字段数量的,即将值对象作为一个单独的字段存放。如图 2-11 所示,将值对象(产地地址)的内容结构化成字符串放到 PlaceOfProduction 字段中。不过在读写这个值对象的时候,需要借助程序对其进行解析和构建。
图 2-11 把值对象结构化成字符串并将其存放在单独的一列中
前面讲解了领域、子域、限界上下文、实体和值对象。这里简单对它们的关系做个总结,同时也为后面几个概念的介绍做铺垫。如图 2-12 所示,领域是指架构需要实现的业务范围,在这个范围中,借助通用语言和限界上下文工具,将领域分成一个个子域。子域是业务角度的称呼,从技术角度讲的话,领域就是被分成了一个个限界上下文。限界上下文中包含实体和值对象,它们都属于领域对象,而且实体可以引用值对象。
图 2-12 领域、子域(限界上下文)、实体和值对象的关系
2.3.4 聚合和聚合根
在一个限界上下文中,可以切分出很多个实体,如何管理这些实体,以及它们之间如何协同工作,是接下来要解决的问题。如图 2-13 所示,将实体和值对象组合成整体,并进行统一的协调和管理就是聚合要做的事情。聚合针对领域对象(实体、值对象)进行组合以及业务封装,并且保证聚合后内部数据的一致性。如果说限界上下文对应一个服务或者应用,是系统的物理边界,那么聚合就是领域对象处在限界上下文内部的逻辑边界。
图 2-13 聚合是实体与值对象的组合,在限界上下文中形成了逻辑边界
如果把领域对象理解为个体,那么聚合就是一个团体。团体通过管理和协调个体之间的关系,带领个体共同完成同一个工作。聚合内部的领域对象协同实现业务逻辑,因此需保证数据的一致性。同时聚合也是数据存储的基本单元,一个聚合对应一个仓库,对数据进行存储。如果把聚合比作一个组织,那么这个组织肯定需要一个领导者,领导者负责本组织和其他组织之间的沟通。其中提到的领导者便是聚合根,它本质也是一个实体,具有实体的业务属性、状态和行为。作为聚合的管理者,聚合根负责协调实体和值对象,让它们协同完成工作。如图 2-14 所示,订单聚合可以进行添加、删除商品的操作,因此订单聚合根需要定义添加、删除商品行为,还要保证这个行为在聚合内的事务性和数据一致性。同时商品实体作为商品聚合的聚合根,在聚合内部可以修改商品的基本信息。订单聚合和商品聚合通过值对象——商品 ID 进行关联引用,这个商品 ID 就是商品聚合根的一个值对象。
图 2-14 聚合通过聚合根与其他聚合联系
2.3.5 领域事件
如果说每个聚合的业务都是独立的,那么当多个聚合需要共同完成一个业务的时候,该如何处理?例如在支付成功以后,订单服务会修改订单的状态并且通知物流系统出货。由于支付、订单、物流分别处于不同的聚合,如何让它们协同工作呢,这就需要用到领域事件了。结合图 2-15,我们来看下面这个例子。
(1) 在支付聚合中,完成付款操作后,产生已付款事件,这个事件作为消息发往消息中间件。
(2) 订单聚合在消息中间件中注册监听器,监听从支付聚合发来的已付款消息,一旦收到消息,就执行修改订单状态的操作。
(3) 订单聚合修改完订单状态以后,产生订单已付款事件,此事件也以消息的形式发往消息中间件。
(4) 物流聚合按照第 (2) 步的方式接收到订单已付款的消息以后,执行发货操作,并且产生已发货事件。
注意
图 2-15 中箭头所指的方向表示消息的流动方向。
图 2-15 聚合之间通过领域事件相互沟通、协同工作
从上面的例子可以看出,聚合在执行命令和操作之后便会产生事件,这个事件会引出下一步的命令和操作,其中提到的事件就被称为领域事件。一次事务只能更改一个聚合的状态,如果一次业务操作涉及多个聚合状态的更改,就好像上面的例子中,付款、修改订单、发货三个步骤需要放在同一个事务中处理,就需要通过领域事件保持数据的最终一致性。有了领域事件的加入,每个聚合都能专注于自己的工作,并依赖领域事件和其他聚合进行沟通和协同,保证了聚合内部的事务独立性和数据一致性。领域事件是解耦的工具,在分布式应用拆分和微服务的场景下,会被频繁用到。特别是当服务部署在不同的网络节点时,尤为重要。
前面我们把领域驱动设计的模型结构过了一遍,这里做一个小结。如图 2-16 所示,领域就是我们需要关注并且实施的业务范围,它还可以分为子域,子域是业务角度的理解。使用通用语言可以将领域分为限界上下文,它与子域相对应,是技术角度的理解,限界上下文中对业务有唯一的语义标识。我们可以根据限界上下文定义系统的物理边界,也就是应用或者服务。限界上下文中包含多个领域对象,领域对象包括实体和值对象,它们是对业务的真实反映,具有唯一性和可变性等特性。为了更好地协同和管理领域对象,用聚合的概念将它们组织起来。聚合是一个逻辑上的概念,它是一个领域对象的组织,聚合根是这个组织的管理者,或者说是对外接口。聚合根本身也是一个实体,它让聚合之间产生联系。最后,聚合之间的协作和通信需要通过领域事件完成,它让每个聚合不仅可以专注在自身的业务操作中,还可以和其他聚合共同完成工作。
图 2-16 领域、子域(限界上下文)、聚合、实体、值对象、领域事件结构图
2.4 分析业务需求形成应用服务
介绍完了领域驱动设计的模型结构,我们来看看如何将业务需求转换成这些模型。回到 2.2 节,完成整个应用服务的拆分需要三步,分别是分析、抽取和构建。2.3 节介绍了领域模型的定义,为本节打下了基础。本节主要介绍的是分析和抽取过程。建立任何一个软件架构都是为了完成业务需求,而业务需求是用来完成商业目标的。这里以构建一个学生选课系统为例,讲解服务分析和抽取的整个过程。学生选课系统的业务背景如下。
学生可以通过系统选择选修课,并且提交选择选修课的申请,之后教务处负责审核。
教务处的老师收到选修课的申请以后,根据审批规则进行核对,最终产生审批结果:通过或者不通过。
获得上选修课资格的学生,去上课的时候需要签到,老师会检查签到情况,并在课程结束的时候生成签到明细。同时学生也可以查看自己的签到情况。
因为这里只是个例子,所以业务看上去比较简单,基本上可以总结为:学生申请选修课,教务处审批选修课,学生签到并且上课老师查询签到记录。
接下来,我们说一下整个拆分过程的思路。
(1) 根据不同的业务场景创建业务流程,在每个业务流程的节点上标注参与者、命令和事件信息。
(2) 根据标注的参与者、命令和事件信息生成领域对象,包括实体、值对象、聚合、领域事件等。领域专家和技术团队通过通用语言,对相关的领域对象进行进一步划分,形成聚合并找到聚合根。
(3) 通过聚合划定限界上下文,这里需要依赖通用语言,因为同样一个事务在不同的限界上下文中所指的内容和含义可能有所不同。限界上下文就是服务的边界,根据它来创建服务或者应用。
好了,接下来按照上面的三个步骤进行下面的工作,首先是业务流程的分析。
2.4.1 分析业务流程
通过上面对业务需求的描述,我们可以把业务需求分为三个场景,分别是申请选修课场景、审批选修课场景和选修课签到场景。接下来我们分别画出这三个场景对应的业务流程图,并且标注参与者、命令和事件信息。
首先是申请选修课场景,如图 2-17 所示,图被分成四行,分别是参与者、业务流程、命令、事件。先看前两行,参与者和业务流程刚好可以组成一个简单的句子,例如学生登录系统。这个句子是通过需求调研获得的,也是领域专家和技术团队的通用语言。从左往右看业务流程这一行,箭头所指的方向就是业务的流动方向,依次是:
学生登录系统
学生申请选修课
学生修改申请
学生提交课程申请审批
图 2-17 申请选修课场景
在创建完这个业务流程的基础上,查找每个流程节点对应的命令和事件。例如登录系统节点对应登录动作;修改申请节点对应查询申请和修改申请这两个命令,以及选修课申请已修改事件。
第二个是审批选修课场景,采取的分析方式与第一个场景一样,先根据参与者和业务流程画出审批流程的几个阶段。如图 2-18 所示,审批流程分为三个阶段:老师登录系统、老师查询课程申请、老师进行课程申请审批。这里的参与者变成了老师。同样每个流程节点也有对应的命令和事件。
图 2-18 审批选修课场景
最后是选修课签到场景,签到流程分为四个阶段,和前面两个业务流程不一样的是这里的参与者既有老师也有学生。如图 2-19 所示,签到流程分为学生上课签到、老师检查签到、老师生成签到明细、学生查询签到明细,各流程对应的命令和事件也在图中标注出来了。严格来说,签到和查询签到还可以拆分成更细的两个场景,这里为了演示方便,先放到一起。有兴趣的朋友可以沿着这个思路做进一步的拆分。
图 2-19 选修课签到场景
好了,上面三张图就是业务分析阶段的结果。下面就把这些结果交给下一个阶段,来抽取领域对象和生成聚合。
2.4.2 抽取领域对象和生成聚合
2.4.1 节通过分析业务,将需求分成了参与者、业务流程、命令和事件。然后将它们对应领域对象,生成了领域对象之间的关系。本节将把上面提到的三个场景中的领域对象分别抽取出来,然后重点关注实体、领域事件、命令。抽取的目的是观察领域对象之间的关联和共性,最终对它们进行聚合和限界上下文划分。如图 2-20 所示,用不同的形状表示三个场景中的领域对象:圆形表示实体,长方形表示命令,五边形表示事件。注意,这里只粗略地划分领域对象,不做细分,因为目的是划分服务和聚合的边界。针对实体和值对象的识别和划分会在 2.5.5 节中详细介绍。
图 2-20 从业务流程中抽取领域对象
通过抽取领域对象,会发现以下几点。
选修课申请这个实体在申请选修课场景和审批选修课场景中都存在,而且表达的含义也相同。这个实体对应修改申请、查询申请、提交审批等命令。另外,审批规则实体和登录命令也是存在于申请选修课和审批选修课两个场景中。
再看选修课签到场景,签到明细作为实体,并没有出现关于选修课申请和审批规则的实体。学生和老师实体在三个场景中都存在,表达的都是同一个意思,属于通用的实体。
虽然选修课实体在三个场景中都存在,但申请选修课场景和审批选修课场景中的选修课描述的是课程本身,包括课程内容、学分;而选修课签到场景中的选修课更多的是关心上下课的时间、上课的位置等信息。这正是上下文不一致导致的,同一事物在不同上下文中的含义出现了偏差。
通过抽取领域对象,我们对业务逻辑有了更深的理解。现在就可以根据逻辑上的相关性,对领域对象进行组合,生成聚合了。聚合是逻辑上的边界,为限界上下文的划分提供依据。要想生成聚合,首先需要考虑聚合的逻辑独立性,即能否在聚合内部完成一个完整的业务逻辑。对于前面提到的申请选修课场景、审批选修课场景、选修课签到场景,当然可以生成三个聚合。但是考虑到前两个场景都是在完成申请审批的业务流程,因此可以合并为一个聚合。当然,如果业务合并在一起后显得比较复杂,也可以进行再次拆分。同时,选修课签到场景可以自己生成一个聚合,其中学生、老师实体属于组织关系,比较通用,系统中的其他地方应该也会用到这样的概念,所以可以抽取出来作为单独的聚合。如图 2-21 所示,将所有和申请、审批选修课相关的领域对象都划分到选修课申请聚合中,选修课申请作为该聚合的聚合根。将和签到相关的领域对象分到签到聚合中,签到明细作为聚合根。将学生和老师实体划分到人员组织聚合中。
图 2-21 从抽取领域对象到生成聚合
2.4.3 划定限界上下文
本节针对图 2-21 生成的聚合划分限界上下文,也就是生成服务的边界。如果说聚合是服务的逻辑边界,那么限界上下文就是服务的物理边界。从完成业务的角度来看,选修课申请聚合和签到聚合分属不同的语义环境。通过 2.4.2 节中对选修课这个实体在不同场景中的解释也可以看出,选修课申请聚合和签到聚合的含义不太相同。因此这两个聚合需要分开。至于人员组织聚合,可以单独将其划分为一个限界上下文,或者转化为一个通用组件。如图 2-22 所示,将三个聚合划分为选修课申请、签到、人员组织三个限界上下文。人员组织可以作为通用域,协助另外两个子域。选修课申请作为单独的限界上下文,承载大部分实体、命令和事件,可以考虑将其称为核心域。签到则可以作为支撑域,用来支撑核心域。
图 2-22 基于聚合划分限界上下文
图 2-22 所示的三个限界上下文,可以由三个应用服务对应实现,分别是选修课申请服务、签到服务、人员组织服务。这里也体现了我们想表达的分布式应用服务的拆分概念。当然,这种划分不是唯一的选择,例如选修课申请本身就是一个聚合,可以将这个聚合继续拆分成申请和审批两个聚合。随着业务的发展和变化,也可能衍生出新的限界上下文。这些需要不断利用领域驱动设计的思想去迭代。另外,关于限界上下文之间的通信,2.3.5 节也提到了,可以通过领域事件的方式进行,这里由于篇幅关系不再展开。本节主要聚焦于如何将业务拆分成应用服务,2.5 节将会介绍如何把领域对象落地成实际的架构和代码。
2.5 领域驱动设计分层
领域驱动设计分层能够帮助我们把领域对象转化为软件架构。在分解复杂的软件系统时,分层是最常用的一种手段。在领域驱动设计的思想中,分层代表软件框架,是整个分布式架构的“骨架”;领域对象是业务在软件中的映射,好比“血肉”。本节要做的事情是认识骨架,并且告诉大家如何将血肉填充到骨架中去,即领域驱动设计中的分层架构和每层的意义,以及如何将领域对象放置到分层架构中。
2.5.1 分层的概述与原则
在软件设计中,分层随处可见,分布式架构也不例外。我们面对的是一个纷繁复杂的世界,业务的发展和用户的需求变化一直萦绕着我们,软件架构作为业务和用户的支撑,更需要面对这种复杂的环境。我们需要一个使复杂问题简单化的工具,这个工具就是分层。分层不仅让我们能够站在一个更高的位置看待软件设计,还给整个架构带来了高内聚、低耦合、可扩展、可复用等优势,下面就具体分析一下这些优势。
高内聚:定义每层需要关注的重点,使复杂问题简单化,让整个架构清晰化。例如商店只负责卖好商品就行了,不需要考虑商品都是如何制作的。同样,基础设施层做好提供日志、通知服务的工作就好了,不用关注具体的业务流程是怎样的。
低耦合:各层分工明确,层与层之间通过标准接口进行通信。一个层次不需要关心其他层次的具体实现过程,即便其他层次的内部结构或者流程发生改变了,只要接口不变化就不会影响自己的工作。例如面包厂改变了生产面包的流程甚至工艺,但只要面包的口感、价格、包装没有发生改变,商店还是一样可以卖这些面包,不会因为面包厂的那些改变而影响卖面包这件事。这个例子中的口感、价格、包装就是层与层之间的接口。接口能够降低层与层之间的耦合度。
可扩展:由于每层都各司其职,层与层之间的沟通都通过接口完成,因此无论在哪层扩展功能,都是很方便的,只需要对其他层的功能进行组合即可。例如商店之前只卖面包,现在想扩展业务——卖蛋糕,于是就去联系蛋糕厂。以此类推,如果想扩展其他业务,可以去找其他厂家。究其根本,商店只需要知道如何经营和组合好这些商品就可以了。
可复用:每层都可以向一层或者多层提供服务,特别是基础、通用功能会被多处使用。例如面包厂的面包不仅可以提供给商店 A,也可以提供给商店 B。这样的复用提高了应用服务的使用率,避免了重复造轮子的现象。
了解了分层的优势以后,来看看如何具体实现分层。架构分层看上去,就是按照功能对每层进行分割和堆叠。但在具体落地时还需要考虑清楚,每层的职责以及层与层之间的依赖关系。架构有分成三层的,也有分成四层、五层的。业务情况、技术背景,以及团队架构不同,分层也会有所不同。这里通过领域驱动设计的分层方式,给分布式架构提供分层思路。
如图 2-23 所示,领域驱动设计将架构分成四层,从上往下分别是用户接口层、应用层、领域层和基础层。箭头表示层和层之间的依赖与被依赖关系。例如,箭头从用户接口层指向应用层,表示用户接口层依赖于应用层。从图中可以看到,基础层被其他所有层依赖,位于最核心的位置。
图 2-23 领域驱动设计的传统四层架构
但这种分法和业务领导技术的理念是相冲突的,搭建分布式架构时是先理解业务,然后对业务进行拆解,最后将业务映射到软件架构。这么看来,领域层才是架构的核心,所以图 2-23 中的依赖关系是有问题的。于是出现了 DIP(Dependency Inversion Principle,依赖倒置原则),DIP 的思想指出:高层模块不应该依赖于底层模块,这两者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。因此,作为底层的基础层应该依赖于用户接口层、应用层和领域层提供的接口。高层是根据业务展开的,通过对业务抽象产生了接口,底层依赖这些接口为高层提供服务。还是以商店卖面包为例,商店卖面包是业务行为,对该业务进行抽象得到的接口对应面包的种类、口感、价格、包装等。面包厂作为底层服务,要为商店提供面包这一服务,就需要依赖刚抽象出的接口,把这个接口作为生产目的对待。带着这个思想重新审视架构分层,所得结果如图 2-24 所示。可以看到,领域层跑到了最下面,应用层和基础层依赖于领域层,基础层和用户接口层均依赖于应用层。此时,领域层成为了分层架构的核心。
图 2-24 用依赖倒置的方式重新定义领域驱动设计的四层架构
上面介绍了架构分层的概念和优点,并且展开说明了领域驱动设计的架构分层。针对基础层、用户接口层、应用层以及领域层的分层说明会在 2.5.2 节中详细介绍。
2.5.2 分层的内容
本节从离用户最近的用户接口层开始介绍。
用户接口层也称为表现层,包括用户界面、Web 服务和远程调用三部分。该层负责向用户显示信息和解释用户指令。这里的用户既可以是系统的使用者,也可以是一个程序或者一个计算机系统。用户接口层负责系统与外界的通信和交互,例如 Web 服务负责接收和解释 HTTP 请求,以及解释、验证、转换输入参数。由于是跨系统的调用,因此会涉及信息的序列化与反序列化。说白了,该层的主要职责是与外部用户、系统交互,接受反馈,展示数据。特别需要说明的是,远程调用是分布式系统的核心思想,会在 3.4 节中重点介绍。
应用层比较简单,不包含业务逻辑,用来协调领域层的任务和工作。它不需要反映业务状态,只反映用户或程序的进展状态。应用层负责组织整个应用流程,是面向用例设计的。通常,应用服务是运行在应用层的,负责服务组合、服务编排和服务转发,组合业务执行顺序以及拼装结果。并不能说应用层和业务完全无关,它以粗粒度的方式对业务做简单组合。具体功能有信息安全认证、用户权限校验、事务控制、消息发送和消息订阅等。
领域层实现了应用服务的核心业务逻辑,并保证业务的正确性。这层体现了系统的业务能力,用来表达业务概念、业务状态和业务规则。
领域层包含领域驱动设计中的领域对象,例如聚合、聚合根、实体、值对象、领域服务。领域模型的业务逻辑由实体和领域服务实现。其次,当某些业务功能单一,且实体无法实现的时候,会由领域服务协助实现。领域服务可以将聚合内的多个实体组合在一起,实现复杂的业务逻辑。领域服务描述了业务操作的过程,可以对领域对象进行转换,处理多个领域对象,产生一个结果。说白了,就是领域服务可以操作一个或者多个领域对象,而这些操作是一个实体无法完成的。领域服务和应用服务的区别是,它具有更多的业务相关性。
基础层为其他三层提供通用的技术和基础服务,包括数据持久化、工具、消息中间件、缓存等。基础服务部分采取了前面提到的 DIP 技术,由该技术支持的基础资源给用户接口层、应用层与领域层提供服务,帮助层与层之间沟通,减少层与层之间的依赖,从而实现层与层之间的解耦。
例如在基础层实现的数据库访问,就是面向领域层接口的。领域层只是根据业务向基础层发出命令,告诉它需要提供的数据规格(数据规格包括用户名字、身份证、性别、年龄等信息),基础层负责获取对应的数据并交给领域层。具体如何获取数据、从什么地方获取数据,这些问题全部都是基础层需要考虑的,领域层是不关心的。领域层都面向同一个抽象的接口,这个接口就是数据规格。当数据库的实现方式发生更换时,例如从 Oracle 数据库换成了 MySQL 数据库,只要基础层把获取数据的实现方式修改一下即可;领域层则还是遵循之前的数据规格,进行数据获取,不受任何影响。
2.5.3 分层的总结
2.5.2 节将领域驱动设计中四个分层的概念都介绍了一遍,这里将它们总结到图 2-25 中,以便大家加深对分层概念的理解。理解分层概念有助于拆解技术架构,特别是在分布式架构中,业务技术混在一起,需要有一个方法论作为指导。而由于业务、经验、组织背景的不同,造成架构拆解和应用服务拆分也不同,因此本书无法给出一个标准答案,只是希望借助领域驱动设计的经典方法,为大家提供一个思路。
图 2-25 领域驱动设计的分层结构图
图 2-25 从上往下看。首先是用户接口层,包括用户界面、Web 服务以及信息通信功能。作为系统的入口,用户接口层下面是应用层,这一层主要包括应用服务,但不包含具体的业务,只是负责对领域层中的领域服务进行组合、编排和转发。应用层下面是领域层,这一层包括聚合、实体、值对象等领域对象,负责完成系统的主要业务逻辑。领域服务负责对一个或者多个领域对象进行操作,从而完成需要跨越领域对象的业务逻辑。用户接口层、应用层、领域层下方和右方的是基础层,这层就和它的名字一样,为其他三层提供基础服务,包括 API 网关、消息中间件、数据库、缓存、基础服务、通用工具等。除了提供基础服务,基础层还是针对通用技术的解耦。
2.5.4 服务内部的分层调用与服务间的调用
将分层思想落地到分布式架构或者微服务架构,每个被拆分的应用或者服务都包含用户接口层、应用层、领域层。那么服务内部以及服务之间是如何完成调用的呢?来看图 2-26,这张图可以回答这个问题。
图 2-26 服务内部和服务之间的调用
先看在服务内部,层与层之间是如何调用的。图 2-26 中的左边有一个服务 A,顺着实心箭头的方向看,调用先通过用户接口层来到应用层。由于应用层会对领域层的领域服务进行组合编排,以满足用户接口层的需要,因此可以看到应用服务 A 中包含两个领域服务,分别是领域服务 1 和领域服务 2,这两个领域服务分别对应领域层的领域服务 1 和领域服务 2。又因为领域服务是通过聚合中的实体以及实体方法完成业务逻辑的,所以箭头指向了实体,表示调用实体。在完成具体业务逻辑的同时,还需要调用基础层的数据库、缓存、基础服务等组件。
再看服务之间如何完成调用。我们知道可以通过限界上下文的方式对应用服务进行拆分,拆分后的每个应用或者服务在逻辑功能上都是一致的。于是服务之间的调用会跨越限界上下文,也就是跨越业务逻辑的边界。这种跨越边界的调用从应用层发起,体现在图 2-26 中,就是从左边服务 A 的应用层里面的应用服务 A 引出一根带箭头的虚线,指向右边服务 B 的应用层里面的应用服务 B。同时由于分层协作的关系,一个服务在调用其他服务时,需要通过基础层的 API 网关。解释完表示服务之间调用关系的虚线以后,往下看还有一条虚线,从领域服务 2 发出,先指向消息中间件,后指向领域服务 3。领域服务 2 产生领域事件以后,会把这个事件发往消息中间件,当领域服务 3 监听到这个产生的领域事件后,会继续执行后面的逻辑。总结一下,这两根虚线通过基础层完成两个服务之间的调用和信息传递。
2.5.5 把分层映射到代码结构
正如 2.5 节开头提到的那样,领域驱动设计的分层把领域对象转化为软件架构。分层的思路一直都影响着软件架构的设计,如果顺着这个思路继续往下,就是代码的实施部分了。因此本节介绍如何将分层架构转化为代码结构,代码结构是层次结构在代码实现维度的映射。好的层次设计有助于设计代码结构,好的代码结构设计更容易让人对整体软件架构有清晰的理解。下面就来逐层介绍代码结构。
用户接口层(UserInterface)的代码结构包括 Assembler、DTO 和 Facade 三部分内容,如图 2-27 所示。
图 2-27 用户接口层代码结构
顺着箭头所指的方向从上往下看图 2-27,展示层的 VO(ViewObject)传入到用户接口层后,先通过 Assembler 转换为 DTO,再由 Facade 往下传递。下面分别介绍一下用户接口层代码结构的三部分组成内容。
Assembler:起格式转换的作用。传入用户接口层的数据和用户接口层中的数据,格式有可能是不一样的。例如展示层提交了一个表单,我们称之为 VO(View Object,视图对象),这个 VO 传入用户接口层之后需要经过 Assembler 转换,形成用户接口层能够识别的 DTO 格式的数据。
DTO(Data Transfer Object,数据传输对象):它是用户接口层数据传输的载体,不包含业务逻辑,由 Assembler 转换而得。DTO 可以将用户接口层与外界隔离。
Facade:门面,是服务提供给外界系统的接口,也是调用领域服务的入口。Facade 提供较粗粒度的调用接口,通常不包含业务逻辑,只是将用户请求转交给应用服务进行处理。一般地,提供 API 服务的 Controller 就是一个 Facade。
根据代码结构的思路,如图 2-28 所示,代码目录中处在最上层的是 userinterface,它下面分别是 assembler、dto 和 facade 目录。
图 2-28 用户接口层代码目录
应用层的代码结构由 Command、Application Service 和 Event 组成,如图 2-29 所示。
图 2-29 应用层代码结构
同样是顺着箭头所指的方向从上往下看图 2-29,用户接口层传入的消息先转换成 Command,然后交给 Application Service 做处理。Application Service 负责连接领域层,调用领域服务、聚合(根)等领域对象,对业务逻辑进行编排和组装。同时,Application Service 还协助领域层订阅和发布 Event。下面分别介绍一下应用层代码结构的三部分组成内容。
Command:命令,可以理解为用户所做的操作,例如下订单、支付等,是应用服务的传入参数。
Application Service:应用服务,会调用和封装领域层的 Aggregate、Service、Entity、Repository、Factory。其主要实现组合和编排,本身不实现业务,只对业务进行组合。
Event:事件,这里主要存放事件相关的代码,负责事件的订阅和发布。事件的发起和响应则放在领域层处理。如果用订报纸来举例,那么应用层的 Event 负责的是订阅报纸和联系发布报纸,阅读订阅的报纸和发布报纸的具体工作则由领域层的 Event 完成。
应用层的代码目录如图 2-30 所示,处在最上面的是 application 目录,它下面包括 command、event(publish、subscribe)和 service 目录。
图 2-30 应用层代码目录
领域层的代码结构包括一个或者多个 Aggregate(聚合)。每个 Aggregate 又包括 Entity、Event、Repository、Service、Factory 等,这些领域模型共同完成核心业务逻辑。领域层的代码结构如图 2-31 所示。
图 2-31 领域层代码结构
由图 2-31 可以看出,应用层依赖于领域层中的 Aggregate 和 Service。Aggregate 中包含 Entity 和值对象。Service 会对领域对象进行组合,完成复杂的业务逻辑。Aggregate 中的方法和 Service 中的动作都会产生 Event。所有领域对象的持久化和查询都由 Repository 实现。下面分别介绍一下领域层代码结构的组成内容。
Aggregate:聚合,聚合的根目录通常由一个实体的名字来表示,例如订单、商品。由于聚合定义了服务内部的逻辑边界,因此聚合中的实体、值对象、方法都围绕某一个逻辑功能展开,例如订单聚合包括订单项信息、下单方法、修改订单的方法和付款方法等,其主要目的是实现业务的高内聚。由于一个服务由多个聚合组成,因此服务的拆分和扩容都可以根据聚合重新编排。如图 2-32 所示,当服务 1 中的聚合 C 成为业务瓶颈时,可以将其扩展到服务 3 中。又或者由于业务重组,聚合 A 可以从服务 1 迁移到服务 2 中。
图 2-32 聚合的代码结构方便服务扩展和重组
Entity:实体,包括业务字段和业务方法。跨实体的业务逻辑代码则可以放到 Service 中。
Event:领域事件,包括与业务活动相关的逻辑代码,例如订单已创建、订单已付款。作为负责聚合间沟通的工具,Event 需要实现发送和监听事件的功能。建议将监听事件的代码单独存放在 listener 目录中。
Service:领域服务,包括需要由一个或者多个实体共同完成的服务,或者需要调用外部服务完成的功能,例如订单系统需要调用外部的支付服务来完成支付操作。如果 Service 的业务逻辑比较复杂,可以针对每个 Service 分别设计类,遇到需要调用外部系统的地方最好采用代理类来实现,以做到最大程度的解耦。
Repository:仓库,其作用是持久化对象。针对数据的操作都放在这里,主要是读取和查询。一般来说,一个 Aggregate 对应一个 Repository。
领域层的代码目录如图 2-33 所示,处在最上面的是 domain 目录,它下面可以存放多个 aggregate 目录,其命名可以根据业务来定义。每个 aggregate 目录下面存放着 entity、event、repository、service 目录,分别代表实体、领域事件、仓库和服务。
图 2-33 领域层代码目录
基础层的代码结构主要包括工具、算法、缓存、网关以及一些基础通用类。这层的目录存放比较随意,根据具体情况具体决定。这里也不做具体的规定,仅给出一个例子以供参考,如图 2-34 所示。最上面是 infrastructure 目录,它下面存放着 config 和 util 文件夹,分别存放与配置和工具相关的代码。2.5.6 节会根据业务场景再加入一些其他目录。
图 2-34 基础层代码目录
至此,就聊完了各层的代码结构和代码目录。这里总结一下,如图 2-35 所示。
图 2-35 分层代码结构图
图 2-35 中最右边的其他服务通过基础层中的 API 网关,将信息传入用户接口层。传入的信息先通过 Assembler 转换成 DTO 对象,再传给 Facade。Facade 负责把信息传递给应用层,信息以命令的形式被传递给 Application Service。Application Service 会组合领域层中的 Aggregate 和 Service。领域层中的 Entity 和值对象,配合 Aggregate 和 Service 完成业务逻辑,并且通过 Repository 将 Entity 和值对象存储到数据库中。领域层中的 Event 会根据业务的发生,获取事件信息,通过应用层中 Event 里的订阅和发布,与其他服务进行通信。
最后,图 2-36 给出了所有代码目录组成的一张大图,分为四层,每层再根据自己的功能做进一步拆分。
图 2-36 分层后的代码目录
2.5.6 代码分层示例
2.5.5 节讲了如何把架构分层映射到代码结构,每层根据不同的功能分别需要做哪些实现。本节举一个例子,将代码落地。先介绍业务背景,我们要实现一个创建订单的功能,其中每个订单都有多个订单项,每个订单项分别对应一个产品,产品有对应的价格;可以根据订单项和订单的价格计算订单总价,针对每个订单设置对应的送单地址。接下来具体实现这个小业务的聚合代码架构。
图 2-37 从实体、事件和命令三个维度对上一段中的业务进行了切分。该业务涉及订单、订单项、地址、订单状态、产品实体。围绕这些实体,有订单已创建、订单地址已修改、订单已支付、订单项已创建、产品已修改事件。每个事件都有一个命令与之对应,分别是创建订单、修改订单地址、支付订单、创建订单项、修改产品。
图 2-37 从三个维度切分订单业务
可以看出,订单业务是围绕订单展开的,实体、事件、命令中都涉及订单相关的内容。按照这个思路,可以画出订单与其他领域对象的关系,如图 2-38 所示。
图 2-38 订单聚合关系图
在图 2-38 中,一个订单包含 N 个订单项,一个订单项对应一个产品。此外,订单还包含地址和订单状态信息,它们之间都是 1 对 1 的关系。订单中包含创建订单(项)、支付订单、修改订单地址的命令,这些命令可以理解为实体类中的方法。这三个命令分别对应三个事件:订单(项)已创建、订单已支付、订单地址已修改。订单项作为订单的一部分,对应修改产品的命令和产品已修改的事件。
分析完聚合,会发现整个聚合都是围绕订单展开的,因此可以将订单作为这个聚合的聚合根。对图 2-38 进行进一步的拆解,就产生了具体的类、属性和方法,即图 2-39。其中主要描述了领域层中的领域对象(订单、订单项、地址、订单状态),以及对应的领域事件(如订单已创建、订单项已创建、订单已支付、订单地址已修改、产品已修改),还有订单仓库和订单服务。后几列分别对这些内容定义了对象类型、包名、类名、属性和方法名。
图 2-39 订单聚合在领域层的关系图
图 2-40 描述的是应用层的关系图。应用层主要实现订单服务,这个服务中需要定义三个属性,分别如下。
OrderRepository:订单仓库,用于获取订单仓库的数据库接口。一般来说,主要的业务逻辑会在领域层中的聚合根和领域服务中完成,应用层是不会直接操作数据库的,但在有些情况下是可以操作的。例如通过订单 ID 获取订单信息,或者将订单实体直接保存到数据库中。这些操作并不包含复杂业务,如果交由领域层处理,将会导致操作烦琐,因此要放在应用层完成。
OrderFactory:订单工厂,用来生成聚合根或者领域对象。本例中的 Order 对象就是由 OrderFactory 产生,然后投入使用的。
OrderPaymentService:支付服务,这是一个用来完成支付功能的领域服务。
除了订单服务,图 2-40 中其他 5 个命令的作用都是作为订单服务的输入,这在后面的代码调用过程中会详细介绍。
图 2-40 订单聚合在应用层的关系图
领域层和应用层是代码的主要组成部分,用户接口层和基础层的代码则比较简单。订单业务的完整代码目录见图 2-41。其中 userinterface 文件夹下面就是用户接口层的内容,这里比较简单,是一个 Web API 的 controller,负责对外提供访问接口,由于没有对象转换,所以 assembler 和 dto 文件夹是空的。infrastructure 目录里面存放的是基础层的内容。由于需要定义聚合根,因此 aggregate 目录中存放的是聚合的基础类。event 目录中存放的是事件相关的基础类。同样,exception 目录存放针对异常定义的基础类,jackson 目录存放针对序列化、反序列化的基础类,repository 目录存放数据仓库的基础类。
图 2-41 订单业务的代码目录全貌
到现在,我相信大家知道如何将分层结构落地到代码层面了。这里再通过一个调用过程介绍一下这些代码是如何协作的。图 2-42 描述了如何用代码创建订单,这里对调用过程做了最大程度的简化,尽量让每层调用都清晰化。数据库存储和事件发送属于基础层完成的部分,由于篇幅关系,这里不具体实现。
图 2-42 创建订单代码流程
首先,请求由用户接口层传入,由于是创建订单操作,所以会把 CreateOrderCommand 命令作为参数传入 OrderController 类中。接收到该命令以后,用户接口层会调用应用层中的 OrderApplicationService,其中的 createOrder 方法会分别调用领域层的 OrderFactory 和 OrderRepository。OrderFactory 的 create 方法可以生成聚合根 Order,然后调用 Order 中的 create 方法生成订单。之后 Order 会调用 raiseEvent 方法向其他服务发送 OrderCreatedEvent,以通知其他服务订单已创建。createOrder 会调用 OrderRepository 中的 save 方法,传入参数是 Order,将 Order 保存到数据库中。
从图 2-43 可以看出,应用层中的服务只负责生成聚合根 Order,然后将其保存下来。
图 2-43 应用层服务调用领域层方法生成订单并保存
而从图 2-44 可以看出,在领域层的聚合根 Order 中,是通过 create 方法创建订单的,在订单生成以后才通过 raiseCreatedEvent 发送消息。
图 2-44 领域层聚合根创建订单
2.6 总结
本章从分布式应用服务的拆分开始,讲述了领域驱动设计的拆分思路和领域模型的结构。说明了应用服务的拆分过程,即业务需求分析、领域知识抽取,以及建立架构。沿着这个拆分过程,分别从领域驱动设计中的模型结构、业务需求拆分流程、架构分层设计三个方面讲述了在分布式系统中如何拆分应用服务的整个过程。既然对应用服务进行了拆分,也就形成了分布式系统中应用分离的状态,给后面章节介绍服务之间的调用、协调打下了基础。
公开
同步至部落
取消
完成
0/2000
荧光笔
直线
曲线
笔记
复制
AI
- 深入了解
- 翻译
- 解释
- 总结
本文深入探讨了领域驱动设计在分布式架构中的重要性和应用方法。传统需求落地过程中的沟通障碍导致了系统架构混乱和性能问题,而领域驱动设计通过定义业务和应用边界的领域模型,保证了业务与代码的一致性。文章详细阐述了领域驱动设计的拆分思路,包括通用语言的重要性、领域知识的获取和领域模型的构建。此外,还介绍了领域、子域和限界上下文的概念,以及它们在业务和技术层面的作用。通过领域驱动设计方法,读者可以更好地理解和应用领域驱动设计在分布式架构中的作用,为应用服务的拆分提供了思路和方法。文章内容深入浅出,通过实例和图表生动展示了实体和值对象的关系,为读者提供了清晰的技术指导。文章还介绍了聚合和聚合根的概念,以及领域事件在不同聚合之间的协同工作。最后,文章通过一个学生选课系统的业务背景,演示了如何将业务需求转换成领域驱动设计模型的过程。整体而言,本文为读者提供了全面的领域驱动设计知识,帮助他们更好地理解和应用这一设计方法。
2024-01-04给文章提建议
仅可试看部分内容,如需阅读全部内容,请付费购买文章所属专栏
《分布式架构原理与实践》
《分布式架构原理与实践》
立即购买
登录 后留言
精选留言
由作者筛选后的优质留言将会公开显示,欢迎踊跃留言。
收起评论