领域驱动设计主要通过限界上下文应对复杂度,它是绑定业务架构、应用架构和数据架构的关键架构单元。设计由领域而非数据驱动,且为了保证定义了领域模型的应用架构和定义了数据模型的数据架构的变化方向相同,就应该在领域建模阶段率先定义领域模型,再根据领域模型定义数据模型。这就是领域驱动设计与数据驱动设计的根本区别。

一、对象关系映射

如果领域建模采用对象建模范式,存储数据则使用关系数据库,那么领域模型就是面向对象的,数据模型则是面向关系表的。在领域驱动设计中,领域模型一方面充分地表达了系统的领域逻辑,同时还映射了数据模型,作为持久化对象完成数据的读写。要持久化领域模型对象,需要为对象与关系建立映射,即所谓的“对象关系映射”(object relationship mapping,ORM)。当然,这主要针对关系数据库。

对象与关系往往存在“阻抗不匹配”的问题,主要体现为以下3个方面。

  • 类型的阻抗不匹配:例如不同关系数据库对浮点数的不同表示方法,字符串类型在数据库的最大长度约束等,又例如Java等语言的枚举类型本质上仍然属于基本类型,关系数据库中却没有对应的类型来匹配。
  • 样式的阻抗不匹配:领域模型与数据模型不具备一一对应的关系。领域模型是一个具有嵌套层次的对象图结构,数据模型在关系数据库中却是扁平的关系结构,要让数据库能够表示领域模型,就只能通过关系来变通地映射实现。
  • 对象模式的阻抗不匹配:面向对象的封装、继承和多态无法在关系数据库得到直观体现。通过封装可以定义一个高内聚的类来表达一个细粒度的基本概念,但数据表往往不这么设计。数据表只有组合关系,无法表达对象之间的继承关系。既然无法实现继承关系,就无法满足Liskov替换原则,自然也就无法满足多态。

二、解决方式

(一)使用JPA

1.枚举类型

关系数据库的基本类型没有枚举类型。如果领域模型的字段定义为枚举,通常会在数据库中将相应的列定义为smallint类型,然后通过@Enumerated表示枚举的含义。

public enum EmployeeType {
   Hourly, Salaried, Commission
}
public class Employee {
   @Enumerated
   @Column(columnDefinition = "smallint")
   private EmployeeType employeeType;
}

smallint虽然能够体现值的有序性,但在管理和运维数据库时,查询得到的枚举值却是没有任何业务含义的数字,制造了理解障碍。为此,可将列定义为VARCHAR,而在领域模型中定义枚举

public enum Gender {
   Male, Female
}
public class Employee {
   @Enumerated(EnumType.STRING)
   private Gender gender;
}

注解@Enumerated(EnumType.STRING)可将枚举类型转换为字符串。注意,数据库的字符串应与枚举类型的字符串值以及大小写保持一致。

2.日期类型

处理针对Java的日期和时间类型进行映射要相对复杂一些,因为Java定义了多种日期和时间类型

  • 用以表达数据库日期类型的java.sql.Date类和表达数据库时间类型的java.sql.Timestamp类;
  • Java库用以表达日期、时间和时间戳类型的java.util.Date类或java.util.Calendar类;
  • Java 8引入的新日期类型java.time.LocalDate类与新时间类型java.time.LocalDateTime类。
  • 数据库本身支持java.sql.Date或java.sql.Timestamp类型,若领域模型对象的日期或时间字段属于这一类型,则无须任何配置即可使用,和使用其他基础类型一般自然。

通过columnDefinition属性值,甚至还可以为其设置默认值,例如设置为当期日期:

@Column(name = "START_DATE", columnDefinition = "DATE DEFAULT CURRENT_DATE")
private java.sql.Date startDate;

如果字段定义为java.util.Date或java.util.Calendar类型,可通过@Temporal注解将其映射为日期、时间或时间戳,例如:

@Temporal(TemporalType.DATE)
private java.util.Calendar birthday;
@Temporal(TemporalType.TIME)
private java.util.Date birthday;
@Temporal(TemporalType.TIMESTAMP)
private java.util.Date birthday;

如果字段定义为Java 8新引入的LocalDate或LocalDateTime类型,情况稍显复杂,取决于JPA的版本。

