in DDD 领域驱动设计 ~ read.

领域驱动设计

什么是领域驱动设计

传统的软件设计中,一般存在以下问题:

  1. 业务人员只懂业务,不懂架构和设计;
  2. 架构人员专注于设计看似美好的架构;
  3. 开发人员想当然的去实现业务逻辑;

在整个软件生命周期中的每一个环节和每一个参与的角色都是割裂的,互相拥有自身对系统的理解,如同盲人摸象般,这种割裂会带来很大的沟通成本。由于任何沟通都是一种信息的转换,而任何转换带来的都是信息的丢失,因此系统最终的实现跟原始的需求可能会差之甚远,甚至导致系统的失败。为了解决这个问题Eric Evans提出了Domain-Driven Design领域驱动设计,简称DDD。

领域驱动设计是一套综合软件系统分析和设计的面向对象建模方法。领域驱动设计的核心是领域模型,所谓领域模型,是关于某个特定业务领域的软件模型,一般通过对象模型来实现,这些对象同时包含了数据和行为,并且准确的表达了业务含义。

领域驱动设计思想的关键之处在于将人们在建模时从关注于将需求转换为数据改变为专注于业务,通过使业务人员、架构师和开发人员一起协作,让团队中的每一个人都关注于业务的抽象,从而将业务问题简化,做出更符合业务场景的战略设计和战术设计。

领域驱动建模、数据库建模和Smart UI的区别

传统的软件开发中一般使用数据库驱动建模,在软件开发的早期,从业务需求的抽象中建立符合数据库(一般是关系型数据库)的数据的组织格式,在代码中建立Java Bean,包含一些getter、setter方法,通过调用这些getter、setter方法来实现业务逻辑。甚至一些系统为了利用数据库的事务来保证业务的原子性,将业务逻辑通过数据库的存储过程来实现。

Smart UI是将大量的业务逻辑直接放到UI层,业务逻辑层只剩下简单的CURD的操作。与数据库建模类似,Smart UI适用于业务简单的小项目。

在领域驱动建模中,所有的参与人员会一起来理解业务需求,建立起一套围绕业务需求的通用语言,划定系统的边界,建立系统的模型,通过反复的迭代来将真正的业务规则显化出来从而提炼模型。通过代码来表现模型,通过将模型与实现绑定来消除架构设计和开发实现的鸿沟。

从以下四个维度比较两种建模方法:

Smart UI 数据库建模 领域驱动建模

软件生命周期

瀑布式

瀑布式

迭代式

描述业务逻辑的方式

文档

文档

实现

业务逻辑的位置

UI层

散布在代码中或存储过程

领域模型

修改与集成的难度

一般

由此可见,数据库建模和Smart UI适用于一两个人开发的业务简单的项目,而领域驱动建模更适合于业务复杂的大型项目的管理。

领域驱动设计的架构与集成

下图是领域驱动设计架构与集成的总览图,主要包含三大部分:领域驱动设计的模式——上下文、精炼、大比例结构。另外核心是通用语言和模型驱动设计。

DDD战略设计

通用语言

通用语言是领域驱动设计的核心之一。通用语言指在通过与领域专家的交谈,设计开发人员从中挖掘出一些领域的关键概念,并构建出可用于将来讨论用的词汇:名词和动词,从而形成针对该业务场景的通用语言。该通用语言可以帮助业务人员了解系统的实现并确认是否符合业务需求,还可以帮助设计开发人员理解实际的业务需求。该语言解决了过往软件工程中不同角色之间信息转换过程中信息的丢失,使得团队中的每一个成员都可以快速、高效、准确地理解业务需求。

语言中的名词即为模型中类的名称,动词即为模型中类的主要操作。通过这种映射将语言和模型进行绑定,当语言发生变化的时候模型必须要随之变化,反之当模型发生变化的时候语言也要随之变化。

有时候需要将模型使用文档描述出来,此时一定要使用通用语言来描述,同时尽可能多的使用图来表达模型。文档也要跟模型、语言进行绑定,同步更新,否则文档就失去其自身的意义。

