1. 首页
  2. >
  3. 架构设计

DDD深入浅出

为什么我们要了解ddd?

作为一个开发者,我们肯定接手过其他的人的项目。我想你一定有个这样的经历:

面对冗杂的系统,模块彼此关联,没有人能描述清楚每个细节,没有文档,即使有文档也和系统对不上。当新需求需要修改一个功能时,往往光回顾该功能涉及的流程就需要很长时间,更别提修改带来的不可预知的影响面。于是 RD 就加开关,小心翼翼地切流量上线,一有问题赶紧关闭开关。


面对此般场景,你要么跑路,要么重构。重构是克服演进式设计中大杂烩问题的主力,通过在单独的类及方法级别上做一系列小步重构来完成,我们可以很容易重构出一个独立的类来放某些通用的逻辑,但是,你会发现你很难给它一个业务上的含义,只能给予一个技术维度描绘的含义。你正在一边重构一边给后人挖坑。


作为一个架构师,在软件开发中如何降低系统复杂度是一个永恒的挑战,虽然通过一系列的设计模式或范例来降低一些常见的复杂度。但是问题在于,这些理念是通过技术手段解决技术问题,但并没有从根本上解决业务的问题。

如果你也有这方面的苦恼,那么ddd 的思想也许能为你带来启发。


DDD 和传统数据驱动的区别

DDD 全称是 Domain-Driven Design,中文叫领域驱动设计,是一套应对复杂软件系统分析和设计的面向对象建模方法论。

什么是数据驱动

DDD深入浅出

传统的数据驱动开发模式,View、Service、dao这种三层分层模式,开发者会很自然的写出过程式代码,这种开发方式中的对象只是数据载体,而没有行为,是一种贫血对象模型。以数据为中心,以数据库ER图为设计驱动,分层架构在这种开发模式下可以认为是数据处理和实现的过程。


什么是领域驱动

DDD深入浅出


以前的系统分析和设计是分开的,导致需求和成品非常容易出现偏差,两者相对独立,还会导致沟通困难,DDD 则打破了这种隔阂,提出了领域模型概念,统一了分析和设计编程,使得软件能够更灵活快速跟随需求变化。



DDD 的宏观理念其实并不难懂,但是如同 REST 一样,DDD 也只是一个设计思想,缺少一套完整的规范,导致DDD新手落地困难。


由于 DDD 不是一套框架,而是一种架构思想,所以在代码层面缺乏了足够的约束,导致 DDD 在实际应用中上手门槛很高,甚至可以说绝大部分人都对 DDD 的理解有所偏差。举个例子(贫血域模型)在实际应用当中层出不穷,而一些仍然火热的 ORM 工具比如 Hibernate,Entity Framework 实际上助长了贫血模型的扩散。同样的,传统的基于数据库技术以及 MVC 的四层应用架构(UI、Business、Data Access、Database),在一定程度上和 DDD 的一些概念混淆,导致绝大部分人在实际应用当中仅仅用到了 DDD 的建模的思想,而其对于整个架构体系的思想无法落地。


从单机的时代,服务化的架构还局限于单机 +LB 用 MVC 提供 Rest 接口供外部调用,到今天,在一个所有的东西都能被称之为“服务”的时代(XAAS),人们在踩过诸多拆分服务的坑(拆分过细导致服务爆炸、拆分不合理导致频分重构等)之后,开始死锁原因了。DDD 的思想让我们能冷静下来,去思考到底哪些东西可以被服务化拆分,哪些逻辑需要聚合,才能带来最小的维护成本,而不是简单的去追求开发效率。


有了 DDD 的指导,加之微服务的事件,才是完美的架构。


DDD 与微服务的关系

系统的复杂度越来越来高是必然趋势,原因可能来自自身业务的演进,也有可能是技术的创新,然而一个人和团队对复杂性的认知是有极限的,就像一个服务器的性能极限一样,解决的办法只有分而治之,将大问题拆解为小问题,最终突破这种极限。微服务在这方面都给出来了理论指导和最佳实践,诸如注册中心、熔断、限流等解决方案,但微服务并没有对“应对复杂业务场景”这个问题给出合理的解决方案,这是因为微服务的侧重点是治理,而不是分。


