You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

19 KiB

01领域驱动设计到底在讲什么

你好我是徐昊。今天我们来聊聊领域驱动设计Domain Driven Design即DDD

说起业务建模领域驱动设计是一个绕不过去的话题。自从Eric Evans在千禧年后发布他的名著“Domain Driven DesignTackling the Complexity in the Heart of Software”领域驱动设计这一理念迅速被行业采纳时至今日仍是绝大多数人进行业务建模的首要方法。

有意思的是可能因为成书年代过于久远大多数人并没有读过Eric的书而是凭直觉本能地接受了领域驱动这一说法或是在实践中跟随周围的实践者学习使用它。但是对于Eric到底在倡导一种什么样的做法并不了然

所以今天这节课我们要回顾一下领域驱动设计的要点和大致做法从而可以更好地理解DDD从何处而来以及DDD在其创始人的构想中是如何操作的。

领域模型对于业务系统是更好的选择

我们都知道软件开发的核心难度在于处理隐藏在业务知识中的复杂度那么模型就是对这种复杂度的简化与精炼。所以从某种意义上说Eric倡导的领域驱动设计是一种模型驱动的设计方法通过领域模型Domain Model捕捉领域知识,使用领域模型构造更易维护的软件。

模型在领域驱动设计中,其实主要有三个用途:

  1. 通过模型反映软件实现Implementation的结构
  2. 以模型为基础形成团队的统一语言Ubiquitous Language
  3. 把模型作为精粹的知识,以用于传递。

这样做的好处是显而易见的:

  1. 理解了模型,你就会大致理解代码的结构;
  2. 在讨论需求的时候,研发人员可以很容易明白需要改动的代码,并对风险与进度有更好地评估;
  3. 模型比代码更简洁,毕竟模型是抽象出来的,因而有更低的传递成本。

模型驱动本身并不是什么新方法,像被所有人都视为编程基本功的数据结构,其实也是一系列的模型。我们都知道有一个著名的公式“程序 = 算法 + 数据结构”,实际上这也是一种模型驱动的思路,指的是从数据结构出发构造模型以描述问题,再通过算法解决问题。

在软件行业发展的早期,堆、栈、链表、树、图等与领域无关的模型,确实帮我们解决了从编译器、内存管理到数据库索引等大量的基础问题。因此,无数的成功案例让从业人员形成了一种习惯:将问题转化为与具体领域无关的数据结构,即构造与具体领域无关的模型

而领域驱动则是对这种习惯的挑战,它实际讲的是:对于业务软件而言,从业务出发去构造与业务强相关的模型,是一种更好的选择。那么模型是从业务出发还是与领域无关,关键差异体现在人,而不是机器对模型的使用上。

构造操作系统、数据库等基础软件的团队,通常都有深厚的开发背景,对于他们而言,数据结构是一种常识。更重要的是,这种常识并不仅仅体现在对数据结构本身的理解上(如果仅仅是结构那还不能算难以理解),还体现在与数据结构配合的算法,这些算法产生的行为,以及这些行为能解决什么问题。

比如树Tree这种非常有用的数据结构它可以配合深度优先Depth-First、广度优先Breadth-First遍历产生不同的行为模式。那么当开发人员谈论树的时候它们不仅仅指代这种数据结构还暗指了背后可能存在的算法与行为模式以及这种行为与我们当前要解决的业务功能上存在什么样的关联。

但是,如果我们构造的是业务系统,那么团队中就会引入并不具有开发背景的业务方参与。这个时候,与领域无关的数据结构及其关联算法,由于业务方并不了解,在他们的头脑中也就无法直观地映射为业务的流程和功能。这种认知上的差异,会造成团队沟通的困难,从而破坏统一语言的形成,加剧知识传递的难度。

于是在业务系统中,构造一种专用的模型(领域模型),将相关的业务流程与功能转化成模型的行为,就能避免开发人员与业务方的认知差异。这也是为什么我们讲,领域模型对于业务系统来说是一种更好的选择。

或许在今天看起来,这种选择是天经地义的。但事实是,这一理念的转变开始于面向对象技术的出现而最终的完成则是以行业对DDD的采纳作为标志的

