阅读 385

领域驱动设计战术篇--实体

在问题空间中存在很多具有固有身份的概念,通常情况下,这些概念将建模为实体。

实体是具有唯一标识的概念,找到领域中的实体并对其进行建模是非常重要的环节。如果理解一个概念是一个实体,就应该追问领域专家相关的细节,比如概念生命周期、核心数据、具体操作、不变规则等;从技术上来说,我们可以应用实体相关模式和实践。

1 理解实体

一个实体是一个唯一的东西,并且可以在相当长的一段时间内持续变化。

实体是一个具有身份和连贯性的概念。

  • 身份 是一个重要的领域概念,应该显示建模以提高其在领域中的表达性。
  • 连贯性 指通过唯一身份来让某个概念,在生命周期的各阶段被发现、被更新甚至被删除。

一个实体就是一个独立的事物。每个实体都拥有一个 唯一标识符 (也就是身份),并通过 标识 与和 类型 对实体进行区分开。通常情况下,实体是可变的,也就是说,他的状态随着时间发生变化。

唯一身份标识可变性特征 将实体对象和值对象区分开来。

由于从数据建模出发,通常情况下,CRUD 系统不能创建出好的业务模型。在使用 DDD 的情况下,我们会将数据模型转化成实体模型。

从根本上说,实体主要与身份有关,它关注“谁”而非 “什么”。

2 实现实体

大多数实体都有类似的特征,因此存在一些设计和实现上的技巧,其中包括唯一标识、属性、行为、验证等。

在实体设计早期,我们刻意将关注点放在能体现实体 唯一性属性行为 上,同时还将关注如何对实体进行查询。

2.1 唯一标识

有时,实体具有明确的自然标识,可以通过对概念的建模来实现;有时,可能没有已存的自然标识,将由应用程序生成并分配一个合理的标识,并将其用于数据存储。

  • 值对象 作为实体的唯一标识,能够更好的表达领域概念。
  • 标识具有稳定性 在为实体分配标识后,我们绝对不允许对其进行修改。如果键改变了,那么系统中所有引用该键的地方都需要同步更新,通常情况下,这是不可能做到的。不然,将导致严重的业务问题。
2.1.1 自然键作为唯一标识

在考虑实体身份时,首先考虑该实体所在问题空间是否已经存在唯一标识符,这些标识符被称为自然键。

通常情况下,以下几类信息可以作为自然键使用:

  • 身份证号
  • 国家编号
  • 税务编号
  • 书籍 ISBN
  • ...

在使用时,我们通常使用值对象模式对自然键进行建模,然后为实体添加一个构造函数,并在构造函数中完成唯一标识的分配。

首先,需要对书籍 ISBN 值对象建模:

@Value
public class ISBN {
    private String value;
}
复制代码

然后,对 Book 实体建模:

@Data
public class Book {
    private ISBN id;

    public Book(ISBN isbn){
        this.setId(isbn);
    }

    public ISBN getId(){
        return this.id;
    }

    private void setId(ISBN id){
        Preconditions.checkArgument(id != null);
        this.id = id;
    }
}
复制代码

Book 在构造函数中完成 id 的赋值,之后便不会修改,以保护实体标识的稳定性。

自然键,在实际研发中,很少使用。特别是在需要用户手工输入的情况下,难免会造成输入错误。对标识的修改会导致引用失效,因此,我们很少使用用户提供的唯一标识。通常情况下,会将用户输入作为实体属性,这些属性可以用于对象匹配,但是我们并不将这样的属性作为唯一身份标识。

2.1.2 应用程序生成唯一标识

当问题域中没有唯一标识时,我们需要决定标识生成策略并生成它。

最常见的生成方式包括自增数值、全局唯一标识符(UUID、GUID等)以及字符串等。

自增数值

数字通常具有最小的空间占用,非常利于持久化,但需要维护分配 ID 的全局计数器。

我们可以使用全局的静态变量作为全局计数器,如:

public final class NumberGenerator {
    private static final AtomicLong ATOMIC_LONG = new AtomicLong(1);

    public static Long nextNumber(){
        return ATOMIC_LONG.getAndIncrement();
    }
}

复制代码

但是,但应用崩溃或重启时,静态变量就会丢失它的值,这意味着会生成重复的 ID,从而导致业务问题。为了纠正这个问题,我们需要利用全局持久化资源构建计数器。

我们可以使用 Redis 或 DB 构建自己的全局计数器。

基于 Redis inc 指令的全局计数器:

@Component
public class RedisBasedNumberGenerator {
    private static final String NUMBER_GENERATOR_KEY = "number-generator";

    @Autowired
    private RedisTemplate<String, Long> redisTemplate;

    public Long nextNumber(){
        return this.redisTemplate.boundValueOps(NUMBER_GENERATOR_KEY)
                .increment();
    }
}

复制代码

基于 DB 乐观锁的全局计数器: 首先,定义用于生成 Number 的表结构:

create table tb_number_gen
(
 	id bigint auto_increment primary key,
 	`version` bigint not null,
 	type varchar(16) not null,
 	current_number bigint not null
 );
create unique index 'unq_type' on tb_number_gen ('type');
复制代码

然后,使用乐观锁完成 Number 生成逻辑:

@Component
public class DBBasedNumberGenerator {
    private static final String NUMBER_KEY = "common";
    private JdbcTemplate jdbcTemplate;