我们都知道,架构一个系统的时候,应该从以下几方面考虑:

  1. 功能维度
  2. 质量维度(包括性能和可用性)
  3. 工程维度

微服务在第二个做得很好,但第一个维度和第三个维度做的不够。这就给 DDD 了一个“可乘之机”,DDD 给出了微服务在功能划分上没有给出的很好指导这个缺陷。所以说它们在面对复杂问题和构建系统时是一种互补的关系。

DDD 与微服务如何协作

知道了 DDD 与微服务还不够,我们还需要知道他们是怎么协作的。

一个系统(或者一个公司)的业务范围和在这个范围里进行的活动,被称之为领域,领域是现实生活中面对的问题域,和软件系统无关,领域可以划分为子域,比如电商领域可以划分为商品子域、订单子域、发票子域、库存子域 等,在不同子域里,不同概念会有不同的含义,所以我们在建模的时候必须要有一个明确的边界,这个边界在 DDD 中被称之为限界上下文,它是系统架构内部的一个边界,《整洁之道》这本书里提到:

系统架构是由系统内部的架构边界,以及边界之间的依赖关系所定义的,与系统中组件之间的调用方式无关。
所谓的服务本身只是一种比函数调用方式成本稍高的,分割应用程序行为的一种形式,与系统架构无关。

所以复杂系统划分的第一要素就是划分系统内部架构边界,也就是划分上下文,以及明确之间的关系,这对应之前说的第一维度(功能维度),这就是 DDD 的用武之处。其次,我们才考虑基于非功能的维度如何划分,这才是微服务发挥优势的地方。


假如我们把服务划分成 ABC 三个上下文:

DDD深入浅出

我们可以在一个进程内部署单体应用,也可以通过远程调用来完成功能调用,这就是目前的微服务方式,更多的时候我们是两种方式的混合,比如 A 和 B 在一个部署单元内,C 单独部署,这是因为 C 非常重要,或并发量比较大,或需求变更比较频繁,这时候 C 独立部署有几个好处:

  1. C 独立部署资源:资源更合理的倾斜,独立扩容缩容。
  2. 弹力服务:重试、熔断、降级等,已达到故障隔离。
  3. 技术栈独立:C 可以使用其他语言编写,更合适个性化团队技术栈。
  4. 团队独立:可以由不同团队负责。

架构是可以演进的,所以拆分需要考虑架构的阶段,早期更注重业务逻辑边界,后期需要考虑更多方面,比如数据量、复杂性等,但即使有这个方针,也常会见仁见智,没有人能一下子将边界定义正确,其实这里根本就没有明确的对错。

即使边界定义的不太合适,通过聚合根可以保障我们能够演进出更合适的上下文,在上下文内部通过实体和值对象来对领域概念进行建模,一组实体和值对象归属于一个聚合根。

按照 DDD 的约束要求:

  • 第一,聚合根来保证内部实体规则的正确性和数据一致性;
  • 第二,外部对象只能通过 id 来引用聚合根,不能引用聚合根内部的实体;
  • 第三,聚合根之间不能共享一个数据库事务,他们之间的数据一致性需要通过最终一致性来保证。

有了聚合根,再基于这些约束,未来可以根据需要,把聚合根升级为上下文,甚至拆分成微服务,都是比较容易的。

其实DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。而微服务追求业务层面的复用,设计出来的系统架构和业务一致;在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。

可以参见下图来更好地理解双方之间的协作关系:

DDD深入浅出


DDD 的相关术语与基本概念

我们来认识一下 DDD 的一些概念吧,每个概念找了一个 Spring 模式开发的映射概念,方便理解,但要仅仅作为理解用,不要过于依赖。另外,这里可能需要结合后面的代码反复结合理解,才能融汇贯通到实际工作中。

领域

映射概念:切分的服务。

领域就是范围。范围的重点是边界。领域的核心思想是将问题逐级细分来减低业务和系统的复杂度,这也是 DDD 讨论的核心。