import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
import java.sql.Date;
import java.time.LocalDate;
@Converter(autoApply = true)
public class LocalDateAttributeConverter implements AttributeConverter<LocalDate, Date> {
   @Override
   public Date convertToDatabaseColumn(LocalDate locDate) {
      return locDate == null ? null : Date.valueOf(locDate);
   }
   @Override
   public LocalDate convertToEntityAttribute(Date sqlDate) {
      return sqlDate == null ? null : sqlDate.toLocalDate();
   }
}

3.主键类型

关系数据库表的主键列至为关键,通过它可以标注每一行记录的唯一性。

主键还是建立表关联的关键列,通过主键与外键的关系可以间接支持领域模型对象之间的导航,同时也保证了关系数据库的完整性。无论是单一主键还是联合主键,主键作为身份标识(identity),只要能够确保它在同一张表中的唯一性,原则上都可以被定义为各种类型,如BigInt、VARCHAR等。在数据表定义中,只要某个列被声明为PRIMARY KEY,在领域模型对象的定义中,就可以使用JPA提供的@Id注解。

这个注解还可以和@Column注解组合使用:

@Id
@Column(name = "employeeId")
private int id;

主流关系数据库都支持主键的自动生成,JPA提供了@GeneratedValue注解说明了该主键通过自动生成。该注解还定义了strategy属性用以指定自动生成的策略。JPA还定义了@SequenceGenerator与@TableGenerator等特殊的ID生成器。

在建立领域模型时,我们强调从领域逻辑出发考虑领域类的定义。尤其对实体类而言,ID代表的是实体对象的身份标识。

它与数据表的主键有相似之处,例如二者都要求唯一性,但二者的本质完全不同:前者代表业务含义,后者代表技术含义;前者用于对实体对象生命周期的管理与跟踪,后者用于标记每一行在数据表中的唯一性。

领域驱动设计往往建议定义值对象作为实体的身份标识。一方面,值对象类型可以清晰表达该身份标识的业务含义;另一方面,值对象类型的封装也有利于应对未来主键类型可能的变化。

JPA定义了一个特殊的注解@EmbeddedId来建立数据表主键与身份标识值对象之间的映射。例如,为Employee实体对象定义了EmployeeId值对象,则Employee的定义为:

@Entity
@Table(name="employees")
public class Employee extends AbstractEntity<EmployeeId> implements AggregateRoot
<Employee> {
   @EmbeddedId
   private EmployeeId employeeId;
}

JPA对主键类有两个要求:相等性比较与序列化支持,即需要主键类实现Serializable接口,并重写Object的equals()与hashcode()方法。值对象的类定义还需要声明Embeddable注解。由于框架需要通过反射创建值对象,因此,如果值对象定义了带参数的构造函数,还需要为其定义默认的构造函数:

@Embeddable
public class EmployeeId implements Identity<String>, Serializable {
   @Column(name = "id")
   private String value;
   private static Random random;
   static {
      random = new Random();
   }
   // 必须提供默认的构造函数
   public EmployeeId() {
   }
   private EmployeeId(String value) {
      this.value = value;
   }
   @Override
   public String value() {
      return this.value;
   }
   public static EmployeeId of(String value) {
      return new EmployeeId(value);
   }
   public static Identity<String> next() {
      return new EmployeeId(String.format("%s%s%s",
                   composePrefix(),
                   composeTimestamp(),
                   composeRandomNumber()));
   }
   @Override
   public boolean equals(Object o) {
      if (this == o) return true;
      if (o == null || getClass() != o.getClass()) return false;
      EmployeeId that = (EmployeeId) o;
      return value.equals(that.value);
   }
   @Override
   public int hashCode() {
      return Objects.hash(value);
   }
}

使用时,可以直接传入EmployeeId对象作为主键查询条件:

Optional<Employee> optEmployee = employeeRepo.findById(EmployeeId.of("emp200109101000001"));
4.样式的阻抗不匹配

样式(schema)的阻抗不匹配,就是对象图与关系表之间的不匹配。要做到二者的匹配,需要做到图结构与表结构之间的互相转换。

在领域模型的对象图中,一个实体组合了另一个实体,由于两个实体都有各自的身份标识,映射到数据库,就可通过主外键关系建立关联。

