A1.领域驱动设计(DDD)之领域层与Spring Data Jpa实践

前言

这是一个基于Java 21 的 六边形架构与领域驱动设计的一个通用项目,并且结合现有的最新版本技术架构实现了 领域驱动设计模式和六边形架构模式组件定义. 并且结合微服务,介绍了领域层,领域事件,资源库,分布式锁,序列化,安全认证,日志等,并提供了实现功能. 并且我会以日常发布文章和更新代码的形式来完善它.

数据存储

在六边形架构中, 领域模型与存储模型是分开的两部分,但是Hibernate提供了领域模型与存储模型强大的映射功能,并且将领域模型的操作直接映射到数据库. 简单来说,我们不用关心数据的存储,Hibernate会帮我们实现。为什么这里说Hibernate,因为Spring Data JPA的其中的一种底层实现是Hibernate JPA.

本篇不会介绍类似分析模式中各个对象关系与数据存储的映射关系(请期待),而是介绍怎么将数据库的模型对应到领域驱动设计中的值对象,实体,聚合根。 该代码部分在common-domain-with-spring-data-jpa模块中, 该模块只依赖开篇所讲的common-core。

数据库主键

在日常开发中,有时候会设计将业务唯一标识符设置为数据库(技术)的主键,我的建议是将业务唯一标识符与数据库主键分离开,并且在实体,值对象中隐藏数据库主键, 使数据库主键的唯一作用是确定领域对象能快速地定位到储存的位置。

package com.iokays.common.domain.jpa;

import jakarta.persistence.*;

import java.io.Serial;
import java.io.Serializable;

/**
 * 用于标识领域对象在数据库持久层的位置基类
 * <p>
 * <p>
 * 领域对象的业务并不关心数据库主键. 只是领域对象在数据库中的持久化需要主键映射数据的位置。
 */
@MappedSuperclass
@Access(AccessType.FIELD) //使用字段注入,防止贫血
public class IdentifiedDomainObject implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /**
     * 受保护的空构造方法,用于Hibernate 从数据库中加载对象时(实例化)使用, 业务代码不应该使用
     */
    protected IdentifiedDomainObject() {
        super();
    }

}

该基类定义了数据库ID的生成规则,并添加了一个受保护的构造函数用于Hibernate初始化对象,并且使用 @Access(AccessType.FIELD), 将字段直接通过字段名映射。 因为没有提供公共的设置,访问方法,即使继承了该类,也无法直接访问到这2个字段。

如果使用公开的Setter方法,会导致领域对象变为贫血模型。 贫血模型会导致领域对象的操作变成一个事务脚本。

基于数据库的值对象

package com.iokays.common.domain.jpa;


import com.iokays.common.core.domain.ValueObject;

import java.io.Serial;

/**
 * 具有唯一标识的值对象
 * <p>
 * 该标识非业务标识,是数据库持久化的唯一标识
 */
public abstract class IdentifiedValueObject<T> extends IdentifiedDomainObject implements ValueObject<T> {

    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 受保护的构造函数,防止直接实例化,用于Hibernate等ORM框架
     */
    protected IdentifiedValueObject() {
        super();
    }
}

具有唯一标识的值对象(ValueObject), 这里的唯一标识指的是含有数据库主键,因为值对象也是需要存储在数据库中,我们需要快速的找到指定的值对象。

基于数据库的实体

package com.iokays.common.domain.jpa;


import com.iokays.common.core.domain.Entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.io.Serial;
import java.time.LocalDateTime;

/**
 * 领域对象 实体
 */
@EntityListeners(AuditingEntityListener.class)
public abstract class IdentifiedEntity<T> extends IdentifiedDomainObject implements Entity<T> {

    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 创建时间,由SpringDataJpa自动生成。
     */
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdDate;

    /**
     * 修改时间,由SpringDataJpa自动生成。
     */
    @LastModifiedDate
    @Column(nullable = false)
    private LocalDateTime lastModifiedDate;