通用语言与UML的关系

UML适合表现类、类的属性和类之间的关系,但不适合表现类的行为和约束。鉴于此,可以通过UML来表现模型,并在UML中使用注释来表达类的行为和约束。

当系统变得越来越复杂时,UML会变得很大,此时UML的图也变得难以理解。在这种情况下可以通过使用多个小图来表达模型的一个子集的方式来解决,当把多个小图合并的时候即为完整的业务模型。

总之,软件开发应当是一个不断简化模型、设计和代码的统一的迭代过程

模型驱动设计

下图是从《领域驱动设计》摘出来的关于领域驱动设计中的构成块(下半部)和柔性设计的模式(上半部)。

DDD构造块

下面我们挨个说下每个的含义和用途。

Layered architecture - 分层架构

分层架构是软件架构中最常见的一种架构方式,它通过将系统划分为多个层次来将不同用途的逻辑封装在不同的地方。比如一个常见的web系统的架构方式是:

  1. 最上层是UI层,负责UI的渲染、展示和操作的响应;
  2. 其次是业务层,负责对UI层的响应进行相对应的操作;
  3. 其次是领域层,负责所有的业务逻辑和领域模型的实现;
  4. 其次是基础设施层,负责向上提供基础功能,如数据库、缓存、RPC等功能;

严格意义上每一层都只是访问自己相邻的下层,下层不知道自己的上层是谁、如何使用自己的。但在实际应用中可能会出现一个操作对应的没有领域层的处理,对于该情况为简化代码可以不必严格遵守上层只能访问相邻的下层的限制,只需遵守上层访问下层即可。

有时候下层需要在执行完毕后通知上层进行某些特殊的操作,此时可以通过在上层调用下层时传入一个回调函数来实现。

基础设施层是与硬件、底层架构等相绑定的,因此为了更好将对业务屏蔽底层的复杂性,一般会将基础设施层抽离出来,删除领域层对基础设施层的依赖。此时可以通过使用ServiceProvider的方式将领域层与基础设施层解耦,在运行期通过依赖反转的方式对领域层注入所需的基础设施层的服务。

分层架构是一种非常有用的架构,从计算机硬件的体系结构到一个系统,再到一个系统中的一个模块,很多地方都可以看到分层架构的身影,它可以帮助我们很好的提高软件的内聚性,降低对外部的耦合,从而使得我们的系统更加的健壮。

Entity - 实体

实体具有自己的唯一标识符,并且通过唯一标识符来进行判断一个实体的多个实例是否相同,如下所示:

public class PersonEntity {  
  private long id;
  private String name;

  public long getId() {
    return id;
  }

  public void setName(String name) {
    this.name = name;
  }

  public String getName() {
    return name;
  }

  @Override
  public boolean equals(Object other) {
    if (other instanceof PersonEntity) {
      if (((Person) other).getId() == this.getId()) {
        return true;
      }
    }
    return false;
  }

  @Override
  public int hashCode() {
    int result = (int) (id ^ (id >>> 32));
    return result;
  }
}

Value object - 值对象

不是所有的对象在领域驱动设计里都是实体,如果对于一个对象我们只关心对象的属性的时候应当考虑使用值对象。值对象应当是

  1. 不变的
  2. 简单的
  3. 不应当包含对实体对象的引用,因为实体是可变的
  4. 其属性应当是一个概念上的整体,比如Address的值对象中不应当包含非地址相关的属性
  5. 其方法不应当将实体作为入参或出参,因为实体是可变的

如下:

public class Address {  
  private final long code;
  private final String name;

  public Address(long code, String name) {
    setCode(code);
    setName(name);
  }

  protected void setCode(long code) {
    this.code = code;
  }

  public long getCode() {
    return code;
  }

  protected void setName(String name) {
    this.name = name;
  }

  public String getName() {
    return name;
  }  

  @Override
  public boolean equals(Object o) {
      if (this == o) return true;
      if (o == null || getClass() != o.getClass()) return false;
      Address add = (Address) o;
      if (name != null ? !name.equals(add.name) : add.name != null) return false;
      return !(code != null ? !code.equals(add.code) : add.code != null);
  }