关联关系包括一对一、一对多、多对一和多对多。

例如,在领域模型中,HourlyEmployee聚合根实体与TimeCard实体之间的关系可以定义为:

@Entity
@Table(name="hourly_employees")
public class HourlyEmployee extends AbstractEntity<EmployeeId> implements AggregateRoot
<HourlyEmployee> {
   @EmbeddedId
   private EmployeeId employeeId;
   @OneToMany // 该注解定义了一对多关系
   @JoinColumn(name = "employeeId", nullable = false)
   private List<TimeCard> timeCards = new ArrayList<>();
}
@Entity
@Table(name = "timecards")
public class TimeCard {
   private static final int MAXIMUM_REGULAR_HOURS = 8;
   @Id
   @GeneratedValue
   private String id;
   private LocalDate workDay;
   private int workHours;
   public TimeCard() {
   }
}

在数据模型中,timecards表通过外键employeeId建立与employees表之间的关联:

CREATE TABLE hourly_employees(
   employeeId VARCHAR(50) NOT NULL,
   ......
   PRIMARY KEY(employeeId)
);
CREATE TABLE timecards(
   id INT NOT NULL AUTO_INCREMENT,
   employeeId VARCHAR(50) NOT NULL,
   workDay DATE NOT NULL,
   workHours INT NOT NULL,
   PRIMARY KEY(id)
);

如果对象图的实体和值对象之间形成了一对多的关联,由于值对象没有唯一的身份标识,因此它对应的数据模型也没有主键,而将实体表的主键作为外键,由此来表达彼此之间的归属关系。

这时,领域模型仍然通过集合来表达一对多的关联,但使用的注解并非@OneToMany,而是@ElementCollection。

例如,领域模型中的SalariedEmployee聚合根实体与Absence值对象之间的关系可以定义为:

@Embeddable
public class Absence {
   private LocalDate leaveDate;
   @Enumerated(EnumType.STRING)
   private LeaveReason leaveReason;
   public Absence() {
   }
   public Absence(LocalDate leaveDate, LeaveReason leaveReason) {
      this.leaveDate = leaveDate;
      this.leaveReason = leaveReason;
   }
}
@Entity
@Table(name="salaried_employees")
public class SalariedEmployee extends AbstractEntity<EmployeeId> implements AggregateRoot
<SalariedEmployee> {
   private static final int WORK_DAYS_OF_MONTH = 22;
   @EmbeddedId
   private EmployeeId employeeId;
   @Embedded
   private Salary salaryOfMonth;
   @ElementCollection
   @CollectionTable(name = "absences", joinColumns = @JoinColumn(name = "employeeId"))
   private List<Absence> absences = new ArrayList<>();
   public SalariedEmployee() {
   }
}

@ElementCollection说明了字段absences是SalariedEmployee实体的字段元素,类型为集合;@CollectionTable标记了关联的数据表以及关联的外键。其数据模型的SQL语句如下:

CREATE TABLE salaried_employees(
   employeeId VARCHAR(50) NOT NULL,
   ......
   PRIMARY KEY(employeeId)
);
CREATE TABLE absences(
   employeeId VARCHAR(50) NOT NULL,
   leaveDate DATE NOT NULL,
   leaveReason VARCHAR(20) NOT NULL
);

数据表absences没有自己的主键,employeeId列是employees表的主键。注意,在Absence值对象的定义中,无须再定义employeeId字段,因为Absence值对象并不能脱离SalariedEmployee聚合根单独存在。这是聚合对领域模型产生的影响,也可视为聚合的设计约束。

5.对象模式的阻抗不匹配

领域模型要符合面向对象的设计原则,一个重要特征是建立了高内聚松耦合的对象图。

要做到这一点,就需要将具有高内聚关系的概念封装为一个类,通过显式的类型体现领域中的概念。

这样既提高了代码的可读性,又保证了职责的合理分配,避免出现一个庞大的实体类。领域驱动设计更强调这一点,并因此引入了值对象的概念,用以表现那些无须身份标识却又具有内聚知识的领域概念。

因此,一个设计良好的领域模型,往往会呈现出一个具有嵌套层次的对象图模型结构。虽然嵌套层次的领域模型与扁平结构的关系数据模型并不匹配,但通过JPA提供的@Embedded与@Embeddable注解可以非常容易实现这一嵌套组合的对象关系,例如Employee类的address属性和email属性:

@Entity
@Table(name="employees")
public class Employee extends AbstractEntity<EmployeeId> implements AggregateRoot
<Employee> {
   @EmbeddedId
   private EmployeeId employeeId;
   private String name;
   @Embedded
   private Email email;
   @Embedded
   private Address address;
}
@Embeddable
public class Address {
   private String country;
   private String province;
   private String city;
   private String street;
   private String zip;
   public Address() {
   }
}
@Embeddable
public class Email {
   @Column(name = "email")
   private String value;
   public String value() {
      return this.value;
   }
}

Address类和Email类都是Employee实体的值对象。注意,为了支持JPA框架通过反射创建对象,若为值对象定义了带参的构造函数,需要显式定义默认构造函数。

EmployeeId类的定义与Address类的定义相同,也属于值对象,只是前者由于作为了实体的身份标识,并映射了数据模型的主键,因此应声明为@EmbeddedId注解。无论是Address、Email还是EmployeeId类,在领域对象模型中虽然被定义为独立的类,但在数据模型中,却都是employees表中的列。

其中,Email类仅仅对应表中的一个列,之所以要定义为类,目的是在领域模型中体现电子邮件的领域概念,并有利于封装对邮件地址的验证逻辑;Address类封装了多个内聚的值,体现为country、province等列,以利于维护地址概念的完整性,同时也可以实现对领域概念的复用。创建employees表的SQL脚本如下所示:

CREATE TABLE employees(
   id VARCHAR(50) NOT NULL,
   name VARCHAR(20) NOT NULL,
   email VARCHAR(50) NOT NULL,
   employeeType SMALLINT NOT NULL,
   gender VARCHAR(10),
   currency VARCHAR(10),
   country VARCHAR(20),
   province VARCHAR(20),
   city VARCHAR(20),
   street VARCHAR(100),
   zip VARCHAR(10),
   mobilePhone VARCHAR(20),
   homePhone VARCHAR(20),
   officePhone VARCHAR(20),
   onBoardingDate DATE NOT NULL
   PRIMARY KEY(id)
);

一个值对象如果在数据模型中被设计为一个独立的表,由于无须定义主键,依附于实体对应的数据表,因此在领域模型中依旧标记为@Embeddable。这既体现了面向对象的封装思想,又表达了一对一或一对多的关系。

SalariedEmployee聚合中的Absence值对象就遵循了这样的设计原则。面向对象的封装思想体现了对细节的隐藏,正确的封装还体现为对职责的合理分配。

遵循“信息专家模式”,无论是针对领域模型中的实体,还是针对值对象,都应该从它们拥有的数据出发,判断领域行为是否应该分配给这些领域模型类。

如HourlyEmployee实体类的payroll(Period)方法、Absence值对象的isIn(Period)与isPaidLeave()方法乃至于Salary值对象的add(Salary)等方法,都充分体现了对领域行为的合理封装,避免了贫血模型的出现:

public class HourlyEmployee extends AbstractEntity<EmployeeId> implements AggregateRoot
<HourlyEmployee> {
   public Payroll payroll(Period period) {
      if (Objects.isNull(timeCards) || timeCards.isEmpty()) {
         return new Payroll(this.employeeId, period.beginDate(), period.endDate(),
Salary.zero());
      }
      Salary regularSalary = calculateRegularSalary(period);
      Salary overtimeSalary = calculateOvertimeSalary(period);
      Salary totalSalary = regularSalary.add(overtimeSalary);
      return new Payroll(this.employeeId, period.beginDate(), period.endDate(), totalSalary);
   }
}
public class Absence {
   public boolean isIn(Period period) {
      return period.contains(leaveDate);
   }
   public boolean isPaidLeave() {
      return leaveReason.isPaidLeave();
   }
}
public class Salary {
   public Salary add(Salary salary) {
      throwExceptionIfNotSameCurrency(salary);
      return new Salary(value.add(salary.value).setScale(SCALE), currency);
   }
   public Salary subtract(Salary salary) {
      throwExceptionIfNotSameCurrency(salary);
      return new Salary(value.subtract(salary.value).setScale(SCALE), currency);
   }
   public Salary multiply(double factor) {
      return new Salary(value.multiply(toBigDecimal(factor)).setScale(SCALE), currency);
   }
   public Salary divide(double multiplicand) {
      return new Salary(value.divide(toBigDecimal(multiplicand), SCALE, BigDecimal.
ROUND_DOWN), currency);
   }
}