    /**
     * 受保护的空构造方法,用于Hibernate 从数据库中加载对象时(实例化)使用, 业务代码不应该使用
     */
    protected IdentifiedEntity() {
        super();
    }
}

实体对象比值对象多了2个字段,创建时间和最后更新时间,且两个字段的值都是Spring Data Jpa 自动生成的,在实体中,个人觉得这个是必要的。

基于数据库具有版本控制的实体

package com.iokays.common.domain.jpa;

import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.Version;

import java.io.Serial;

/**
 * 具有版本号的领域对象
 * <p>
 * 用于领域层与数据库层的数据版本控制
 * <p/>
 */
@MappedSuperclass
public abstract class ConcurrencySafeEntity<T> extends IdentifiedEntity<T> {

    @Serial
    private static final long serialVersionUID = 1L;

    @Column(nullable = false)
    @Version
    private int concurrencyVersion;

    protected ConcurrencySafeEntity() {
        super();
    }

    public int concurrencyVersion() {
        return this.concurrencyVersion;
    }

}

因为领域对象存在于内存中,当最终入库时,需要保证在变更前是数据库中最新的数据,所以用到了乐观锁的机制, 但当这样出现并发更新时,会出现大量的失败,那么我这边提出的解决方案时,在应用服务层添加锁机制。保证进行业务操作时拿到的最新的数据。

基于数据库的聚合根

package com.iokays.common.domain.jpa;

import com.iokays.common.core.domain.AggregateRoot;
import com.iokays.common.core.event.DomainEvent;
import jakarta.persistence.MappedSuperclass;
import org.springframework.data.annotation.Transient;
import org.springframework.data.domain.AfterDomainEventPublication;
import org.springframework.data.domain.DomainEvents;
import org.springframework.util.Assert;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;

/**
 * 聚合根
 * 该聚合根实现了Spring Data的{@link DomainEvents}和{@link AfterDomainEventPublication}注解,
 * 复用了AbstractAggregateRoot 的代码
 *
 * @param <A>
 * @see org.springframework.data.domain.AbstractAggregateRoot
 */
@MappedSuperclass
public abstract class AbstractAggregateRoot<A extends AbstractAggregateRoot<A>> extends ConcurrencySafeEntity<A> implements AggregateRoot {

    @Transient
    private final transient List<DomainEvent> domainEvents = new ArrayList<>();

    /**
     * Registers the given event object for publication on a call to a Spring Data repository's save or delete methods.
     *
     * @param event must not be {@literal null}.
     * @return the event that has been added.
     * @see #andEvent(DomainEvent)
     */
    protected DomainEvent registerEvent(DomainEvent event) {

        Assert.notNull(event, "Domain event must not be null");

        this.domainEvents.add(event);
        return event;
    }

    /**
     * Clears all domain events currently held. Usually invoked by the infrastructure in place in Spring Data
     * repositories.
     */
    @AfterDomainEventPublication
    protected void clearDomainEvents() {
        this.domainEvents.clear();
    }

    /**
     * All domain events currently captured by the aggregate.
     */
    @DomainEvents
    protected Collection<DomainEvent> domainEvents() {
        return Collections.unmodifiableList(domainEvents);
    }

    /**
     * Adds all events contained in the given aggregate to the current one.
     *
     * @param aggregate must not be {@literal null}.
     * @return the aggregate
     */
    protected final A andEventsFrom(A aggregate) {

        Assert.notNull(aggregate, "Aggregate must not be null");

        this.domainEvents.addAll(aggregate.domainEvents());

        return (A) this;
    }

    /**
     * Adds the given event to the aggregate for later publication
     * when calling a Spring Data repository's save or delete method.
     * Does the same as {@link #registerEvent(DomainEvent)} but returns the aggregate instead of the event.
     *
     * @param event must not be {@literal null}.
     * @return the aggregate
     * @see #registerEvent(DomainEvent)
     */
    protected final A andEvent(DomainEvent event) {

        registerEvent(event);

        return (A) this;
    }
}