  @Override
  public int hashCode() {
      int result = (int) (code ^ (code >>> 32));
      result = 31 * result + (name != null ? name.hashCode() : 0);
      return result;
  }
}

Service - 服务

当执行一个显著的业务操作过程来对领域对象进行转换的时候,或者使用多个领域对象作为入参进行计算并且返回一个值对象的时候使用服务。

服务是包含业务规则的,否则该服务不是领域层的服务。领域层的服务应当与业务层的服务和基础设施层隔离开来,这是为了保证业务逻辑不会散落到其他地方,从而保证领域层的内聚性。

服务是与领域相关的操作,一般是无状态的,通过将服务于实体和值对象的相关功能进行分组来屏蔽细粒度的操作行为。

在服务的设计的时候不应当让客户端根据询问服务的结果来决定调用服务对象的方法,而应当通过调用服务对象的公共接口的方式来告诉服务对象所要执行的操作。

Event - 事件

事件是一种特殊的服务,一般在分布式处理中经常会用到事件。有很多组件都提供了事件的服务,如RabbitMQ、ActiveMQ、Kafka等。如果需要对事件进行存储或者存在分布式事件,建议选择Kafka。

如何解决消息的丢失:

  1. 重试,当没有发送成功时重新发送消息,一般为了防止雪崩效应要限制重试次数;
  2. 基于业务解决,如果业务需求有定义消息丢失时如何处理则按业务需求实现;
  3. 快速失败,当发送失败时第一时间告诉客户端,由客户端来决定下一步的操作;

当重新发送消息时可能会存在重复消息,如何解决重复消息:

  1. 实现事件处理的幂等;
  2. 对消息状态进行跟踪,可使用状态机实现;

事件存储的注意事项:

  1. 通过冗余存储来提供事件存储的可靠性,多个备份等;
  2. 通过replay的方式来实现聚合实例的查询;
  3. 通过快照的方式来提高聚合实例查询的效率;
  4. 提供冗余信息以满足80%的消费方,避免信息的再次查询;

Aggregate - 聚合

聚合是用来定义对象所有权和边界的领域模式,它一般是针对一组相关的对象,使用边界将内部和外部的对象隔离开来。

聚合的根是一个实体类,它是聚合对外的接口,外部只能通过它来访问聚合。除了实体之外的其他内部对象对只在聚合内有意义,外部不能持有它们的引用。在实际应用中如果需要将聚合的内部对象传递给外部时,一般通过一个值对象将数据传递出去,从而保证该操作对聚合没有副作用。

聚合的设计应当符合迪米特法则:任何对象的任何方法只能调用以下对象中的方法——该对象自身、所传入的参数对象、它所创建的对象、自身所包含的其他对象。

有时候聚合也会持有别的聚合的引用,但要尽可能的减少这种对象之间的关联,方式如下:

  1. 删除模型中非本质的关联关系;
  2. 通过增加约束来削减多重性;
  3. 尽可能的消除双向关联;
  4. 通过持有其他聚合的唯一标识来消除引用;

原则上在一个事务内只能修改一个聚合实例,因此为了保证更新的正确性和效率,在聚合建模时要极可能的设计小的聚合。当需要更新多个聚合时,原子性在很多场景下并不是必要的,因此可以通过使用最终一致性的方式来保证事务的正常执行和数据的一致性,如通过事件进行异步操作。

当并发较大时,可以简单的通过给数据添加一个版本号来实现类似CAS的锁,从而避免锁住大量数据的重量级锁。

Factory - 工厂

工厂是用来封装对象的创建或重建时的知识和过程,特别是复杂的对象,如聚合等。工厂可以将创建过程原子化。

对象一般都具有一些约束条件,一般将针对字段的规则放到被调用对象的构造方法或属性setter方法里面,将整体的规则放到工厂内部。

工厂方法的好处:

  1. 有效的表达限界上下文中的通用语言;
  2. 减轻客户端在创建新势力时的负担,屏蔽创建复杂性;
  3. 确保所创建的实例处于正确的状态;