    @Autowired
    public void setDataSource(DataSource dataSource){
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    public Long nextNumber(){
        do {
            try {
                Long number = nextNumber(NUMBER_KEY);
                if (number != null){
                    return number;
                }
            }catch (Exception e){
                // 乐观锁更新失败,进行重试
//                LOGGER.error("opt lock failure to generate number, retry ...");
            }
        }while (true);
    }

    /**
     * 表结构:
     * create table tb_number_gen
     * (
     * 	id bigint auto_increment primary key,
     * 	`version` bigint not null,
     * 	type varchar(16) not null,
     * 	current_number bigint not null
     * );
     * add unique index 'unq_type' on tb_number_gen ('type');
     *
     * @param type
     * @return
     */
    private Long nextNumber(String type){
        NumberGen numberGen = jdbcTemplate.queryForObject(
                "select id, type, version, current_number as currentNumber " +
                        "from tb_number_gen " +
                        "where type = '" + type +"'",
                NumberGen.class);

        if (numberGen == null){
            // 不存在时,创建新记录
            int result = jdbcTemplate.update("insert into tb_number_gen (type, version, current_number) value ('" + type +" ', '0', '1')");
            if (result > 0){
                return 1L;
            }else {
                return null;
            }
        }else {
            // 存在时,使用乐观锁 version 更新记录
            int result = jdbcTemplate.update("update tb_number_gen " +
                    "set version = version + 1," +
                    "current_number = current_number + 1 " +
                    "where " +
                    "id = " + numberGen.getId() + " " +
                    " and " +
                    "version = " + numberGen.getVersion()
            );
            // 更新成功,说明从读取到更新这段时间,数据没有发生变化,numberGen 有效,结果为 number + 1
            if (result > 0){
                return numberGen.getCurrentNumber() + 1;
            }else {
                // 更新失败,说明从读取到更新这段时间,数据发生变化,numberGen 无效,获取 number 失败
                return null;
            }
        }
    }

    @Data
    class NumberGen{
        private Long id;
        private String type;
        private int version;
        private Long currentNumber;
    }
}

复制代码
全局唯一标识符

GUID 生成非常方便,并且自身就保障是唯一的,不过在持久化时会占用更多的存储空间。这些额外的空间相对来说微不足道,因此对大多数应用来说,GUID 是默认方法。

有很多算法可以生成全局唯一的标识,如 UUID、GUID 等。

生成策略,需要参考很多因子,以产生唯一标识:

  1. 计算节点当前时间,以毫秒记;
  2. 计算节点的 IP 地址;
  3. 虚拟机中工厂对象实例的对象标识;
  4. 虚拟机中由同一个随机数生成器生成的随机数

但,我们没有必要自己写算法构建唯一标识。Java 中的 UUID 是一种快速生成唯一标识的方法。

@Component
public class UUIDBasedNumberGenerator {

    public String nextId(){
        return UUID.randomUUID().toString();
    }
}
复制代码

如果对性能有很高要求的场景,可以将 UUID 实例缓存起来,通过后台线程不断的向缓存中添加新的 UUID 实例。

@Component
public class UUIDBasedPoolNumberGenerator {
    private static final Logger LOGGER = LoggerFactory.getLogger(UUIDBasedPoolNumberGenerator.class);

    private final BlockingQueue<String> idQueue = new LinkedBlockingQueue<>(100);
    private Thread createThread;

    /**
     * 直接从队列中获取已经生成的 ID
     * @return
     */
    public String nextId(){
        try {
            return idQueue.take();
        } catch (InterruptedException e) {
            LOGGER.error("failed to take id");
            return null;
        }
    }

    /**
     * 创建后台线程,生成 ID 并放入到队列中
     */
    @PostConstruct
    public void init(){
        this.createThread = new Thread(new CreateTask());
        this.createThread.start();
    }

    /**
     * 销毁线程
     */
    @PreDestroy
    public void destroy(){
        this.createThread.interrupt();
    }

    /**
     * 不停的向队列中放入 UUID
     */
    class CreateTask implements Runnable{

        @Override
        public void run() {
            while (!Thread.currentThread().isInterrupted()){
                try {
                    idQueue.put(UUID.randomUUID().toString());
                } catch (InterruptedException e) {
                    LOGGER.error("failed to create uuid");
                }
            }
        }
    }
}
复制代码

当在浏览器中创建一个实体并提交回多个后端 API 时,GUID 就会非常有用。如果没有 ID 后端服务将无法对相同实体进行识别。这时,最好使用 JavaScript 在客户端创建一个 GUID 来解决。

在浏览器中生成 GUID,可以有效控制提交数据的幂等性。

字符串

字符串常用于自定义 ID 格式,比如基于时间戳、多特征组合等。

如下例订单唯一标识:

public class OrderIdUtils {

    public static String createOrderId(String day, String owner, Long number){
        return String.format("%s-%s-%s", day, owner, number);
    }
}
复制代码

一个订单 ID 由日期、所有者和序号三者组成。

对于标识,使用 String 来维护并不是很好的方法,无法对其生成策略、具体格式进行有效限制。使用一个值对象会更加合适。

@Value
public class OrderId {
    private final String day;
    private final String owner;
    private final Long number;

    public OrderId(String day, String owner, Long number) {
        this.day = day;
        this.owner = owner;
        this.number = number;
    }

    public String getValue(){
        return String.format("%s-%s-%s", getDay(), getOwner(), getNumber());
    }

    @Override
    public String toString(){
        return getValue();
    }
}

复制代码

相比之下,OrderId 比 String 拥有更强的表达力。

2.1.3 持久化存储生成唯一标识

将唯一标识的生成委派给持久化机制是最简单的方案。我们从数据库获取的序列总是递增,结果总是唯一的。

大多数数据库(如 MySQL)都原生支持 ID 的生成。我们把新建实体传递到数据访问框架,在事务成功完成后,实体便有了 ID 标识。

一个使用 JPA 持久化的实例如下: 首先,定义 Entity 实体:

@Data
@Entity
public class Person {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private Date birthAt;
}
复制代码

实体类上添加 @Entity 注解标记为实体;@Id 标记该属性为标识;@GeneratedValue(strategy = GenerationType.IDENTITY) 说明使用数据库自增主键生成方式。 然后,定义 PersonRepository :

public interface PersonRepository extends JpaRepository<Person, Long> {
}
复制代码

PersonRepository 继承于 JpaRepository,具体的实现类会在运行时由 Spring Data Jpa 自动创建,我们只需直接使用即可。

@Service
public class PersonApplication {
    @Autowired
    private PersonRepository personRepository;

    public Long save(Person person){
        this.personRepository.save(person);
        return person.getId();
    }
}
复制代码

在成功调用 save(person) 后,JPA 框架负责将数据库生成的 ID 绑定到 Person 的 id 属性上,person.getId() 方法便能获取 id 信息。

性能可能是这种方法的一个缺点。

2.1.4 使用另一个限界上下文提供的唯一标识

通过集成上下文,可以从另一个限界上下文中获取唯一标识。但一般不会直接使用其他限界上下文的标识,而是需要将其翻译成本地限界上下文的概念。

这也是比较常见的一种策略。例如,在用户成功注册后,系统自动为其生成唯一名片,此时,名片唯一标识便可以直接使用用户 ID。

当用户注册成功后,User 限界上下文将发布 UserRegisteredEvent 事件。

@Value
public class UserRegisteredEvent {
    private final UserId userId;
    private final String userName;
    private final Date birthAt;
}
复制代码

Card 限界上下文,从 MQ 中获取 UserRegisteredEvent 事件,并将 UserId 翻译成本地的 CardId,然后基于 CardId 进行业务处理。具体如下:

@Component
public class UserEventHandler {