该聚合根实现了Spring Data的{@link DomainEvents}和{@link AfterDomainEventPublication}注解, 同时我们发现它其实就是继承了ConcurrencySafeEntity,是一个实体, 并且我们保存领域事件的数组列表,操作结束后,会由Spring Data Jpa自动以Spring Event形式发送出去。就可以使用Spring EventListener监听,在同一个事务下进行事件存储操作。

业务唯一标识符

每一个实体都会有一个业务唯一标识符,我们只要简单的定义下抽象类即可,在各个实体内,各自实现标识符和生成规则即可。

package com.iokays.common.domain.jpa;

import jakarta.persistence.Embeddable;
import jakarta.persistence.MappedSuperclass;
import org.apache.commons.lang3.Validate;

import java.io.Serial;
import java.io.Serializable;

/**
 * 业务主键
 */
@Embeddable
@MappedSuperclass
public abstract class AbstractId implements Identity, Comparable<AbstractId>, Serializable {

    @Serial
    private static final long serialVersionUID = 1L;

    private String id;

    protected AbstractId(String anId) {
        this();
        this.setId(anId);
    }

    protected AbstractId() {
        super();
    }

    @Override
    public String id() {
        return this.id;
    }

    @Override
    public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }

        if (anObject instanceof AbstractId other) {
            return this.id().equals(other.id());
        }

        return false;
    }

    @Override
    public int hashCode() {
        return +(this.hashOddValue() * this.hashPrimeValue()) + this.id().hashCode();
    }

    @Override
    public String toString() {
        return this.getClass().getSimpleName() + " [id=" + id + "]";
    }

    protected int hashOddValue() {
        return this.getClass().hashCode();
    }

    protected int hashPrimeValue() {
        return 263;
    }

    protected void validateId(String anId) {
        // implemented by subclasses for validation.
        // throws a runtime exception if invalid.
    }

    private void setId(String anId) {
        Validate.notNull(anId, "The basic identity is required.");
        this.validateId(anId);
        this.id = anId;
    }

    @Override
    public int compareTo(AbstractId o) {
        return this.id().compareTo(o.id());
    }

}

实现了一个Identity, 可以公开获取id的值。

package com.iokays.common.domain.jpa;

public interface Identity {

    String id();

}

我们注意到,AbstractId给定了一个字段 id, 虽然和数据库主键同名,我们在使用的时候,需要在映射做别名处理。

@AttributeOverride(name = "id", column = @Column(name = "customer_id"))
private CustomerId customerId;

我们也提供了validateId方法,来验证id的合法性。

简单用例

customer event storming

在测试包中,添加了个客户注册的功能,很简单的添加操作,但是把值对象,实体,聚合根结合到了一起。

package com.iokays.sample.customer.domain;

import com.iokays.common.domain.jpa.AbstractAggregateRoot;
import com.iokays.sample.customer.domain.command.RegisterCustomer;
import com.iokays.sample.customer.domain.event.CustomerRegistered;
import jakarta.persistence.*;
import org.apache.commons.lang3.Validate;

import java.time.LocalDateTime;
import java.util.Objects;


@Entity(name = "t_customer")
public class Customer extends AbstractAggregateRoot<Customer> {

    @AttributeOverride(name = "id", column = @Column(name = "customer_id"))
    private CustomerId customerId;

    @AttributeOverride(name = "value", column = @Column(name = "email_address"))
    private EmailAddress emailAddress;

    @Embedded
    @AttributeOverride(name = "firstName", column = @Column(name = "first_name"))
    @AttributeOverride(name = "lastName", column = @Column(name = "last_name"))
    private FullName fullName;

    @Enumerated(EnumType.STRING)
    private Gender gender;

    private LocalDateTime registeredAt;

    protected Customer() {
        super();
    }

    public Customer(FullName fullName, Gender gender, EmailAddress emailAddress) {
        this();
        this.customerId = CustomerId.makeCustomerId();
        this.registeredAt = LocalDateTime.now();

        this.fullName = Validate.notNull(fullName, "name must not be null");
        this.gender = Validate.notNull(gender, "gender must not be null");
        this.emailAddress = Validate.notNull(emailAddress, "emailAddress must not be null");

        this.registerEvent(CustomerRegistered.issue(this.customerId, this.registeredAt));
    }