什么时候使用构造方法来创建对象:

  1. 当构造过程不复杂的时候使用构造方法;
  2. 当对象的创建不涉及其他对象的创建时使用构造方法;
  3. 当客户端需要决定对象的创建方式时使用构造方法,如策略模式等;
  4. 类是特定的类型,不涉及到继承,不需要再具体的实现中选择特定的实现方式时使用构造方法;

以下是一个简单的例子:

public class MyObject {  
    private long id;
    private String name;
    private String desc;

    protected MyObject(long id, String name, String desc) {
        this.setId(id);
        this.setName(name);
        this.setDesc(desc);
    }

    protected void setId(long id) {
        this.id = id;
    }

    public long getId() {
        return id;
    }

    protected void setName(String name) {
        if (name == null || "".equals(name)) {
            throw new InvalidArgumentException("Name shouldn't be missing");
        }
        this.name = name;
    }

    public String getName() {
        return name;
    }

    protected void setDesc(String desc) {
        this.desc = desc;
    }

    public String getDesc() {
        return desc;
    }
}

public class MyObjectFactory {  
    public MyObject ofMyObject(String name, String desc) {
        return new MyObject(generateId(), name, desc);
    }
}

工厂方法的具体实现方式有工厂模式、抽象工厂模式等,详细请参考设计模式。

Module - 模块

模块是用来组织相关概念和任务以降低系统的复杂性,提高内聚性,消除耦合。模块不是在架构确定之后就不再改变的,而是应当随着项目的进展而不断演化的。

在Java中可以简单的使用package来实现,对于复杂的场景也可以使用OSGi来实现,在即将到来的Java 9中提供的新特性Jagsaw也提供了模块的实现方式。

一般的内聚方式有两种:

  1. 通信性内聚,如将Service的类都放到service包里,将Entity的类都放到entity包里;
  2. 功能性内聚,如将登陆相关的类都放到login包里;

前者是横向切分,符合分层架构的方式;后者是纵向切分,符合模块的内聚方式;因此一般建议使用后者。

Repository - 资源库

资源库是用来封装所有获取对象引用所需的逻辑,它可以帮助我们将模型和数据库进行解耦,一般是面向持久化的,可以同时支持关系型数据库和非关系型数据库,对上屏蔽数据库类别的具体选择。

资源库与工厂的区别是——工厂关注的是新创建的对象,负责管理对象生命周期的开始;资源库关注的是已有对象,负责管理对象生命周期的中间和结束。

在使用资源库时如果需要事务时,由于事务是业务逻辑的一部分,因此一般是资源库提供事务的实现,但由客户端来控制事务。

一般是基于Repository、Specification、Criteria来实现的,Spring JPA中提供了资源库的一个良好的实现。

柔性设计

柔性设计指设计易于使用和修改。柔性设计可以很大的缩短软件开发中一次迭代的周期,从而大幅提高开发效率。以下是柔性设计中的一些模式。

Intention revealing interface - 自描述命名接口

类、方法、参数等的命名要能描述意图,方式要被隐藏在内部实现中。好处如下:

  1. 代码就变成了自描述的,其他人可以像阅读文章一样通过快速的阅读代码而理解作者的意图;
  2. 难以与代码保持同步的文档即可被抛弃;
  3. 代码中不会到处充斥着描述代码逻辑的注释,只在关键的算法部分存在注释;

Side-effect-free function - 无副作用的函数

将无副作用的函数运用到极致的是函数式编程,在函数式编程的世界中一切都是不可变的,所有的操作都是一个函数,所有的修改都会产生一个新的对象,这样技术上来说可以很好的应对并发操作,业务上来说可以很好的解决状态管理的问题,提高代码的内聚性。

基于JVM的提供函数式编程的语言有Scala、Closure、Java 8等。

虽然Java提供了函数式编程的API,但Java归根结底不是一个函数式编程的语言,为了在Java中更好的实现无副作用的函数,可以使用命令查询分离的方式。

CQRS(Command Query Responsibility Segregation) - 命令查询职责分离模式