    @EventListener
    public void handle(UserRegisteredEvent event){
        UserId userId = event.getUserId();
        CardId cardId = new CardId(userId.getValue());
        ...
    }
}
复制代码
2.1.5 唯一标识生成时间

实体唯一标识的生成既可以发生在对象创建的时候,也可以发生在持久化对象的时候。

标识生成时间:

  • 及早标识 生成和赋值发生在持久化实体之前。
  • 延迟标识 生成和赋值发生在持久化实体的时候。

在某些情况下,将标识生成延迟到实例持久化会有些问题:

  1. 事件创建时,需要知道持久化实体的 ID。
  2. 如果将实体放入 Set 中,会因为没有 ID,从而导致逻辑错误。

相比之下,及早生成实体标识是比较推荐的做法。

2.1.6 委派标识

有些 ORM 框架,需要通过自己的方式来处理对象标识。

为了解决这个问题,我们需要使用两种标识,一种为领域使用,一种为 ORM 使用。这个在 ORM 使用的标识,我们称为委派标识。

委派标识和领域中的实体标识没有任何关系,委派标识只是为了迎合 ORM 而创建的。 对于外界来说,我们最好将委派标识隐藏起来,因为委派标识并不是领域模型的一部分,将委派标识暴露给外界可能造成持久化漏洞。

首先,我们需要定义一个公共父类 IdentitiedObject,用于对委派标识进行集中管理。

@MappedSuperclass
public class IdentitiedObject {
    @Setter(AccessLevel.PRIVATE)
    @Getter(AccessLevel.PRIVATE)
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long _id;
}
复制代码

委派标识的 setter 和 getter 都是 private 级别,禁止程序对其进行修改(JPA 框架通过反射对其进行访问)。然后,定义 IdentitiedPerson 实体类:

@Data
@Entity
public class IdentitiedPerson extends IdentitiedObject{
    @Setter(AccessLevel.PRIVATE)
    private PersonId id;
    private String name;
    private Date birthAt;

    private IdentitiedPerson(){

    }

    public IdentitiedPerson(PersonId id){
        setId(id);
    }
}
复制代码

IdentitiedPerson 实体以 PersonId 作为自己的业务标识,并且只能通过构造函数对其进行赋值。这样在隐藏委派标识的同时,完成了业务建模。

领域标识不需要作为数据库的主键,但大多数情况下,需要设置为唯一键。

2.1.7 本地标识和全局标识

在聚合边界内,我们可以将缩短后的标识作为实体的本地标识。而作为聚合根的实体需要全局的唯一标识。

聚合内部实体,只能通过聚合根进行间接访问。因此,只需保障在聚合内部具有唯一性即可。 例如,聚合根 Order 拥有一个 OrderItem 的集合,对于 OrderItem 的访问必须通过 Order 聚合根,因此,OrderItem 只需保障局部唯一即可。

@Value
public class OrderItemId {
    private Integer value;
}

@Data
@Entity
public class OrderItem extends IdentitiedObject{
    @Setter(AccessLevel.PRIVATE)
    private OrderItemId id;
    private String productName;
    private Integer price;
    private Integer count;

    private OrderItem(){

    }

    public OrderItem(OrderItemId id, String productName, Integer price, Integer count){
        setId(id);
        setProductName(productName);
        setPrice(price);
        setCount(count);
    }
}

复制代码

OrderItemId 为 Integer 类型,由 Order 完成其分配。

@Entity
public class Order extends IdentitiedObject{
    @Setter(AccessLevel.PRIVATE)
    private OrderId id;

    @OneToMany
    private List<OrderItem> items = Lists.newArrayList();

    public void addItem(String productName, Integer price, Integer count){
        OrderItemId itemId = createItemId();
        OrderItem item = new OrderItem(itemId, productName, price, count);
        this.items.add(item);
    }

    private OrderItemId createItemId() {
        Integer maxId = items.stream()
                .mapToInt(item->item.getId().getValue())
                .max()
                .orElse(0);
        return new OrderItemId(maxId + 1);
    }
}

复制代码

createItemId 方法获取现有 OrderItem 集合中最大的 id,并通过自增的方式,生成新的 id,从而保证在 Order 范围内的唯一性。相反,聚合根 Order 需要进行全局访问,因此,OrderId 需要全局唯一。

@Value
public class OrderId {
    private final String day;
    private final String owner;
    private final Long number;

    public OrderId(String day, String owner, Long number) {
        this.day = day;
        this.owner = owner;
        this.number = number;
    }

    public String getValue(){
        return String.format("%s-%s-%s", getDay(), getOwner(), getNumber());
    }

    @Override
    public String toString(){
        return getValue();
    }
}
复制代码

2.2 实体行为

实体专注于身份和连续性,如果将过多的职责添加到实体上,容易使实体变的臃肿。通常需要将相关行为委托给值对象和领域服务。

2.2.1 将行为推入值对象

值对象可合并、可比较和自验证,并方便测试。这些特征使其非常适用于承接实体的行为。

在一个分期付款的场景中,我们需要将总金额按照分期次数进行拆分,如果发生不能整除的情况,将剩下的金额合并到最后一笔中。

@Entity
@Data
public class Loan {
    private Money total;

    public List<Money> split(int size){
        return this.total.split(size);
    }
}
复制代码

其中,核心的查分逻辑在值对象 Money 中。

public class Money implements ValueObject {
    public static final String DEFAULT_FEE_TYPE = "CNY";
    @Column(name = "total_fee")
    private Long totalFee;
    @Column(name = "fee_type")
    private String feeType;
    private static final BigDecimal NUM_100 = new BigDecimal(100);

    private Money() {
    }