这充分证明领域模型对象既可以作为持久化对象,搭建起对象与关系表之间的桥梁,又可以体现包含丰富领域行为在内的领域概念与领域知识。

合二者为一体的领域模型对象定义在领域层,可被南向网关的资源库端口与适配器直接访问,无须再定义单独的数据模型对象。前面提到的数据模型,实际上指的是数据库中创建的数据表。

对象模式中的泛化关系(通过继承体现)更为特殊,因为关系表自身不具备继承能力,这与对象之间的关联关系不同。继承体现了“差异式编程”,父类与子类以及子类之间存在属性的差异,但在数据模型中,却可以将父类与子类所有的属性无论差异都放在一张表中,就好似对集合求并集一般。

这种策略在ORM中被称为Single-Table策略。为了区分子类的类型差异,需要在这张单表中额外定义一个列,作为区分子类的标识列,对应的JPA注解为@DiscriminatorColumn。例如,如果Employee存在继承体系,若选择Single-Table策略,整个继承体系映射到employees表中,则它的标识列就是employeeType列。

若子类之间的差异太大,采用Single-Table策略实现继承会让数据表的行数据出现太多不必要的列,又不得不为这些列提供存储空间。要避免这种存储空间的冗余,可采用Joined-Subclass策略实现继承。

继承体系中的父实体与子实体在数据库中都有一个单独的表与之对应,子实体对应的表无须为继承自父实体的属性定义列,而是通过共享主键的方式与之关联。由于Single-Table策略是ORM默认的继承策略,若要采用Joined-Subclass策略,需要在父实体类的定义中显式声明继承策略,如下所示:

@Entity
@Inheritance(strategy=InheritanceType.JOINED)
@Table(name="employees")
public class Employee {}

采用Joined-Subclass策略实现继承时,子实体与父实体在数据模型中的表现实则为一对一的连接关系,这可以认为是为了解决对象关系阻抗不匹配的无奈之举,毕竟用表的连接关系表达类的泛化关系,怎么看怎么觉得别扭。

若领域模型中继承体系的子类较多,这一设计还会影响查询效率,因为它可能牵涉到多张表的连接。如果既不希望产生不必要的数据冗余,又不愿意表连接拖慢查询的速度,则可以采用Table- Per-Class策略。采用这种策略时,继承体系中的每个实体类都对应一个独立的表,与Joined-Subclass策略不同之处在于,父实体对应的表仅包含父实体的字段,子实体对应的表不仅包含了自身的字段,同时还包含了父实体的字段。

这相当于用数据表样式的冗余避免数据的冗余、用单表来避免不必要的连接。如果子类之间的差异较大,那么Table-Per-Class策略明显优于Joined-Subclass策略。

继承的目的绝不仅仅是复用,甚至可以说复用并非它的主要价值,毕竟“聚合/合成优先复用原则”已经成为面向对象设计的金科玉律。

继承的主要价值在于支持多态,以利用Liskov替换原则,使得子类能够替换父类而不改变其行为,并允许定义新的子类来满足功能扩展的需求,保证对扩展是开放的。在Java或C#中,由于受到单继承的约束,定义抽象接口以实现多态更为普遍。

无论是继承多态还是接口多态,都应站在领域逻辑的角度,思考是否需要引入合理的抽象来应对未来需求的变化。

在采用继承多态时,需要考虑对应的数据模型是否能够在对象关系映射中实现继承,并选择合理的继承策略以确定关系表的设计。如果继承多态与接口多态针对领域行为,则与领域模型的持久化无关,也就无须考虑领域模型与数据模型之间的映射。

6.瞬态领域模型

领域服务作为对领域行为的封装,自然无须考虑持久化;如果不是采用事件溯源模式,领域事件也无须考虑持久化。位于聚合内部的实体和值对象需要持久化,否则就无须引入资源库来管理它们的生命周期了。