在传统系统中的修改、查询一般是下图所示:

CQRS以往方式

在实际实践中,当读多写少、多种读用途时一般会通过读写分离的方式解决并发的问题,从应用层的读写分离到数据库层面的读写分离。另外当多种读用途的时候可以通过提供不同的数据库模型来提供不同的视图给不同的业务场景使用,同时避免大量读请求给数据库带来的压力导致写的失败。读写分离可以针对性的对应用进行优化,降低业务复杂度,隔离开读逻辑和写逻辑使它们不会互相影响。

数据库层面的读写分离一般是通过数据库的冗余复制来实现,数据库的复制有以下两种方式:

  1. 数据库的同步复制,如下图所示:

CQRS数据库同步复制

  1. 数据库的异步复制,如下图所示:

CQRS数据库异步复制

同步复制在实现上一般通过分布式事务来实现,分布式事务的实现有2PC,3PC,TCC等实现方式。

异步复制在实现上来说一般是通过以下两种方式实现:

  1. 通过数据库的修改log的同步来实现,比如MySQL的BinLog中包含了每一次的数据库修改信息,可以通过一个监听器监听BinLog并将每一次的修改发送到从数据库来实现数据库修改的同步;
  2. 通过在应用层将每一次的修改操作通过异步消息发送到从数据库来实现数据库修改的同步。从应用层面的异步复制一般将修改写到一个基于event的数据库存储或消息队列(如event store,Kafka等)里面,由于这种event每次的写入只是append,不会有修改的操作,因此锁的粒度很小,从而很大的提高性能,如下图所示:

CQRS event store

一般情况下,如果用户的参与度高,对延迟敏感的情况下使用同步复制;如果用户的参与度低,对延迟不敏感的情况下使用异步复制;如果有大量的修改操作的时候可以使用事件来处理。

与数据库层面的CQRS类似,应用层的CQRS对于领域建模和事务处理也有很大的帮助。方法分为两种:命令和查询。其中命令用于对数据的修改,查询用于对数据的查询。两种类型的方法不应该混淆在一起,这同样也是一种单一职责的设计思想。一般来说:

  1. 命令方法没有返回,所有对数据的修改都应当使用命令式的方法;
  2. 查询方法没有对状态的修改或副作用;

Assertion - 断言

断言是用来描述操作的后置条件和类以及聚合的固定规则。它描述地是状态,而非过程。在Java中一般通过单元测试用例来实现。

单元测试用例应当是一种辅助的文档,用来让用户更好的理解类和方法的用法,从而消除难以与代码同步的文档。

Conceptual contour - 概念轮廓

使模型和领域匹配、同步,保证团队的每一个成员都对领域概念和模型概念有一个清晰的认知,清楚的理解领域边界和领域内部的内容。

Standalone class - 独立类

独立类是通过控制类之间的依赖关系从而降低类之间的耦合,它可以帮助开发者很好的写出低耦合的代码,从而帮助团队成员减少概念的过载。

类之间的依赖关系应当只表示对象的基本概念,反映领域模型内部的业务关系,不应当因为代码的实现而人为的增加类之间的依赖。当需要人为的增加类之间的依赖的时候要考虑是否需要重构领域模型来满足新的业务需要。

Closure of operation 闭合操作

闭合操作指定义操作的时候尽可能的让返回类型与其参数的类型相同,这样就不会产生新的依赖和副作用,一般用于对值对象的操作。

闭合操作是来自于函数式编程的概念,通过封装来对外提供不变性,去除方法或函数的副作用。如:

public int add(int a, int b) {  
    return a + b;
}

声明式设计

声明式设计是指通过一些声明式的语言方式写代码,这样可以提高可读性,将领域模型和实现紧紧的绑定在一起。一般有以下三种实现方式:

  1. 使用轻量级框架,通过使用一个轻量级的框架来自动处理某些单调易出错的方面,如持久化、getter/setter方法、O/R Mapping等;
  2. DSL(Domain Specific language) - 领域特定语言,通过对业务自定义一门语言来使得业务人员来自定义业务需求,缺点是难以重构;
  3. 轻量级DSL,通过在已有语言上定义一些谓词方法来实现Specification,如or、and、not等谓词;