    private Money(Long totalFee, String feeType) {
        Preconditions.checkArgument(totalFee != null);
        Preconditions.checkArgument(StringUtils.isNotEmpty(feeType));
        Preconditions.checkArgument(totalFee.longValue() > 0);
        this.totalFee = totalFee;
        this.feeType = feeType;
    }

    public static Money apply(Long totalFee){
        return apply(totalFee, DEFAULT_FEE_TYPE);
    }

    public static Money apply(Long totalFee, String feeType){
        return new Money(totalFee, feeType);
    }


    private void checkInput(Money money) {
        if (money == null){
            throw new IllegalArgumentException("input money can not be null");
        }
        if (!this.getFeeType().equals(money.getFeeType())){
            throw new IllegalArgumentException("must be same fee type");
        }
    }

    public List<Money> split(int count){
        if (getTotalFee() < count){
            throw new IllegalArgumentException("total fee can not lt count");
        }
        List<Money> result = Lists.newArrayList();
        Long pre = getTotalFee() / count;
        for (int i=0; i< count; i++){
            if (i == count-1){
                Long fee = getTotalFee() - (pre * (count - 1));
                result.add(Money.apply(fee, getFeeType()));
            }else {
                result.add(Money.apply(pre, getFeeType()));
            }
        }
        return result;
    }
}
复制代码

可见,通过将功能推到值对象,不仅避免了实体 Loan 的臃肿,而且通过值对象 Money 的封装,大大增加了重用性。

2.2.2 将行为推入领域服务

领域服务没有标识、没有状态,对逻辑进行封装。非常适合承接实体的行为。

我们看一个秘密加密需求:

@Entity
@Data
public class User {
    private String password;

    public boolean checkPassword(PasswordEncoder encoder, String pwd){
        return encoder.matches(pwd, password);
    }

    public void changePassword(PasswordEncoder encoder, String pwd){
        setPassword(encoder.encode(pwd));
    }
}
复制代码

其中 PasswordEncoder 为领域服务

public interface PasswordEncoder {

	/**
	 * 秘密编码
	 */
	String encode(CharSequence rawPassword);

	/**
	 * 验证密码有效性
	 * @return true if the raw password, after encoding, matches the encoded password from
	 * storage
	 */
	boolean matches(CharSequence rawPassword, String encodedPassword);
}
复制代码

通过将密码加密和验证逻辑推到领域服务,不仅降低了实体 User 的臃肿,还可以使用策略模式对加密算法进行灵活替换。

2.2.3 重视行为命名

实体是业务操作的承载者,行为命名代表着很强的领域概念,需要使用通用语言中的动词,应极力避免 setter 方式的命名规则。

假设,一个新闻存在 上线下线 两个状态。

public enum NewsStatus {
    ONLINE, // 上线
    OFFLINE; // 下线
}
复制代码

假如直接使用 setter 方法,上线和下线两个业务概念很难表达出来,从而导致概念的丢失。

@Entity
@Data
public class News {
    @Setter(AccessLevel.PRIVATE)
    private NewsStatus status;
    /**
     * 直接的 setter 无法表达业务含义
     * @param status
     */
    public void setStatus(NewsStatus status){
        this.status = status;
    }
}
复制代码

setStatus 体现的是数据操作,而非业务概念。此时,我们需要使用具有业务含义的方法命名替代 setter 方法。


@Entity
@Data
public class News {
    @Setter(AccessLevel.PRIVATE)
    private NewsStatus status;

    public void online(){
        setStatus(NewsStatus.ONLINE);
    }

    public void offline(){
        setStatus(NewsStatus.OFFLINE);
    }
}
复制代码

与 setStatus 不同,onlineoffline 具有明确的业务含义。

2.2.4 发布领域事件

在实体行为成功执行之后,常常需要将变更通知给其他模块或系统,以触发后续流程。因此,需要向外发布领域事件。

发布领域事件,最大的问题是,在实体中如何获取发布事件接口 DomainEventPublisher 。常见的有以下几种模式:

  • 作为业务方法的参数进行传递。
  • 通过 ThreadLocal 与线程绑定。
  • 将事件暂存在实体中,在持久化完成后,获取并发布。

首先,我们需要定义事件相关接口。

DomainEvent:定义领域事件。

public interface DomainEvent<ID, E extends Entity<ID>> {
    E getSource();

    default String getType() {
        return this.getClass().getSimpleName();
    }
}
复制代码

DomainEventPublisher:用于发布领域事件。

public interface DomainEventPublisher {
    <ID, EVENT extends DomainEvent> void publish(EVENT event);

    default <ID, EVENT extends DomainEvent> void publishAll(List<EVENT> events) {
        events.forEach(this::publish);
    }
}
复制代码

DomainEventSubscriber: 事件订阅器,用于筛选待处理事件。

public interface DomainEventSubscriber<E extends DomainEvent> {
    boolean accept(E e);
}
复制代码

DomainEventHandler: 用于处理领域事件。

public interface DomainEventHandler<E extends DomainEvent> {
    void handle(E event);
}
复制代码

DomainEventHandlerRegistry : 对 DomainEventSubscriber 和 DomainEventHandler 注册。

public interface DomainEventHandlerRegistry {
    default <E extends DomainEvent>void register(DomainEventSubscriber<E> subscriber, DomainEventHandler<E> handler){
        register(subscriber, new DomainEventExecutor.SyncExecutor(), handler);
    }

    default <E extends DomainEvent>void register(Class<E> eventCls, DomainEventHandler<E> handler){
        register(event -> event.getClass() == eventCls, new DomainEventExecutor.SyncExecutor(), handler);
    }

    default <E extends DomainEvent>void register(Class<E> eventCls, DomainEventExecutor executor, DomainEventHandler<E> handler){
        register(event -> event.getClass() == eventCls, executor, handler);
    }

    <E extends DomainEvent>void register(DomainEventSubscriber<E> subscriber, DomainEventExecutor executor, DomainEventHandler<E> handler);

    <E extends DomainEvent> void unregister(DomainEventSubscriber<E> subscriber);

    <E extends DomainEvent> void unregisterAll(DomainEventHandler<E> handler);
}

复制代码

DomainEventBus: 继承自 DomainEventPublisher 和 DomainEventHandlerRegistry, 提供事件发布和订阅功能。

public interface DomainEventBus extends DomainEventPublisher, DomainEventHandlerRegistry{
}
复制代码

DomainEventExecutor: 事件执行器,指定事件执行策略。

public interface DomainEventExecutor {
    Logger LOGGER = LoggerFactory.getLogger(DomainEventExecutor.class);