除此之外,在设计领域模型时,往往会发现存在一些游离在聚合边界外的领域对象,它们拥有自己的属性值,体现了高内聚的领域概念,并遵循“信息专家模式”封装了操作自身信息的领域行为,但却没有身份标识,无须进行持久化,例如与HourlyEmployee聚合根交互的Period类,其作用是体现一个结算周期,作为薪资计算的条件:

public class Period {
   private LocalDate beginDate;
   private LocalDate endDate;
   public Period(LocalDate beginDate, LocalDate endDate) {
      this.beginDate = beginDate;
      this.endDate = endDate;
   }
   public Period(YearMonth yearMonth) {
      int year = yearMonth.getYear();
      int month = yearMonth.getMonthValue();
      int firstDay = 1;
      int lastDay = yearMonth.lengthOfMonth();
      this.beginDate = LocalDate.of(year, month, firstDay);
      this.endDate = LocalDate.of(year, month, lastDay);
   }public Period(int year, int month) {
      if (month < 1 || month > 12) {
         throw new InvalidDateException("Invalid month value.");
      }int firstDay = 1;
      int lastDay = YearMonth.of(year, month).lengthOfMonth();this.beginDate = LocalDate.of(year, month, firstDay);
      this.endDate = LocalDate.of(year, month, lastDay);
   }public LocalDate beginDate() {
      return beginDate;
   }public LocalDate endDate() {
      return endDate;
   }public boolean contains(LocalDate date) {
      if (date.isEqual(beginDate) || date.isEqual(endDate)) {
         return true;
      }
      return date.isAfter(beginDate) && date.isBefore(endDate);
   }
}

结算周期提供了成对的起止日期,缺少任何一个日期,就无法正确地进行薪资计算。将beginDate与endDate封装到Period类中,再利用构造函数限制实例的创建,就能避免起止日期任意一个值的缺失。

引入Period类还能封装领域行为,让对象之间的协作变得更加合理。它的类型没有声明@Entity,并不需要持久化,也没有被定义在聚合边界内。为示区别,可将这样的类称为瞬态类(transientclass),由此创建的对象则称为瞬态对象。

对应地,倘若在一个支持持久化的领域类中,需要定义一个无须持久化的字段,可将其称为瞬态字段(transient field)。JPA定义了@Transient注解用以显式声明这样的字段,例如:

@Entity
@Table(name="employees")
public class Employee extends AbstractEntity<EmployeeId> implements AggregateRoot
<Employee> {
   @EmbeddedId
   private EmployeeId employeeId;
   private String firstName;
   private String middleName;
   private String lastName;
   @Transient
   private String fullName;
}

Employee类对应的数据模型定义了firstName、middleName和lastName列。为了调用方便,该类又定义了fullName字段。该值并不需要持久化到数据库中, 因此声明为瞬态字段。瞬态类属于领域模型的一部分。相较于聚合内的实体和值对象,它更加纯粹,无须依赖任何外部框架,属于真正的POJO类;它的设计符合整洁架构思想,即处于内部核心的领域类不依赖任何外部框架。

7.JPA使用注意事项

我们可以使用 JPA 的级联更新实现聚合根的持久化。在实际操作中发现,JPA 并不好用。其实这不是 JPA 的问题,是因为 JPA 做的太多了,JPA 不仅有各种状态转换,还有多对多关系。

如果保持克制就可以使用 JPA 实现 DDD,尝试遵守下面的规则:

  • 不要使用 @ManyToMany 特性,多对多关系太复杂。
  • 只给聚合根配置 Repository 对象。聚合根内有其他内部实体,虽然需要持久化,但不要为它配置Repository对象。
  • 避免造成网状的关系,互相循环依赖。
  • 读写分离。关联等复杂查询,读写分离查询不要给 JPA 做,JPA 只做单个对象的查询,复杂查询可以给mybatis做。

二、领域模型与数据模型

在领域模型内部,聚合是最小的设计单元,资源库是持久化实现的抽象。一个资源库对应一个聚合,故而聚合也是领域模型最小的持久化单元。

当领域模型引入限界上下文与聚合之后,领域模型类与数据表之间就有可能突破类与表之间一一对应的关系。

因此,在遵循领域驱动设计原则实现持久化时,需要考虑领域模型与数据模型之间的关系,而在进行领域建模时,一定是先有领域模型,后有数据模型!