领域驱动设计的模式

在领域驱动设计中要始终保持模型的完整一致性,尽可能的切分大模型,在各个小模型之间划分清晰的边界并精确定义它们之间的关系,并遵守模型间相绑定的契约。为了实现这个目的,下面来依次介绍下领域驱动设计的一些模式。模式之间的关系见本文第一幅图。

Bounded Context - 界定上下文

界定的上下文是领域驱动设计思想的核心之一。界定上下文是指为每个模型定义一个界限清晰的明确的上下文,该上下文在定义的时候要根据团队的组织、软件系统的各个部分的用法以及物理表现(如代码和数据库模式等)来设置模型的边界,并且在这些边界中要严格保持模型的一致性,不可收到边界之外的任何问题的干扰和混淆。界定上下文不是模块的上下文,虽然有时候可能一个模块拥有自己的一个界定上下文,但是在大多数的时候一个界定上下文包含多个模块。

另外一个模型在定义的时候最好要小到一个团队可以实现的程度,这样可以避免大量额外的交流的工作,也可以避免信息在交流的过程中出现的损耗。在定义不同模型之间的边界和关系的时候,要小心不能再模型之间传递任何对象,也不能再没有边界的情况下自由地激活行为。关于不同模型之间信息的交流可以使用公开发布的语言来进行集成。

Context Map - 上下文映射

一个系统中可能存在多个界定上下文,每个界定上下文都应当有自己的通用语言来描述自己的领域业务,在不同的界定上下文之间要抽象出它们的关系,这种关系就是上下文映射。团队中每个人都应当知道自己所工作在的上下文的界限以及在上下文和代码之间的映射等,通过大家都使用同样的通用语言即可保证之。

在定义上下文后,要为每个上下文创建模型,然后用一个约定的名称指明每个模型所属的上下文。

创建上下文有很多模式:

  1. 共享内核模式、客户/供应商模式是具有高级交互的模式;
  2. 独立方式模式是让上下文高度独立和分开运行的模式;
  3. 开放服务模式和防护层模式是处理系统和集成系统或者外部系统之间交互的模式;

Continuous Integration - 持续集成

可以通过Jenkins来实现持续集成,一般包括自动化单元测试、自动化集成测试、每日构建等。

Shared Kernel - 共享内核

有时候两个上下文之间为了减少重复而共用一部分模型,使用本模式的时候要小心,因为此时两个系统之间的耦合非常重。

对于这部分公共的部分,必须要有足够的测试和沟通来保证任何修改都不会影响到彼此。

Customer/Supplier Teams - 客户/供应商

当两个上下文之间存在单向依赖或者有依赖的异构系统从而无法使用共享内核时,可以使用本模式。

本模式是将依赖者作为客户向被依赖者作为供应商提需求,供应商根据需求制定工作计划并实现业务需求。另外还通过对接口进行自动化测试来保证供应商的修改不会影响到客户的代码。

这要求两者之间必须要有良好的合作关系,否则只能使用顺从者模式。

Conformist - 顺从者

适用于客户团队得不到供应商团队足够的支持时。如果实际上两者之间的依赖是简单的,较弱的依赖的话,可以直接使用独立方式模式来去除依赖,将依赖系统独立。如果依赖系统必须使用被依赖系统则可以自行实现防护层模式,通过其中的保护方式和转换器来处理两个系统之间的交互。如果依赖系统必须使用被依赖系统的模型,则只能使用本模式。

一般来说可以通过在被依赖系统模型的基础上直接构建依赖系统的模型,或者通过适配器来对两个模型进行转换来提供依赖系统模型的自由度(顺从者模式和防护层模式的综合)。

Anticorruption Layer - 防护层

用于和遗留系统或外部系统交互的场景。