子域

映射概念:子服务。

领域可以进一步划分成子领域,即子域。这是处理高度复杂领域的设计思想,它试图分离技术实现的复杂性。这个拆分的里面在很多架构里都有,比如 C4。

DDD深入浅出


核心域

映射概念:核心服务。

在领域划分过程中,会不断划分子域,子域按重要程度会被划分成三类:核心域、通用域、支撑域。


通用域

映射概念:中间件服务或第三方服务。

支撑域

映射概念:企业公共服务。

统一语言

映射概念:统一概念。

定义上下文的含义。它的价值是可以解决交流障碍,不管你是 RD、PM、QA 等什么角色,让每个团队使用统一的语言(概念)来交流,甚至可读性更好的代码。

通用语言包含属于和用例场景,并且能直接反应在代码中。

可以在事件风暴(开会)中来统一语言,甚至是中英文的映射、业务与代码模型的映射等。可以使用一个表格来记录。

限界上下文

映射概念:服务职责划分的边界。

定义上下文的边界。领域模型存在边界之内。对于同一个概念,不同上下文会有不同的理解,比如商品,在销售阶段叫商品,在运输阶段就叫货品。

DDD深入浅出

理论上,限界上下文的边界就是微服务的边界,因此,理解限界上下文在设计中非常重要。

聚合

映射概念:包。

聚合概念类似于你理解的包的概念,每个包里包含一类实体或者行为,它有助于分散系统复杂性,也是一种高层次的抽象,可以简化对领域模型的理解。

拆分的实体不能都放在一个服务里,这就涉及到了拆分,那么有拆分就有聚合。聚合是为了保证领域内对象之间的一致性问题。

在定义聚合的时候,应该遵守不变形约束法则:

  1. 聚合边界内必须具有哪些信息,如果没有这些信息就不能称为一个有效的聚合;
  2. 聚合内的某些对象的状态必须满足某个业务规则:
  3. 一个聚合只有一个聚合根,聚合根是可以独立存在的,聚合中其他实体或值对象依赖与聚合根。
  4. 只有聚合根才能被外部访问到,聚合根维护聚合的内部一致性。

聚合根

映射概念:包。

一个上下文内可能包含多个聚合,每个聚合都有一个根实体,叫做聚合根,一个聚合只有一个聚合根。

实体

映射概念:Domain 或 entity。

《领域驱动设计模式、原理与实践》一书中讲到,实体是具有身份和连贯性的领域概念,可以看出,实体其实也是一种特殊的领域,这里我们需要注意两点:唯一标示(身份)、连续性。两者缺一不可。

你可以想象,文章可以是实体,作者也可以是,因为它们有 id 作为唯一标示。

值对象

映射概念:Domain 或 entity。

为了更好地展示领域模型之间的关系,制定的一个对象,本质上也是一种实体,但相对实体而言,它没有状态和身份标识,它存在的目的就是为了表示一个值,通常使用值对象来传达数量的形式来表示。

比如 money,让它具有 id 显然是不合理的,你也不可能通过 id 查询一个 money。

定义值对象要依照具体场景的区分来看,你甚至可以把 Article 中的 Author 当成一个值对象,但一定要清楚,Author 独立存在的时候是实体,或者要拿 Author 做复杂的业务逻辑,那么 Author 也会升级为聚合根。


四种领域模型

失血模型、贫血模型、充血模型、胀血模型

四种模型示例

失血模型

Domain Object 只有属性的 getter/setter 方法的纯数据类,所有的业务逻辑完全由 business object 来完成。