不同于软件行业对数据结构的长时间研究与积累,在不同的领域中该使用什么样的领域模型,其实并没有一个现成的做法。因而在DDD中Eric Evans提倡了一种叫做知识消化Knowledge Crunching的方法帮助我们去提炼领域模型。这么多年过去了,也产生了很多新的提炼领域模型的方法,但它们在宏观上仍然遵从知识消化的步骤

知识消化的五个步骤

知识消化法具体来说有五个步骤,分别是:

  1. 关联模型与软件实现;
  2. 基于模型提取统一语言;
  3. 开发富含知识的模型;
  4. 精炼模型;
  5. 头脑风暴与试验。

在知识消化的五步中,关联模型与软件实现,是知识消化可以顺利进行的前提与基础。它将模型与代码统一在一起,使得对模型的修改,就等同于对代码的修改。

根据模型提取统一语言,则会将业务方变成模型的使用者。那么通过统一语言进行需求讨论,实际就是通过模型对需求进行讨论。

后面三步呢,构成了一个提炼知识的循环:通过统一语言讨论需求;发现模型中的缺失或者不恰当的概念,精炼模型以反映业务的实践情况;对模型的修改引发了统一语言的改变,再以试验和头脑风暴的态度,使用新的语言以验证模型的准确。

如此循环往复不断完善模型与统一语言。因其整体流程与重构Refactoring类似也有人称之为重构循环。示意图如下:

说句题外话,目前很多人把 Knowledge Crunching 翻译为“知识消化”。不过在我看来应该直译为“知识吧唧嘴”更好些Crunching 就是吃薯片时发出的那种难以忽略的咔嚓咔嚓声。

你看Knowledge Crunching 是一个如此有画面感的词汇,这就意味着当我们获取领域知识的时候,要大声地、引人注意地去获得反馈,哪怕这个反馈是负面的。

而且如果把它叫做“知识吧唧嘴”,我们很容易就能从宏观上理解 Knowledge Crunching 的含义了:吸收知识、接听反馈——正如你吃薯片时在吧唧嘴一样。

好了,言归正传,通过以上的分析,我们其实可以把“知识消化”这五步总结为**“两关联一循环”**

  • “两关联”即:模型与软件实现关联;统一语言与模型关联;
  • “一循环”即:提炼知识的循环。

今天我们先介绍模型与软件实现关联。后面两节课,再关注统一语言与提炼知识的循环。

模型与软件实现关联

我们已经知道,领域驱动设计是一种模型驱动的设计方法。那么很自然地,我们可以得到这样一个结论:

  • 模型的好坏直接影响了软件的实现;
  • 模型的好坏直接影响了统一语言;
  • 模型的好坏直接影响了传递效率与成本。

但Eric Evans在知识消化中并没有强调模型的好坏,反而非常强调模型与软件实现间的关联,这是一种极度违反直觉的说法。

这种反直觉的选择,背后的原因有两个:一是知识消化所倡导的方法,它本质上是一种迭代改进的试错法;第二则是一些历史原因

所谓迭代改进试错法,就是不求一步到位,但求一次比一次好。正如我们刚才总结的,知识消化是“两关联一循环”。通过提炼知识的循环,技术方与业务方在不断地交流与反馈中,逐步完成对模型的淬炼。

无论起点多么低,只要能够持续改进,总有一天会有好结果的。而能够支撑持续改进基础的,则是实现方式与模型方式的一致。所以比起模型的好坏(总是会改好的),关联模型与软件实现就变得更为重要了

历史原因则有两点一是因为在当时领域模型通常被认为是一种分析模型Analysis Model用以定义问题的而无需与实现相关。这样做的坏处呢我们下面再细讲。

二是因为当时处在面向对象技术大规模普及的前夕,由于行业对面向对象技术的应用不够成熟,将模型与实现关联需要付出额外的巨大成本,因而通常会选择一个相对容易、但与模型无关联的实现方式。这个相对容易的方式,往往是过程式的编程风格。

而与模型关联的实现方法也就是被称作“富含知识的模型Knowledge Rich Model是一种面向对象的编程风格。因此我们强调模型与实现关联实际上也就在变相强调面向对象技术在表达领域模型上的优势。接下来我们具体分析。