    default <E extends DomainEvent>  void submit(DomainEventHandler<E> handler, E event){
        submit(new Task<>(handler, event));
    }

    <E extends DomainEvent> void submit(Task<E> task);

    @Value
    class Task<E extends DomainEvent> implements Runnable{
        private final DomainEventHandler<E> handler;
        private final E event;

        @Override
        public void run() {
            try {
                this.handler.handle(this.event);
            }catch (Exception e){
                LOGGER.error("failed to handle event {} use {}", this.event, this.handler, e);
            }

        }
    }

    class SyncExecutor implements DomainEventExecutor{
        @Override
        public <E extends DomainEvent> void submit(Task<E> task) {
            task.run();
        }
    }
}
复制代码

作为业务方法的参数进行传递 是最简单的策略,具体如下:

public class Account extends JpaAggregate {

    public void enable(DomainEventPublisher publisher){
        AccountEnabledEvent event = new AccountEnabledEvent(this);
        publisher.publish(event);
    }
}
复制代码

这种实现方案虽然简单,但是很琐碎,每次都需要传递 DomainEventPublisher 参数,无形中提高了调用方的复杂性。

通过 ThreadLocal 与线程绑定 将 EventPublisher 绑定到线程上下文中,在使用时,直接通过静态方法获取并进行事件发布。

public class Account extends JpaAggregate {
    public void enable(){
        AccountEnabledEvent event = new AccountEnabledEvent(this);
        DomainEventPublisherHolder.getPubliser().publish(event);
    }
}
复制代码

DomainEventPublisherHolder 实现如下:

public class DomainEventPublisherHolder {
    private static final ThreadLocal<DomainEventBus> THREAD_LOCAL = new ThreadLocal<DomainEventBus>(){
        @Override
        protected DomainEventBus initialValue() {
            return new DefaultDomainEventBus();
        }
    };

    public static DomainEventPublisher getPubliser(){
        return THREAD_LOCAL.get();
    }

    public static DomainEventHandlerRegistry getHandlerRegistry(){
        return THREAD_LOCAL.get();
    }
}
复制代码

将事件暂存在实体 是比较推荐的方法,具有很大的灵活性。

public class Account extends JpaAggregate {
    public void enable(){
        AccountEnabledEvent event = new AccountEnabledEvent(this);
        registerEvent(event);
    }
}

复制代码

registerEvent 方法在 AbstractAggregate 类中,将 Event 暂存到 events 中。

@MappedSuperclass
public abstract class AbstractAggregate<ID> extends AbstractEntity<ID> implements Aggregate<ID> {
    private static final Logger LOGGER = LoggerFactory.getLogger(AbstractAggregate.class);

    @JsonIgnore
    @QueryTransient
    @Transient
    @org.springframework.data.annotation.Transient
    private final transient List<DomainEventItem> events = Lists.newArrayList();

    protected void registerEvent(DomainEvent event) {
        events.add(new DomainEventItem(event));
    }

    protected void registerEvent(Supplier<DomainEvent> eventSupplier) {
        this.events.add(new DomainEventItem(eventSupplier));
    }

    @Override
    @JsonIgnore
    public List<DomainEvent> getEvents() {
        return Collections.unmodifiableList(events.stream()
                .map(eventSupplier -> eventSupplier.getEvent())
                .collect(Collectors.toList()));
    }

    @Override
    public void cleanEvents() {
        events.clear();
    }


    private class DomainEventItem {
        DomainEventItem(DomainEvent event) {
            Preconditions.checkArgument(event != null);
            this.domainEvent = event;
        }

        DomainEventItem(Supplier<DomainEvent> supplier) {
            Preconditions.checkArgument(supplier != null);
            this.domainEventSupplier = supplier;
        }

        private DomainEvent domainEvent;
        private Supplier<DomainEvent> domainEventSupplier;

        public DomainEvent getEvent() {
            if (domainEvent != null) {
                return domainEvent;
            }
            DomainEvent event = this.domainEventSupplier != null ? this.domainEventSupplier.get() : null;
            domainEvent = event;
            return domainEvent;
        }
    }
}

复制代码

完成暂存后,在成功持久化后,进行事件发布。

// 持久化实体
this.aggregateRepository.save(a);
if (this.eventPublisher != null){
    // 对实体中保存的事件进行发布
    this.eventPublisher.publishAll(a.getEvents());
    // 清理事件
    a.cleanEvents();
}
复制代码
2.2.5 变化跟踪

跟踪变化最实用的方法是领域事件和事件存储。当命令操作执行完成后,系统发出领域事件。事件的订阅者可以接收发生在模型上的事件,在接收事件后,订阅方将事件保存在事件存储中。

变化跟踪,通常与事件存储一并使用,稍后详解。

2.3 实体验证

除了身份标识外,使用实体的一个重要需求是保证他们是自验证,并总是有效的。尽管实体具有生命周期,其状态不断变化,我们需要保证在整个变化过程中,实体总是有效的。

验证的主要目的在于检查实体的正确性,检查对象可以是某个属性,也可以是整个对象,甚至是多个对象的组合。

即便领域对象的各个属性都是合法的,也不能表示该对象作为一个整体是合法的;同样,单个对象合法也并不能保证对象组合是合法的。

2.3.1 属性合法性验证

可以使用自封装来验证属性。

自封装性要求无论以哪种方式访问数据,即使从对象内部访问数据,都必须通过 getter 和 setter 方法。 一般情况下,我们可以在 setter 方法中,对属性进行合法性验证,比如是否为空、字符长度是否符合要求、邮箱格式是否正确等。

@Entity
public class Person extends JpaAggregate {
    private String name;
    private Date birthDay;

    public Person(){

    }

    public Person(String name, Date birthDay) {
        setName(name);
        setBirthDay(birthDay);
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        // 对输入参数进行验证
        Preconditions.checkArgument(StringUtils.isNotEmpty(name));
        this.name = name;
    }

    public Date getBirthDay() {
        return birthDay;
    }

