何时使用领域驱动设计

何时使用领域驱动设计
何时使用领域驱动设计?其实当你的应用程序架构设计是面向业务的时候,你已经开始使用领域驱动设计了。领域驱动设计既不是架构风格(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模式中也包含领域模型、仓储等概念,然而,实现方式与分层架构大不相同:
领域模型中不包含规约(Specifications),因为“写”端不具备查询功能
领域模型中聚合本身的行为(也就是方法)仅包含一个职责,就是派发领域事件(Domain Events)。例如,下面就是修改User聚合的Email属性的样例代码,从代码上看,它仅仅是派发了一个事件:

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

我相信你肯定会有疑问:这不是多此一举么?在ChangeEmail方法中直接设置属性不就行了?然而,答案就是不行,因为当调用方通过User ID来向仓储读取User聚合的时候,仓储会从数据库中读出与这个ID相关的所有事件,然后逐一应用在User对象上,此时,上面的由InlineEventH
何时使用领域驱动设计
声明:除非特别标注,否则均为本站原创文章,转载时请以链接形式注明文章出处。如若本站内容侵犯了原著者的合法权益,可联系本站删除。