    public static Customer registerBy(final RegisterCustomer cmd) {
        Validate.notNull(cmd, "注册客户的命令不能为空");

        return new Customer(
                cmd.fullName(),
                cmd.gender(),
                cmd.emailAddress());
    }

    public CustomerId customerId() {
        return customerId;
    }

    @Override
    public boolean sameIdentityAs(Customer other) {
        return Objects.equals(this.customerId, other.customerId);
    }

}

受保护的构造函数是给Hibernate使用,而带了参数的构造函数,是给业务调用,当被成功调用后,将会得到一个完整的客户对象。 同时在最后一行,我们添加了一个领域事件-客户已创建事件。

客户的内部一共添加了5个属性,一个业务标识ID(customerId), 两个由值对象组成的emailAddress,fullName,最后两个基本属性。

每一个实体的唯一标识符,都使用一个标识对象来代替,让标识对象维护值的有效性。

package com.iokays.sample.customer.domain;

import com.iokays.common.domain.jpa.AbstractId;
import jakarta.persistence.Embeddable;
import org.apache.commons.lang3.Validate;

import java.util.UUID;

@Embeddable
public class CustomerId extends AbstractId {

    private static final String UUID_REGEX = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";

    private CustomerId() {
        super();
    }

    public CustomerId(String id) {
        super(id);
    }

    public static CustomerId makeCustomerId() {
        return new CustomerId(UUID.randomUUID().toString());
    }

    /**
     * 验证是否是UUID字符串
     *
     * @param anId
     */
    @Override
    protected void validateId(String anId) {
        Validate.notNull(anId);
        Validate.isTrue(anId.matches(UUID_REGEX));
    }
}

我们查看其中一个属性: EmailAddress值对象。

package com.iokays.sample.customer.domain;

import com.iokays.common.core.domain.ValueObject;
import jakarta.persistence.Embeddable;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.apache.commons.validator.routines.EmailValidator;

import java.util.Objects;

/**
 * 客户的邮箱地址
 *
 * @param value {@link Integer}邮箱地址的值
 */
@Embeddable
public record EmailAddress(String value) implements ValueObject<EmailAddress> {

    /**
     * 默认构造函数
     *
     * @param value {@link Integer}邮箱地址的值
     */
    public EmailAddress {
        Validate.isTrue(StringUtils.isNotEmpty(value), "邮箱地址不能为空");
        Validate.isTrue(isValidEmailAddress(value), "邮箱地址不合法");
    }

    /**
     * 创建邮箱地址 {@link EmailAddress}
     *
     * @param value {@link Integer}邮箱地址的值
     * @return 邮箱地址 {@link EmailAddress}
     */
    public static EmailAddress from(String value) {
        return new EmailAddress(value);
    }

    /**
     * 验证邮箱地址是否合法
     *
     * @param value {@link String} 邮箱地址的值
     * @return {@link Boolean} 邮箱地址是否合法
     */
    private boolean isValidEmailAddress(String value) {
        return EmailValidator.getInstance().isValid(value);
    }

    @Override
    public boolean sameValueAs(EmailAddress other) {
        return Objects.equals(value, other.value);
    }
}

邮箱地址的值对象一些验证工作,能保证不管我们在何时获取到这个值对象,都是一个合法的邮箱地址对象,不再需要在不同的业务或不同的地方去单独验证。 并且值对象内部没有提供修改方法,也就是说,当客户的邮箱地址变更时,直接替换成新的值对象即可。 同理,其它值对象也有不变的特性。

最后,我们来看看命令类,领域事件类, 应用服务类的实现:

package com.iokays.sample.customer.domain.command;

import com.iokays.common.core.command.Command;
import com.iokays.common.core.command.CommandId;
import com.iokays.sample.customer.domain.EmailAddress;
import com.iokays.sample.customer.domain.FullName;
import com.iokays.sample.customer.domain.Gender;