从贫血模型到富含知识的模型

在DDD出版的年代Hibernate一种Object Relationship Mapping框架可以将对象模型与其存储模型映射从而以对象的角度去操作存储还是个新鲜事物。大量的业务逻辑实际存在于数据访问对象中或者干脆还在存储过程Store Procedure里。

如果把时光倒回到2003年前后程序的“常规”写法和DDD提倡的关联模型与实现的写法在逻辑组织上还是有显而易见的差异的。

我们现在考虑一个简单的例子,比如极客时间的用户订阅专栏。我们很容易在头脑中建立起它的模型:

在ORM流行起来之前的2003年当然那时候没有try-close语法如下的代码并不是不可接受

class UserDAO {
  
  ...
  public User find(long id) {
    try(PreparedStatement query = connection.createStatement(...)) {
      ResultSet result = query.executeQuery(....);
      if (rs.next) 
         return new User(rs.getLong(1), rs.getString(2), ....);
      ....
    } catch(SQLException e) {
       ...
    }
  }
}

class SubscriptionDAO {
  ...
  // 根据用户Id寻找其所订阅的专栏
  public List<Subscription> findSubscriptionsByUserId(long userId) {
     ...
  }
  
  // 根据用户Id计算其所订阅的专栏的总价
  public double calculateTotalSubscriptionFee(long userId) {
     ...
  }
}

这样的实现方式就是被我司首席科学家Martin Fowler称作**“贫血对象模型”**Anemic Model的实现风格**对象仅仅对简单的数据进行封装,而关联关系和业务计算都散落在对象的范围之外。**这种方式实际上是在沿用过程式的风格组织逻辑,而没有发挥面向对象技术的优势。

与之相对的则是“充血模型”,也就是与某个概念相关的主要行为与逻辑,都被封装到了对应的领域对象中。“充血模型”也就是DDD中强调的**“富含知识的模型"**。不过作为经历那个时代的程序员以及Martin Fowler的同事来说“充血模型”是我更习惯的一个叫法。

Eric在DDD中总结了构造“富含知识的模型”的一些关键元素实体Entity与值对象Value Object对照、通过聚合Aggregation关系管理生命周期等等。按照DDD的建议刚才那段代码可以被改写为

class User {

   // 获取用户订阅的所有专栏
   public List<Subscription> getSubscriptions() {
     ...
   }
   
   // 计算所订阅的专栏的总价
   public double getTotalSubscriptionFee() {
     ...
   }
}

class UserRepository {
  ...
  public User findById(long id) {
    ...
  }
}

从这段代码很容易就可以看出User用户是聚合根Aggregation RootSubscription订阅是无法独立于用户存在的而是被聚合到用户对象中。

通过聚合关系表达业务概念

不同于第一段代码中单纯的数据封装改写后这段代码里的User具有更多的逻辑。Subscription的生命周期被User管理无法脱离User的上下文独立存在我们也无法构造一个没有User的Subscription对象。

而在之前的代码示例中我们其实可以很容易地脱离User直接从数据库中查询出Subscription对象通过调用findSubscriptionsByUserId。所有与Subscription相关的计算其实也被封装在User上下文中。

这样做有什么好处呢?首先我们需要明白,在建模中,聚合关系代表了什么含义,然后才能看出“贫血模型”与“富含知识的模型”的差异。我们还是以极客时间的专栏为例。

为了表示用户订阅了某个专栏,我们需要同时使用“用户”与“订阅”两个概念。因为一旦脱离了“订阅”,“用户”只能单纯地表示用户个人的信息;而脱离了“用户”,“订阅”也只能表示专栏信息。那么只有两个放在一起,才能表达我们需要的含义:用户订阅的专栏。

也就是说,在我们的概念里,与业务概念对应的不仅仅是单个对象。**通过关联关系连接的一组对象,也可以表示业务概念,而一部分业务逻辑也只对这样的一组对象起效。**但是在所有的关联关系中,聚合是最重要的一类。它表明了通过聚合关系连接在一起的对象,从概念上讲是一个整体。