    public void setBirthDay(Date birthDay) {
        // 对输入参数进行验证
        Preconditions.checkArgument(birthDay != null);
        this.birthDay = birthDay;
    }
}

复制代码

在构造函数中,我也仍需调用 setter 方法完成属性赋值。

2.3.2 验证整个对象

要验证整个实体,我们需要访问整个对象的状态----所有对象属性。

验证整个对象,主要用于保证实体满足不变性条件。不变条件来源于明确的业务规则,往往需要获取对象的整个状态以完成验证。

  • 延迟验证 就是一种到最后一刻才进行验证的方法。
  • 验证过程应该收集所有的验证结果,而不是在一开始遇到非法状态就抛出异常。
  • 当发现非法状态时,验证类将通知客户方或记录下验证结果以便稍后使用。
@Entity
public class Person extends JpaAggregate {
    private String name;
    private Date birthDay;
    @Override
    public void validate(ValidationHandler handler){
        if (StringUtils.isEmpty(getName())){
            handler.handleError("Name can not be empty");
        }
        if (getBirthDay() == null){
            handler.handleError("BirthDay can not be null");
        }
    }
}
复制代码

其中 ValidationHandler 用于收集所有的验证信息。

public interface ValidationHandler {
    void handleError(String msg);
}
复制代码

有时候,验证逻辑比领域对象本身的变化还快,将验证逻辑嵌入在领域对象中会使领域对象承担太多的职责。此时,我们可以创建一个单独的组件来完成模型验证。在 Java 中设计单独的验证类时,我们可以将该类放在和实体同样的包中,将属性的 getter 方法生命为包级别,这样验证类便能访问这些属性了。

假如,我们不想将验证逻辑全部放在 Person 实体中。可以新建 PersonValidator

public class PersonValidator implements Validator {
    private final Person person;

    public PersonValidator(Person person) {
        this.person = person;
    }

    @Override
    public void validate(ValidationHandler handler) {
        if (StringUtils.isEmpty(this.person.getName())){
            handler.handleError("Name can not be empty");
        }
        if (this.person.getBirthDay() == null){
            handler.handleError("BirthDay can not be null");
        }
    }
}
复制代码

然后,在 Person 中调用 PersonValidator:

@Entity
public class Person extends JpaAggregate {
    private String name;
    private Date birthDay;

    @Override
    public void validate(ValidationHandler handler){
        new PersonValidator(this).validate(handler);
    }
}
复制代码

这样将最大限度的避免 Person 的臃肿。

2.3.3 验证对象组合

相比之下,验证对象组合会复杂很多,也比较少见。最常用的方式是把这种验证过程创建成一个领域服务。

领域服务,我们稍后详解。

2.4 关注行为,而非数据

实体应该面向行为,这意味着实体应该公开领域行为,而不是公开状态。

专注于实体行为非常重要,它使得领域模型更具表现力。通过对象的封装特性,其状态只能被封装它的实例进行操作,这意味着任何修改状态的行为都属于实体。

专注于实体行为,需要谨慎公开 setter 和 getter 方法。特别是 setter 方法,一旦公开将使状态更改直接暴露给用户,从而绕过领域概念直接对状态进行更新。

典型的还是 News 上下线案例。

@Entity
@Data
public class News {
    @Setter(AccessLevel.PRIVATE)
    private NewsStatus status;

    public void online(){
        setStatus(NewsStatus.ONLINE);
    }

    public void offline(){
        setStatus(NewsStatus.OFFLINE);
    }

    /**
     * 直接的 setter 无法表达业务含义
     * @param status
     */
    private void setStatus(NewsStatus status){
        this.status = status;
    }
}
复制代码

2.5 实体创建

当我们新建一个实体时,希望通过构造函数来初始化足够多的状态。这样,一方面有助于表明该实体的身份,另一方面可以帮助客户端更容易的查找该实体。

2.5.1 构造函数

如果实体的不变条件要求该实体所包含的对象不能为 null,或者由其他状态计算所得,那么这些状态需要作为参数传递给构造函数。构造函数对实体变量赋值时,它把操作委派给实例变量的 setter 方法,这样便保证了实体变量的自封装性。

见 Person 实例,将无参构造函数设为 private,以服务于框架;通过 public 暴露所有参数的构造函数,并调用 setter 方法对实体有效性进行验证。

@Entity
public class Person extends JpaAggregate {
    private String name;
    private Date birthDay;

    private Person(){

    }

    public Person(String name, Date birthDay) {
        setName(name);
        setBirthDay(birthDay);
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        // 对输入参数进行验证
        Preconditions.checkArgument(StringUtils.isNotEmpty(name));
        this.name = name;
    }

    public Date getBirthDay() {
        return birthDay;
    }

    public void setBirthDay(Date birthDay) {
        // 对输入参数进行验证
        Preconditions.checkArgument(birthDay != null);
        this.birthDay = birthDay;
    }
}
复制代码
2.5.2 静态方法

对于使用一个实体承载多个类型的场景,我们可以使用实体上的静态方法,对不同类型进行不同构建。

@Setter(AccessLevel.PRIVATE)
@Entity
public class BaseUser extends JpaAggregate {
    private UserType type;
    private String name;

    private BaseUser(){

    }

    public static BaseUser createTeacher(String name){
        BaseUser baseUser = new BaseUser();
        baseUser.setType(UserType.TEACHER);
        baseUser.setName(name);
        return baseUser;
    }

    public static BaseUser createStudent(String name){
        BaseUser baseUser = new BaseUser();
        baseUser.setType(UserType.STUDENT);
        baseUser.setName(name);
        return baseUser;
    }

}
复制代码

相对,构造函数,静态方法 createTeachercreateStudent 具有更多的业务含义。

2.5.3 工厂

对于那些非常复杂的创建实体的情况,我们可以使用工厂模式。

这个不仅限于实体,对于复杂的实体、值对象、聚合都可应用工厂。并且,此处所说的工厂,也不仅限于工厂模式,也可以使用 Builder 模式。总之,就是将复杂对象的创建与对象本身功能进行分离,从而完成对象的瘦身。

2.6 分布式设计

分布式系已经成为新的标准,我们需要在新标准下,思考对领域设计的影响。

2.6.1 不要分布单个实体

强烈建议不要分布单个实体。在本质上,这意味着一个实体应该被限制成单个有界上下文内部的单个领域模型中的单个类(或一组类)。

假如,我们将单实体的不同部分分布在一个分布式系统之上。为了实现实体的一致性,可能需要全局事务保障,大大增加了系统的复杂度。要加载这个实体的话,查询多个不同系统也是一种必然。分布式系统中的网络开销将会放大,从而导致严重的性能问题。

单实体分布式部署

上图,将 OrderItem 和 ProductInfo 与 Order 进行分布式部署,在获取 Oder 时会导致大量的 RPC 调用,降低系统性能。

正确的部分方案为:

image

2.6.2 可以分布多个实体

对于多个实体间,进行分布式部署,可以将压力进行分散,大大增加系统性能。

分布多个实体

这种部署方式是推荐方式。

3 实体建模模式

建模模式有利于提升实体的表达性和可维护性。

3.1 妥善处理唯一标识

唯一标识是实体的身份,在完成分配后,绝对不允许修改。

对于程序生成:

@Data
public class Book {
    private ISBN id;