import java.time.Instant;

/**
 * 注册新客户的不可变记录命令
 *
 * @param id           {@link CommandId} 命令ID
 * @param registeredAt {@link Instant} 登记时间
 * @param fullName     {@link FullName} 客户的姓名
 * @param gender       {@link Gender} 客户的性别
 * @param emailAddress {@link EmailAddress} 客户的邮箱地址
 */
public record RegisterCustomer(CommandId id,
                               Instant registeredAt,
                               FullName fullName,
                               Gender gender,
                               EmailAddress emailAddress) implements Command {

    /**
     * 发出一个注册新客户的命令
     *
     * @param fullName     {@link FullName} 客户的姓名
     * @param gender       {@link Gender} 客户的性别
     * @param emailAddress {@link EmailAddress} 客户的邮箱地址
     * @return 返回一个注册新客户的命令 {@link RegisterCustomer}
     */
    public static RegisterCustomer issue(final FullName fullName,
                                         final Gender gender,
                                         final EmailAddress emailAddress) {
        return new RegisterCustomer(
                CommandId.generate(),
                Instant.now(),
                fullName,
                gender,
                emailAddress
        );
    }
}
package com.iokays.sample.customer.domain.event;

import com.iokays.common.core.event.DomainEvent;
import com.iokays.common.core.event.EventId;
import com.iokays.sample.customer.domain.CustomerId;

import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Objects;

/**
 * 客户已注册的领域事件
 *
 * @param id           {@link EventId} 事件标识
 * @param registeredAt {@link Instant} 事件发生时间
 * @param customerId   {@link CustomerId} 客户标识
 */
public record CustomerRegistered(EventId id, CustomerId customerId,
                                 LocalDateTime registeredAt) implements DomainEvent<CustomerRegistered> {

    /**
     * 发布一个领域事件
     *
     * @param customerId {@link CustomerId} 客户标识
     * @return {@link CustomerRegistered} 客户已注册的领域事件
     */
    public static CustomerRegistered issue(final CustomerId customerId, final LocalDateTime registeredAt) {
        return new CustomerRegistered(EventId.generate(), customerId, registeredAt);
    }

    @Override
    public boolean sameEventAs(CustomerRegistered other) {
        // 通过判断事件标识是否相等来判断事件是否相等
        return Objects.equals(this.id, other.id);
    }
}
package com.iokays.sample.customer.application.service;

import com.iokays.common.core.lock.DistributedLock;
import com.iokays.sample.customer.domain.Customer;
import com.iokays.sample.customer.domain.CustomerId;
import com.iokays.sample.customer.domain.CustomerRepository;
import com.iokays.sample.customer.domain.EmailAddress;
import com.iokays.sample.customer.domain.command.RegisterCustomer;
import org.apache.commons.lang3.Validate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;


@Service
public class CustomerApplicationService {

    private final CustomerRepository customers;

    public CustomerApplicationService(CustomerRepository customers) {
        this.customers = customers;
    }

    @Transactional
    @DistributedLock(value = "customer", key = "#cmd.emailAddress.value") //这里未实现
    public CustomerId registerCustomer(final RegisterCustomer cmd) {
        final EmailAddress emailAddress = cmd.emailAddress();

        Validate.isTrue(customers.findByEmailAddress(emailAddress).isEmpty(), String.format("给定的电子邮件地址: %s 已经存在", emailAddress));

        final Customer customer = Customer.registerBy(cmd);
        customers.save(customer);

        return customer.customerId();
    }

}

我们看到 CustomerApplicationService: 只是对事务管理,分布式锁配置,资源库保存调用(当为变更且对象为持久态,可以省去这一步)进行了处理。

未完待续…​

基于Spring Data Jpa 对领域层的值对象,实体,聚合根的实现已经处理完,下一篇会介绍使用SpringEvent和Spring Integration JDBC,对领域事件的存储和发送。

本站提供了 Hibernate, Spring的官方技术中文文档。