public class Article implements Serializable {     private Integer id;     private String title;     private Integer classId;     private Integer authorId;     private String authorName;     private String content;     private Date pubDate;     //getter/setter/toString }   public interface ArticleDao {      public Article getArticleById(Integer id);      public Article findAll();      public void updateArticle(Article article); }

贫血模型

简单来说,就是 Domain Object 包含了不依赖于持久化的领域逻辑,而那些依赖持久化的领域逻辑被分离到 Service 层。

public class Article implements Serializable {     private Integer id;     private String title;     private Integer classId;     private Integer authorId;     private String authorName;     private String content;     private Date pubDate;     //getter/setter/toString     //判断是否是热门分类(假设等于57或102的类别的文章就是热门分类的文章)     public boolean isHotClass(Article article){         return Stream.of(57,102)             .anyMatch(classId -> classId.equals(article.getClassId()));     }     //更新分类,但未持久化,这里不能依赖Dao去操作实体化     public Article changeClass(Article article, ArticleClass ac){         return article.setClassId(ac.getId());     } }   @Repository("articleDao") public class ArticleDaoImpl implements ArticleDao{     @Resource     private ArticleDao articleDao;     public void changeClass(Article article, ArticleClass ac){         article.changeClass(article, ac);         articleDao.update(article)     } }

注意这个模式不在 Domain 层里依赖 DAO。持久化的工作还需要在 DAO 或者 Service 中进行。

这样做的优缺点

优点:各层单向依赖,结构清晰。

缺点:

  • Domain Object 的部分比较紧密依赖的持久化 Domain Logic 被分离到 Service 层,显得不够 OO
  • Service 层过于厚重

充血模型

充血模型和第二种模型差不多,区别在于业务逻辑划分,将绝大多数业务逻辑放到 Domain 中,Service 是很薄的一层,封装少量业务逻辑,并且不和 DAO 打交道:

Service (事务封装) —> Domain Object <—> DAO

public class Article implements Serializable {     @Resource     private static ArticleDao articleDao;     private Integer id;     private String title;     private Integer classId;     private Integer authorId;     private String authorName;     private String content;     private Date pubDate;     //getter/setter/toString     //使用articleDao进行持久化交互     public List<Article> findAll(){         return articleDao.findAll();     }     //判断是否是热门分类(假设等于57或102的类别的文章就是热门分类的文章)     public boolean isHotClass(Article article){         return Stream.of(57,102)             .anyMatch(classId -> classId.equals(article.getClassId()));     }     //更新分类,但未持久化,这里不能依赖Dao去操作实体化     public Article changeClass(Article article, ArticleClass ac){         return article.setClassId(ac.getId());     } }

所有业务逻辑都在 Domain 中,事务管理也在 Item 中实现。这样做的优缺点如下。

优点:

  • 更加符合 OO 的原则;
  • Service 层很薄,只充当 Facade 的角色,不和 DAO 打交道。

缺点:

  • DAO 和 Domain Object 形成了双向依赖,复杂的双向依赖会导致很多潜在的问题。
  • 如何划分 Service 层逻辑和 Domain 层逻辑是非常含混的,在实际项目中,由于设计和开发人员的水平差异,可能 导致整个结构的混乱无序。

胀血模型

基于充血模型的第三个缺点,有同学提出,干脆取消 Service 层,只剩下 Domain Object 和 DAO 两层,在 Domain Object 的 Domain Logic 上面封装事务。

Domain Object (事务封装,业务逻辑) <—> DAO

似乎 Ruby on rails 就是这种模型,它甚至把 Domain Object 和 DAO 都合并了。

这样做的优缺点:

  • 简化了分层
  • 也算符合 OO

该模型缺点:

  • 很多不是 Domain Logic 的 Service 逻辑也被强行放入 Domain Object ,引起了 Domain Object 模型的不稳定;
  • Domain Object 暴露给 Web 层过多的信息,可能引起意想不到的副作用。DDD落地的一些思考最近把一个新项目使用了DDD思想进行落地,项目代码结构如下:
    整体感觉还是不错的,不用文档,基本也能看清楚业务的脉络。使用Domain Primitive(DP)和CQRS 设计接口,接口语义更加清楚,接口参数的清晰度,业务代码逻辑的清晰度显著提高,而且方便后期读写分离,使用Event Sourcing模式,领域对象的状态完全是由事件驱动的,可以最大限度的实现系统的松耦合。唯一的缺点就是代码量的膨胀,但是这也是不可避免的。对于大团队来讲利大于弊,减少沟通成本,小团队按需尝试。