在定义了领域模型之后,将其映射为数据模型时,不能破坏限界上下文和聚合确定的边界。

至于聚合内部的实体和值对象,则不必保证类与表的一对一关系,也不应该将其设计为一对一关系。不能忽视物理边界对架构的影响。

限界上下文以进程为物理边界,确定了与业务架构对应的应用架构。进程内与进程间对领域模型的调用方式迥然不同。菱形对称架构限制了进程内直接调用领域模型的方式,这就为应用架构提供了演进的可能。

在限界上下文与菱形对称架构的基础上,系统的应用架构可以很容易地从单体架构演进到微服务架构。那么,数据架构能无缝演进吗?数据模型以数据库为物理边界,数据表为逻辑边界,由此确定了数据架构。

但是,限界上下文的物理边界无法做到与数据模型物理边界的一对一关系,例如数据库共享架构就破坏了这种关系。

此时就需要逻辑边界的约束力。领域模型必须与数据模型建立映射关系,才能使资源库适配器通过ORM框架进行持久化。

领域模型属于哪一个数据库,领域模型类属于哪一个数据表,类属性属于哪一个数据列,都是通过映射关系来配置和表达的。这种映射关系并不受数据库边界的影响。只要保证数据模型的逻辑边界与限界上下文的逻辑边界保持一致,就能保证数据架构的演进能力,前提是:数据模型需按照领域模型进行设计。

以薪资管理系统为例,员工管理和薪资结算分属两个不同的限界上下文:员工上下文和薪资上下文。

员工上下文关注员工基本信息的管理,薪资上下文需要对各种类型的员工进行薪资结算。

既然限界上下文是领域模型的知识语境,就可以在这两个限界上下文中同时定义员工Employee领域类,在领域设计模型中,体现为不同的聚合。

根据领域模型设计数据模型,就应该为不同限界上下文的员工领域概念建立不同的员工数据表。

考虑到限界上下文物理边界的不同,数据模型存在两种不同的设计方案。

  • 进程内边界,设计为单库多表:所有限界上下文共享同一个数据库,员工上下文的员工领域模型映射为员工表,薪资上下文的员工领域模型各自映射对应员工类型的员工表,表之间由共同的员工ID进行关联。这一方案满足单体架构风格。
  • 进程间边界,设计为多库多表:为不同限界上下文建立不同的数据库,数据表的定义与单库多表一致。这一方案符合微服务架构风格。无论数据模型采用哪一种设计方案,领域模型都几乎不会受到影响,唯一的影响是ORM元数据定义需要修改对库的映射。如图所示的领域模型代码结构不受数据模型设计方案的影响。

实现领域驱动设计(DDD)系列详解:领域模型的持久化-LMLPHP

在领域模型中,员工上下文的Employee聚合根实体与薪资上下文的HourlyEmployeeSalariedEmployeeCommissionedEmployee这3个聚合根实体之间存在隐含的员工ID关联。设计数据模型时,这4个聚合根实体对应4张数据主表,它们的id主键都是员工ID,彼此之间的关系如图所示。

实现领域驱动设计(DDD)系列详解:领域模型的持久化-LMLPHP
员工领域类的设计充分体现了限界上下文作为领域模型的知识语境,而数据模型与领域模型的对应关系又充分支持了限界上下文对业务能力的纵向切分。领域模型的战略设计与战术设计就是通过限界上下文和聚合的边界有机融合起来的。

三、聚合的持久化

使用JPA实现领域驱动设计的领域模型持久化虽然很方便,但是还是有以下问题:

  • 1.领域模型引入了技术因素,各领域模型增加了@Entity、@Column等与数据库表相关的注解,当设计领域模型时首先肯定没考虑数据库的因素,而是考虑业务因素
  • 2.JPA对多表查询的支持很差,若对报表有很强的需求,使用JPA进行实现需要绕很多弯子

另外,在对mysql这样的关系型数据库时,聚合的持久化也有以下问题:

  • 关系的映射不好处理,层级比较深的对象不好转换。
  • 将数据转换为聚合时会有 n+1 的问题,不好使用关系数据库的联表特性。
  • 全量的数据更新数据库的事务较大,性能低下。