    private Book(){
        
    }

    public Book(ISBN isbn){
        this.setId(isbn);
    }

    public ISBN getId(){
        return this.id;
    }

    private void setId(ISBN id){
        Preconditions.checkArgument(id != null);
        this.id = id;
    }
}
复制代码

由构造函数传入 id,并将 setter 方法设置为私有,以避免被改变。

对于持久化生成:

@Data
@MappedSuperclass
public abstract class JpaAggregate extends AbstractAggregate<Long> {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Setter(AccessLevel.PRIVATE)
    @Column(name = "id")
    private Long id;


    @Override
    public Long getId() {
        return id;
    }
}
复制代码

使用 private 属性和 setter 方法,避免被修改。同时提供 public 的 getter 方法,用于获取生成的 id。

3.2 使用 Specification 进行规格建模

Specification 也称规格模式,主要针对领域模型中的描述规格进行建模。

规范模式是一种软件设计模式,可用于封装定义所需对象状态的业务规则。这是一种非常强大的方法,可以减少耦合并提高可扩展性,以选择与特定条件匹配的对象子集。这些规格可以使用逻辑运算符组合,从而形成复合规范。

规格 Specification 模式是将一段领域知识封装到一个单元中,称为规格。然后,在不同的场景中重用。主要有三种这样的场景:

  • 数据检索 是从存储中获取数据,查找与规范匹配的记录。
  • 内存中验证 是指检查某个对象是否符合规格描述。
  • 创建新对象的场景非常罕见,我们暂且忽略。

这很有用,因为它允许你避免域知识重复。当向用户显示数据时,相同的规格类可用于验证传入数据和从数据库中过滤数据。

在了解完 Specification 的特征 后,我们需要一个框架,它提供了 Specification 相关 API,既能从存储中检索数据,也能对内存对象进行验证。

在这,我们使用 Querydsl 进行构建。

一个 News 实体,存在两种状态,一个是用户自己设置的 NewsStatus,用于标记当前是上线还是下线状态;一个是管理员设置的 NewsAuditStatus,用于标记当前是审核通过还是审核拒绝状态。只有在用户设置为上线同时管理员审核通过,该 News 才可显示。

首先,我们先定义规则。

public class NewsPredicates {

    /**
     * 获取可显示规则
     * @return
     */
    public static PredicateWrapper<News> display(){
        return new Display();
    }

    /**
     * 可显示规则
     */
    static class Display extends AbstractPredicateWrapper<News>{

        protected Display() {
            super(QNews.news);
        }

        @Override
        public Predicate getPredicate() {
            Predicate online = QNews.news.status.eq(NewsStatus.ONLINE);
            Predicate passed = QNews.news.auditStatus.eq(NewsAuditStatus.PAASED);

            return new BooleanBuilder()
                    .and(online)
                    .and(passed);
        }
    }
}
复制代码

该规则可以应用于内存对象。


@Entity
@Data
@QueryEntity
public class News {
    @Setter(AccessLevel.PRIVATE)
    private NewsAuditStatus auditStatus;

    @Setter(AccessLevel.PRIVATE)
    private NewsStatus status;

    /**
     * 判断是否是可显示的
     * @return
     */
    public boolean isDisplay(){
        return NewsPredicates.display().accept(this);
    }
}

复制代码

同时,该规则也可以用于数据检索。

public interface NewsRepository extends Repository<News, Long>, QuerydslPredicateExecutor<News> {

    /**
     * 查找可显示的信息
     * @param pageable
     * @return
     */
    default Page<News> getDispaly(Pageable pageable){
        return findAll(NewsPredicates.display().getPredicate(), pageable);
    }
}
复制代码

可显示规则全部封装于 NewsPredicates 中,如果规则发生变化,只需对 NewsPredicates 进行调整即可。

3.3 使用 Enum 简化状态模式

实体拥有自己的生命周期,往往会涉及状态管理。对状态建模是实体建模的重要部分。

管理实体状态,状态设计模式具有很大的诱惑。

比如一个简单的审核流程。

graph TB
已提交--通过-->审核通过
已提交--修改-->已提交
已提交--拒绝-->审核拒绝
审核拒绝--修改-->已提交
复制代码

使用状态模式如下:

首先,定义状态接口。

public interface AuditStatus {
    AuditStatus pass();
    AuditStatus reject();
    AuditStatus edit();
}
复制代码

该接口中包含所有操作。然后,定义异常类。

public class StatusNotSupportedException extends RuntimeException{
}
复制代码

在当前状态不允许执行某些操作时,直接抛出异常,以中断流程。然后,定义各个状态类,如下:

SubmittedStatus

public class SubmittedStatus implements AuditStatus{
    @Override
    public AuditStatus pass() {
        return new PassedStatus();
    }

    @Override
    public AuditStatus reject() {
        return new RejectedStatus();
    }

    @Override
    public AuditStatus edit() {
        return new SubmittedStatus();
    }
}
复制代码

PassedStatus

public class PassedStatus implements AuditStatus{
    @Override
    public AuditStatus pass() {
        throw new StatusNotSupportedException();
    }

    @Override
    public AuditStatus reject() {
        throw new StatusNotSupportedException();
    }

    @Override
    public AuditStatus edit() {
        throw new StatusNotSupportedException();
    }
}
复制代码

RejectedStatus

public class RejectedStatus implements AuditStatus{
    @Override
    public AuditStatus pass() {
        throw new StatusNotSupportedException();
    }