以此来看当我们在这个例子里谈到User是Subscription的聚合根时实际上我们想说的是在表达“用户订阅的专栏”时User与Subscription是一个整体。如果将它们拆分则无法表示这个概念了。同样计算订阅专栏的总价也只是适用于这个整体的逻辑而不是Subscription或User独有的逻辑。

总结来说我们无法构造一个没有User的Subscription对象也就是说这种概念在软件实现上的映射比起“贫血模型”的实现方式“富含知识的模型”将我们头脑中的模型与软件实现完全对应在一起了——无论是结构还是行为。

这显然简化了理解代码的难度。只要我们在概念上理解了模型,就会大致理解代码的实现方法与结构。同样,也简化了我们实现业务逻辑的难度。通过模型可以解说的业务逻辑,大致也知道如何使用“富含知识的模型”在代码中实现它。

修改模型就是修改代码

关联模型与软件实现,最终的目的是为了达到这样一种状态:修改模型就是修改代码;修改代码就是修改模型

在知识消化中,提炼知识的重构是围绕模型展开的。如果对于模型的修改,无法直接映射到软件的实现上(比如采用贫血模型),那么凝练知识的重构循环就必须停下来,等待这个同步的过程。

如果不停下来等待模型与软件间的割裂就会将模型本身分裂为更接近业务的分析模型以及更接近实现的设计模型Design Model。这个时候分析模型就会逐渐退化成纯粹的沟通需求的工具而一旦脱离了实现的约束分析模型会变得天马行空不着边际。如下所示分析模型参与需求设计模型关联实现

事实上这套做法在上世纪90年代就被无数案例证明难以成功于是才在21世纪初有了模型驱动架构Model-Driven Architecture、领域驱动设计等一系列使用统一模型的方法。那么在模型割裂的情况下统一语言与提炼知识循环也就不会发生了所以我们才必须将模型与软件实现关联在一起,这也是为什么我们称它是知识消化的基础与前提。

你或许会有疑惑“富含知识的模型”的代码貌似就是我们平常写的代码啊是的随着不同模式的ORM在21世纪初期相继成熟以及面向对象技术大规模普及将领域模型与软件实现关联在技术上已经没有多大难度了。虽然寻找恰当的聚合边界仍然是充满挑战的一件事,但总体而言,我们对这样的实现方式并不陌生了。

由此我们可以更好地理解DDD并不是一种编码技术或是一种特定的编码风格。有很多人曾这样问我怎么才能写得DDD一点

我一般都会告诉他,只要模型与软件实现关联了,就够了

毕竟“DDD的编码”的主要目的是不影响反馈的效率保证凝练知识的重构循环可以高效地进行。如果不配合统一语言与提炼知识循环那么它就只是诸多编码风格之一难言好坏。

而如果想“更加DDD”的话则应该更加关注统一语言与提炼知识循环特别是提炼知识循环。事实上它才是DDD的核心过程也是DDD真正发挥作用的地方。

小结

领域驱动设计是一种领域模型驱动的设计方法,它强调了在业务系统中应该使用与问题领域相关的模型,而不是用通用的数据结构去描述问题。这一点已被行业广泛采纳。

Eric Evans提倡的知识消化总结起来是“两关联一循环”模型与软件实现关联统一语言与模型关联提炼知识的循环。

知识消化是一种迭代改进试错法,它并不追求模型的好坏,而是通过迭代反馈的方式逐渐提高模型的有效性。这个过程的前提是将模型与软件实现关联在一起。

这种做法在21世纪初颇有难度不过随着工具与框架的成熟也成为了行业熟知的一种做法。于是通过迭代反馈凝练知识就变成了实施DDD的重点。

不过,在进入这部分之前,我们还要看看如何将统一语言与模型关联起来,这个我们下节课再深入讨论。

编辑小提示:为了方便读者间的交流学习,我们建立了微信读者群。想要加入的同学,戳此加入“如何落地业务建模”交流群>>>

思考题

既然领域驱动设计是一种模型驱动的设计方法,为什么不能让业务方直接去使用模型,而要通过统一语言?这是不是有点多余?

欢迎把你的想法和思考分享在留言区,和我一起交流。相信经过你的深度思考,知识会掌握得更牢固。