聚合的持久化是 DDD 美好愿景落地的最大拦路虎,这些问题有部分可以被解决而有部分必须取舍。

(一)自己实现Repository

一般一个聚合对应一个资源库,若不使用JPA进行实现,则可以使用mybatis进行实现,那么需要自己实现Repository的功能。

使用 Mybatis Mapper,对 Mapper 再次封装。

class OrderRepository {
    private OrderMapper orderMapper;
    private OrderItemMapper orderItemMapper;

    public Order get(String orderId) {
        Order order = orderMapper.findById(orderId);
        order.setOrderItems(orderItemMapper.findAllByOrderId(orderId))
        return order;
    }
}

这种做法有一个小点问题,领域对象 Order 中有 orderItems 这个属性,但是数据库中不可能有 Items,一些开发者会认为这里的 Order 和通常数据库使用的 OrderEntity 不是一类对象,于是进行繁琐的类型转换。

类型转换和多余的一层抽象,加大了工作量。

如果使用 Mybatis,其实更好的方式是直接使用 Mapper 作为 Repository 层,并在 XML 中使用动态 SQL 实现上述代码。

还有一个问题是,一对多的关系,发生了移除操作怎么处理呢?

比较简单的方式是直接删除,再存入新的数组即可,也可以实现对象的对比,记录对象的历史版本,有选择的进行新增、删除和更新。完成了这些,恭喜你,你变相实现了JPA的特性。

(二)使用 Spring Data JDBC

Mybatis 就是一个 SQL 模板引擎,而 JPA 做的太多,有没有一个适中的 ORM 来持久化聚合呢?

Spring Data JDBC 就是人们设计出来持久化聚合,从名字来看他不是 JDBC,而是使用 JDBC 实现了部分 JPA 的规范,让你可以继续使用 Spring Data 的编程习惯。

Spring Data JDBC 的一些特点:

  • 没有 Hibernate 中 session 的概念,没有对象的各种状态
  • 没有懒加载,保持对象的完整性
  • 除了 Spring Data 的基本功能,保持简单,只有保存方法、事务、审计注解、简单的查询方法等。
  • 可以搭配 JOOQ 或 Mybatis 实现复杂的查询能力。

需要注意的是,Spring Data JDBC 的逻辑:

  • 如果聚合根是一个新的对象,Spring Data JDBC 会递归保存所有的关联对象。
  • 如果聚合根是一个旧的对象,Spring Data JDBC 会删除除了聚合根之外旧的对象再插入,聚合根会被更新。因为没有之前对象的状态,这是一种不得不做的事情。也可以按照自己策略覆盖相关方法。

(三)使用 Domain Service 变通处理

正是因为和 ORM 一起时候会有各种限制,而抽象一个 Repository 层会带来大的成本,所以有一种变通的方法。

这种方法不使用充血模型、也不让 Repository 来保证聚合的一致性,而是使用领域服务来实现相关逻辑,但会被批评为 DDD lite 或不是 “纯正的 DDD”。

这种编程范式有如下规则:

  • 按照 DDD 四层模型,Application Service 和 Domain Service 分开,Application Service 负责业务编排,不是必须的一层,可以由 UI 层兼任。
  • 一个聚合使用 DomainService 来保持业务的一致性,一个聚合只有一个 Domain Service。Domain Service 内使用 ORM 的各种持久化技术。
  • 除了 Domain Service 不允许其他地方之间使用 ORM 更新数据。
  • 当不被充血模型困住的时候,问题变得更清晰。

DDD 只是手段不是目的,对一般业务系统而言,充血模型不是必要的,我们的目的是让编码和业务清晰。

这里引入两个概念:

  • 业务主体。操作领域模型的拟人化对象,用来承载业务规则,也就是 Domain Service,比如订单聚合可以由一个服务来管理,保证业务的一致性。我们可以命名为:OrderManager.
  • 业务客体。聚合和领域对象,用来承载业务属性和数据。这些对象需要有状态和自己的生命周期,比如 Order、OrderItem。

回归到原始的编程哲学:

程序 = 数据结构 + 算法

业务主体负责业务规则(算法),业务客体负责业务属性和数据(数据结构),那么用不用 DDD 都能让代码清晰、明白和容易处理了。

07-26 13:35