    @Override
    public AuditStatus reject() {
        throw new StatusNotSupportedException();
    }

    @Override
    public AuditStatus edit() {
        return new SubmittedStatus();
    }
}
复制代码

但,状态模式导致大量的模板代码,对于简单业务场景显得有些冗余。同时太多的状态类为持久化造成了不少麻烦。此时,我们可以使用 Enum 对其进行简化。

public enum AuditStatusEnum {
    SUBMITED(){
        @Override
        public AuditStatusEnum pass() {
            return PASSED;
        }

        @Override
        public AuditStatusEnum reject() {
            return REJECTED;
        }

        @Override
        public AuditStatusEnum edit() {
            return SUBMITED;
        }
    },
    PASSED(){

    },
    REJECTED(){
        @Override
        public AuditStatusEnum edit() {
            return SUBMITED;
        }
    };

    public AuditStatusEnum pass(){
        throw new StatusNotSupportedException();
    }

    public AuditStatusEnum reject(){
        throw new StatusNotSupportedException();
    }

    public AuditStatusEnum edit(){
        throw new StatusNotSupportedException();
    }
}
复制代码

AuditStatusEnum 与 之前的状态模式功能完全一致,但代码要紧凑的多。

另外,使用显示建模也是一种解决方案。这种方式会为每个状态创建一个类,通过类型检测机制严格控制能操作的方法,但对于存储有些不大友好,在实际开发中,使用的不多。

3.4 使用业务方法和 DTO 替换 setter

之前提过,实体不应该绕过业务方法,直接使用 setter 对状态进行修改。

如果业务方法拥有过长的参数列表,在使用上也会导致一定的混淆。最常见策略是,使用 DTO 对业务所需数据进行传递,并在业务方法中调用 getter 方法获取对于数据。

@Entity
@Data
public class User {
    private String name;
    private String nickName;
    private Email email;
    private Mobile mobile;
    private Date birthDay;

    private String password;

    public boolean checkPassword(PasswordEncoder encoder, String pwd){
        return encoder.matches(pwd, password);
    }

    public void changePassword(PasswordEncoder encoder, String pwd){
        setPassword(encoder.encode(pwd));
    }

    public void update(String name, String nickName, Email email, Mobile mobile, Date birthDay){
        setName(name);
        setNickName(nickName);
        setEmail(email);
        setMobile(mobile);
        setBirthDay(birthDay);
    }

    public void update(UserDto userDto){
        setName(userDto.getName());
        setNickName(userDto.getNickName());
        setEmail(userDto.getEmail());
        setMobile(userDto.getMobile());
        setBirthDay(userDto.getBirthDay());
    }
}
复制代码

3.5 使用备忘录或 DTO 处理数据显示

实体存储的数据,往往需要读取出来,在 UI 中显示,或被其他系统使用。

实体作为领域概念,不允许脱离领域层,而在 UI 中直接使用。此时,我们需要使用备忘录或 DTO 模式,将实体与数据解耦。

3.6 避免副作用方法

方法的副作用,是指一个方法的执行,如果在返回一个值之外还导致某些“状态”发生变化,则称该方法产生了副作用。

根据副作用概念,我们可以提取出两类方法:

  • Query 方法 有返回值,但不改变内部状态。
  • Command 方法 没有返回值,但会改变内部状态。

在实际开发中,需要对两者进行严格区分。

在 Application 中,Command 方法需要开启写事务;Query 方法只需开启读事务即可。

@Service
public class NewsApplication extends AbstractApplication {
    @Autowired
    private NewsRepository repository;

    @Transactional(readOnly = false)
    public Long createNews(String title, String content){
        return creatorFor(this.repository)
                .instance(()-> News.create(title, content))
                .call()
                .getId();
    }

    @Transactional(readOnly = false)
    public void online(Long id){
        updaterFor(this.repository)
                .id(id)
                .update(News::online)
                .call();
    }

    @Transactional(readOnly = false)
    public void offline(Long id){
        updaterFor(this.repository)
                .id(id)
                .update(News::offline)
                .call();
    }

    @Transactional(readOnly = false)
    public void reject(Long id){
        updaterFor(this.repository)
                .id(id)
                .update(News::reject)
                .call();
    }

    @Transactional(readOnly = false)
    public void pass(Long id){
        updaterFor(this.repository)
                .id(id)
                .update(News::pass)
                .call();
    }

    @Transactional(readOnly = true)
    public Optional<News> getById(Long id){
        return this.repository.getById(id);
    }

    @Transactional(readOnly = true)
    public Page<News> getDisplay(Pageable pageable){
        return this.repository.getDispaly(pageable);
    }
}
复制代码

其中,有一个比较特殊的方法,创建方法,由于采用的是数据库生成主键策略,需要将生成的主键返回。

3.7 使用乐观锁进行并发控制

实体主要职责是维护业务不变性,当多个用户同时修改一个实体时,会将事情复杂化,从而导致业务规则的破坏。

对此,需要在实体上使用乐观锁进行并发控制,保障只有一个用户更新成功,从而保护业务不变性。

Jpa 框架自身便提供了对乐观锁的支持,只需添加 @Version 字段即可。

@Getter(AccessLevel.PUBLIC)
@MappedSuperclass
public abstract class AbstractEntity<ID> implements Entity<ID> {
    private static final Logger LOGGER = LoggerFactory.getLogger(AbstractEntity.class);

    @Version
    @Setter(AccessLevel.PRIVATE)
    @Column(name = "version", nullable = false)
    private int version;
}
复制代码

4 总结

  • 实体是问题域中具有唯一身份的领域概念。
  • 与值对象不同,实体的相等性严格基于唯一标识。
  • 实体具有明确的生命周期。
  • 在实体生命周期中,需要严格遵从业务不变性条件。
  • 应该将实体定位为值对象的容器,把行为推到值对象和领域服务中,从而避免实体的臃肿。
  • 实体可以提供属性、对象、对象组等多种验证规则,从而保护业务。
  • 实体的唯一标识,可以来自领域概念、程序生成、存储生成等。
  • 规格模式是处理实体规则描述的一大利器。
  • 乐观锁的使用,将大大减少并发导致的业务错误。
关注下面的标签,发现更多相似文章
评论