防护层通过Facade模式或Adapter模式和转换器Translator来实现在不同的模型和协议之间转换概念对象和操作的机制,防护层提供了两个系统或上下文之间的隔离,对象和数据的转换,保证了:

  1. 当被依赖的系统或服务不可用时依赖系统依旧可以正常运行。一般通过在依赖系统中提供Fallback方法来实现;
  2. 当被依赖的系统或服务发生改变时依赖系统依旧可以在一定程度上正常运行或者自动报警。一般通过监控系统或良好的转换器来实现;

Separate Ways - 独立方式

适合多个子系统之间没有关系或者只有很少关系的场景。

虽然多个子系统之间不需要进行集成,但当业务需要时可以通过提供门户UI来实现一定意义上的集成。如果业务需要更深层次的集成,可以考虑转变为其他模式来进行集成。

Open Host Service - 开放服务

适用于当多个子系统都需要集成某一个系统的场景。

被依赖者通过提供公共服务的方式来让其他子系统进行集成。不同的子系统中一般需要实现自己的防护层来处理数据的转换。

Published Language - 公开发布的语言

在开放服务的时候,为了在异构系统中解耦互相之间的依赖,可以通过一些公开发布的语言来进行通信,如XML, JSON, ProtoBuf、Thrift、Avro等。

关于系统集成的相关模式的对比

下图是当多系统之间进行集成时可能用到的相关模式的对比:

DDD构造块

一般在系统集成的时候先考虑独立方式,然后是顺从者或者防护层。

如果需要更深度的集成甚至需要合并不同的上下文的时候要先建立持续集成,然后先考虑独立方式,然后是共享内核,最后是开放服务和公开发布的语言。

精炼

当一个模型以及其上下文划定完毕后并不意味着模型及其上下文就永远不变了。随着业务的发展和对领域更深层次的理解我们需要对模型及其上下文进行演化,这种演化的方式称之为精炼。精炼有很多既有的成熟的模式:

Core Domain - 核心领域

当一个模型随着业务的发展演化的越来越大的时候,我们需要对模型进行拆分,从而让我们更专注于解决真正的业务问题。核心领域就是我们通过分解大的模型而得到的一个代表领域本质的核心领域,在拆分之后除了核心领域之外的其他部分就是若干个通用子领域。一个系统的核心领域在这个系统中是核心领域,但是它在另一个系统中可能就是普通子域,比如用户领域在登录的服务系统中是核心领域,但是在订单系统中就是提供认证功能的通用子领域。

核心领域是一个系统中最重要的部分。在系统开发过程中,最优秀的团队成员不应当仅仅是在做基础设施层,而应当将最核心的力量放在核心业务上,这样才能保证核心领域的实现符合客户的要求。

Generic Subdomains - 通用子领域

如核心领域中所属,当对大模型进行拆分之后除了核心领域之外的部分就是若干个通用子领域,一般每个通用子领域建议放到一个单独的模块中。

通用子领域的实现方式有如下四种:

  1. 购买线程的解决方案,缺点是存在学习曲线、集成等问题;
  2. 外包,缺点是集成等问题,需要与外包团队保持有效持续的沟通来保证该领域的定义是符合业务需求的;
  3. 已有模型,缺点是已有模型一般是高度定制化的,可能存在所需功能不完善等情况;
  4. 自己实现,缺点是需要一定的工作量;

Domain Vision Statement - 领域前景说明

领域前景说明是用来传达领域的基本概念和价值的。在一个系统的领域建模完成之后团队应当对系统的中长期规划有一个清晰的认知。在系统架构或领域建模中,从一个清晰的认知中可以很容易的找到最重要的部分,并安排好计划来实现它;但是在没有一个清晰的中长期规划时团队可能很快的就偏离业务需求,走向失败的深渊。

Highlighted Core - 突出核心

在模型拆分的时候一般会存在系统耦合性太重,很难找到真正的核心领域的问题,突出核心就是为了解决这个问题的,它可以帮助团队改善沟通并指导决策的制定过程。突出核心的步骤如下:

  1. 通过编写文档来描述领域模型以及模型元素之间的主要交互过程;
  2. 把模型的核心领域标记出来,但无需阐明其角色,使开发人员清楚地知道什么领域的边界;
  3. 保证文档与代码的同步;
  4. 保证文档对团队成员的可见性;

通过以上方式可以很好的通过分析将核心领域突出出来。

Cohesive Mechanism - 内聚机制

高内聚、低耦合是软件开发中最重要的标准之一。内聚机制可以保证设计的通用性、易懂性和柔性。

模型是用来描述事实、规则和问题的,内聚机制是用来满足规则或者用来完成模型指定的计算,内聚机制也是核心领域的一部分。

Segregated Core - 隔离核心

隔离核心是为了让核心更可见,通过隔离核心去除不必要的干扰和额外的修改对核心领域的影响。

Abstract Core - 抽象核心

抽象核心是对领域的一种横向的切分,类似于软件开发中的抽象类和接口,抽象核心通过抽象的方式表示了最基本的概念和关系,具体的领域核心通过在其基础上自定义自己的业务规则来快速实现。这一般是领域模型发展到一定程度后通过对模型进行全面的重新组织和重构来实现的。

举一个经典的场景:

在一个风控系统中针对不同的风险模式会有不同的风险控制手段,在领域模型创建的早期会针对不同的风险模式构建不同的领域模型。随着风险领域模型的增多,可以通过抽象内核的方式来得到一个通用的风控领域模型,然后针对不同的风险模式在该通用的分控模型的基础上自定义自己的风险规则来快速实现特定的风险领域模型。

大比例结构

大比例结构是应用于整个系统的规则模式,是一种全局性的规则。

Evolving Order - 进化顺序

系统应当随着应用程序一起演变。这个世界唯一的不变性就是一直在变化,系统开发也是如此,没有什么设计、架构、实现是一层不变的,都应当随着业务的发展而不断演变。

System Metaphor - 系统隐喻

这是一种对领域的类比,是为了更好的解释系统,对对象范式是协调的。

Responsibility Layer - 职责层

职责层是为了划分模型概念和它们的依赖关系,类似于多层架构的思想。高层可以使用低层提供的服务,但是低层对上层是独立的,依赖关系也是从上层到下层。这样可以帮助我们很好的理解系统的依赖关系,使对依赖关系的跟踪变得容易。

Knowledge Level - 知识级别

知识级别是一组描述了另一组对象应当有哪些行为的对象。

Pluggable Component Framework - 可插拔组件框架

类似于抽象核心,使得我们可以通过不同的实现来实现组件的替换,不适合项目开始的时候就实行,适合在项目进展到一定程度,对领域模型的理解深入到一定程度之后实行。

重构模型

大多数时候的重构都是针对代码的实现的,但是重构同样对领域模型也有很好的帮助,它可以帮助我们凸显关键概念,将隐式的概念转变为显式的概念。重构是一个持续不断的过程,长期重构的积累会在某一个时间点给领域模型的设计带来质的突破。

模型的重构一般关注以下三点:

  1. 约束:将不变量的逻辑、对象的限制规则提取出来,显式出来;
  2. 过程:将单个过程提取出来单独的服务,将多个过程提取出来使用策略模式;
  3. 规约:将测试一个对象是否满足特定条件的判断提取出来使用Specification实现,如数据库查询的条件等;

重构依赖于好的测试代码,一个号的测试代码应当是给用户的一种说明,帮助用户更好的理解代码的运转方式,Given-When-Expect是一种很好的测试用例的形式,如下面的伪代码:

@Test
public void given_something_when_doing_something_then_expected_result() {  
    given_something();// mock

    result = doing();

    assert(expected_value, result);
}

测试只需要针对public的方法,对于protected和default可酌情考虑是否需要测试,对于private的方法不需要测试,如果需要对private的方法进行测试的可以说明可以考虑代码的重构了。

领域建模的实践

  1. 模型需要与实现绑定;
  2. 专注于具体的业务场景,抽象思维需要落地于具体的案例;
  3. 领域驱动设计有其自身的局限性,不要试图对任何事情都进行领域驱动设计;
  4. 模型是一个创造性的流程;
分享按钮