A10.领域驱动设计(DDD)之领域层与Spring Data MongoDB融合实践

前言

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

开篇

Spring Data MongoDB 中文文档: https://www.iokays.com/spring-data-mongodb/index.html

数据存储

在领域层,我们使用过Spring Data Jpa来操作数据库. 一般是使用关系性数据库来处理,但是在某些场景下,使用NoSQL数据库来存储数据, 本篇就讲解使用Mongodb来实现领域对象.

MongoDB 基本操作

MongoDB开启单节点多副本模式

众所周知,MongoDB只能在多副本模式下才可以使用事务。单机模式下,我们需要如下配置:

vi /etc/mongod.conf

replication:
    replSetName: rs0

sudo systemctl restart mongod

mongosh --port 27017

rs.initiate()

MongoDB 索引的使用

package com.iokays.test.mongodb;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.index.Index;

@Slf4j
class MongoIndexTest extends AbstractMongoTest {

    @Test
    @DisplayName("测试 创建, 删除索引")
    void testIndex() {
        final String indexName = "name_1";

        // 创建索引
        final var name = template.indexOps(Person.class).ensureIndex(new Index().named(indexName).on("name", Sort.Direction.ASC));
        log.info("indexName: {}", name);

        Assertions.assertEquals(2, template.indexOps(Person.class).getIndexInfo().size());

        // 查询索引
        final var indexInfo = template.indexOps(Person.class).getIndexInfo();
        log.info("indexInfo: {}", indexInfo);

        //删除索引
        template.indexOps(Person.class).dropIndex(indexName);

        Assertions.assertEquals(1, template.indexOps(Person.class).getIndexInfo().size());

    }
}

MongoDB 插入,修改,删除等基本操作

package com.iokays.test.mongodb;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.mongodb.core.FindAndModifyOptions;
import org.springframework.data.mongodb.core.query.Update;

import java.util.Optional;

import static org.springframework.data.mongodb.core.query.Criteria.where;
import static org.springframework.data.mongodb.core.query.Query.query;
import static org.springframework.data.mongodb.core.query.Update.update;

@Slf4j
class MongoSURTest extends AbstractMongoTest {

    @Test
    @DisplayName("base 测试")
    void testBase() throws InterruptedException {
        final String johnDoe = "John Doe";
        final Person person = template.insert(new Person(johnDoe, 42));
        final long count = template.query(Person.class).matching(query(where("name").is(johnDoe))).stream().count();
        log.info("count: {}", count);
        Person person2 = template.findById(person.getId(), Person.class);
        log.info("person2: {}", person2);
        template.updateFirst(query(where("name").is(johnDoe)), update("age", 43), Person.class);
    }

    @Test
    @DisplayName("更新 测试")
    void testUpdate() {
        template.remove(Person.class);

        final var name = "Iokays";
        template.insert(new Person(name, 18));

        Optional<Person> modify = template.update(Person.class).matching(query(where("name").is(name)))
                .apply(new Update().inc("age", 1))
                .findAndModify();

        log.info("modify: {}", modify);

        Optional<Person> modifyNew = template.update(Person.class).matching(query(where("name").is(name)))
                .apply(new Update().inc("age", 1))
                .withOptions(FindAndModifyOptions.options().returnNew(true))
                .findAndModify();

        log.info("modifyNew: {}", modifyNew);


    }

}

大家想要熟悉Spring Data MongoDB,可以结合上面的例子,对操作进行测试,来熟悉MongoDB的基本操作。但接下来会开始介绍领域驱动设计(DDD)领域层和Spring Data MongoDB融合实践。

融合

接下来,我们从最小粒度出发,来讲解领域驱动设计(DDD)领域层各个对象与Spring Data MongoDB融合.

数据库(MongoDB)主键

这里的思想和JPA结合的主键的思路是一样的,该主键只是让领域对象更快的找到对应的数据,而不是业务的唯一标识。该主键尽量设计为业务无感, 业务层应该是不知道该主键的存在。那么我们建立一个抽象类,来处理主键的生成策略,这样业务的领域对象只需要继承该抽象类,就可以获得主键了。

package com.iokays.common.domain.mongodb;

import org.springframework.data.mongodb.core.mapping.MongoId;

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

/**
 * 用于标识领域对象在数据库持久层的位置基类
 * <p>
 * <p>
 * 领域对象的业务并不关心数据库主键. 只是领域对象在数据库中的持久化需要主键映射数据的位置。
 */
public class IdentifiedDomainObject implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;

    @MongoId
    private String id;

}

值对象与数据库的映射

因为MongoDB可以存储丰富的数据结构,我们可以把值对象直接映射到文档中的指定字段中,对于值对象,就不会像JPA那样,需要单独的实体类(表)来映射。我只需要定义一个 record 类并且标记为值对象即可。

import com.iokays.common.core.domain.ValueObject;
import org.apache.commons.validator.routines.EmailValidator;

public record Email(String anEmail) implements ValueObject<Email> {

    public Email {
        EmailValidator.getInstance().isValid(anEmail);
    }

    @Override
    public boolean sameValueAs(Email other) {
        return this.anEmail.equals(other.anEmail);
    }
}

如果想要用指定的文档来存储值对象,我们也可以定义一个基于MongoDB具有唯一标识的值对象.

package com.iokays.common.domain.mongodb;

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();
    }
}

业务主键的对象结构

因为实体具有唯一标识,也就是我们所说的业务主键, 那么业务主键必定一个特殊的类,所以我们定义了AbstractId,来标记是业务唯一标识类。

package com.iokays.common.domain.mongodb;

import org.apache.commons.lang3.Validate;

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

/**
 * 业务主键
 */
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());
    }

}

然后在具体业务主键去实现它,并定义生成规则。

package com.iokays.sample.domain.customer;

import com.iokays.common.domain.mongodb.AbstractId;
import org.apache.commons.lang3.Validate;

import java.util.UUID;

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));
    }
}

因为Mongodb支持字段是Object,所以无需像JPA-hibernate那样,需要重定向该字段到指定的表字段。

实体与数据库的映射

我们先定义一个简单的实体抽象类, 因为实体内部的属性是具体状态并且是可变的,所以加了两个字段:创建时间,最后一次编辑时间。

package com.iokays.common.domain.mongodb;


import com.iokays.common.core.domain.Entity;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;

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

/**
 * 领域对象 实体
 */
public abstract class IdentifiedEntity<T> extends IdentifiedDomainObject implements Entity<T> {

    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 创建时间,由SpringDataJpa自动生成。
     */
    @CreatedDate
    private LocalDateTime createdDate;

    /**
     * 修改时间,由SpringDataJpa自动生成。
     */
    @LastModifiedDate
    private LocalDateTime lastModifiedDate;

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

因为实体的变更,最终会落地到存储层,我们需要保证数据的完整性和及时性,所以我们添加了一个版本号字段,我们在每次修改实体的时候,都会更新该字段。

package com.iokays.common.domain.mongodb;

import org.springframework.data.annotation.Version;

import java.io.Serial;

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

    @Serial
    private static final long serialVersionUID = 1L;

    @Version
    private int concurrencyVersion;

    protected ConcurrencySafeEntity() {
        super();
    }

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

}

聚合根

聚合根是由多个实体和值对象组成的, 并定义其中一个实体为聚合根。实体与值对象所有的行为操作都是聚合根的行为和操作的入口。并且聚合根管理领域事件的发布。

package com.iokays.common.domain.mongodb;

import com.iokays.common.core.domain.AggregateRoot;
import com.iokays.common.core.event.DomainEvent;
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
 */
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;
    }
}

用例

package com.iokays.sample.domain.customer;

import com.iokays.common.domain.mongodb.AbstractAggregateRoot;
import com.iokays.sample.domain.customer.command.RegisterCustomer;
import com.iokays.sample.domain.customer.event.CustomerRegistered;
import org.apache.commons.lang3.Validate;
import org.springframework.data.convert.ValueConverter;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Unwrapped;

import java.time.LocalDateTime;

import static org.springframework.data.mongodb.core.mapping.Unwrapped.OnEmpty.USE_NULL;

@Document
public class Customer extends AbstractAggregateRoot<Customer> {

    @ValueConverter(CustomerIdConverter.class)
    private CustomerId customerId;

    @Unwrapped(onEmpty = USE_NULL)
    private EmailAddress emailAddress;

    private FullName fullName;

    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());
    }

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


}

先去掉字段上的注解,保存在Mongodb会得到这样的结构。

  {
    _id: '673d7f3512f29366d0c75abc',
    customerId: { _id: 'ea311357-e154-416e-b265-ff07cfee8d06' },
    emailAddress: { email: 'pengyuanbing@iokays.com' },
    fullName: { firstName: '孙', lastName: '悟空' },
    gender: 'MALE',
    registeredAt: ISODate('2024-11-20T06:18:29.081Z'),
    concurrencyVersion: 1,
    _class: 'com.iokays.sample.domain.customer.Customer'
  }

领域事件监听

package com.iokays.sample.adapter.messaging.eventbus.publisher;

import com.iokays.common.core.adapter.DrivenAdapter;
import com.iokays.common.core.event.DomainEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Component
@DrivenAdapter
@Transactional
class DomainEventBusMessagePublisher {

    @EventListener
    public void handle(final DomainEvent<?> evt) {
        //消息发布[入库], 分布式事务本地消息表的持久化的位置
        log.info("消息发布[入库]: 客户已注册事件: {}", evt);
//        throw new RuntimeException("测试异常,业务与事件同时回滚");
    }
}

文档内属性映射

我们查询到,当只有一个属性的值对象时,可能我们希望用一个字段来存储,而不是对象的嵌套关系。最简单的方式就是添加 @Unwrapped 到值对象的字段上。我们注意力放在email字段上,看有什么区别。

  {
    _id: '673d7a9322ea6409d2113a41',
    customerId: { _id: 'a8ba625d-ebc3-4d6c-989b-ebfef5a69331' },
    email: 'pengyuanbing@iokays.com',
    fullName: { firstName: '孙', lastName: '悟空' },
    gender: 'MALE',
    registeredAt: ISODate('2024-11-20T05:58:43.621Z'),
    concurrencyVersion: 1,
    _class: 'com.iokays.sample.domain.customer.Customer'
  }

这是直接映射,没有修改字段名,但是如果我们希望customerId也可以这样映射,但是希望字段名是customerId,而不是id字段。我们需要怎么处理呢。 我们需要提供两个转换器(读,写).

package com.iokays.common.domain.mongodb;

import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.WritingConverter;

@WritingConverter
public class AbstractIdConverter implements Converter<AbstractId, String> {

    @Override
    public String convert(AbstractId source) {
        return source.id();
    }
}

因为业务标识主键都继承这个抽象类(AbstractId),所以只需要定义一个转换器就可以了。但是在读操作的时候,需要显式提供子类的转换器。

然后再定义一个MongoDB的配置类,来注册转换器。

//package com.iokays;
//
//import com.iokays.common.domain.mongodb.AbstractIdConverter;
//import org.springframework.context.annotation.Bean;
//import org.springframework.context.annotation.Configuration;
//import org.springframework.data.mongodb.core.convert.MongoCustomConversions;
//
//@Configuration
//public class MongoConfig {
//
//    @Bean
//    public MongoCustomConversions mongoCustomConversions() {
//        return MongoCustomConversions.create(v -> v.registerConverter(new AbstractIdConverter()));
//    }
//}

这是一个全局的配置,但我比较倾向使用注解调用适配器的方式来处理这个问题。 所以我们利用 PropertyValueConverter 来处理。

同样我们定义一个抽象类基于 AbstractId 的转换器。s

package com.iokays.common.domain.mongodb;

import org.springframework.data.convert.PropertyValueConverter;
import org.springframework.data.mongodb.core.convert.MongoConversionContext;

public abstract class AbstractIdPropertyValueConverter<T extends AbstractId> implements PropertyValueConverter<T, String, MongoConversionContext> {
    @Override
    public final String write(T value, MongoConversionContext context) {
        return value.id();
    }

    @Override
    public final T read(String value, MongoConversionContext context) {
        return create(value);
    }

    protected abstract T create(String id);

}

这个抽象类,实现了怎么写,但是读是到具体的子类,我们利用抽象工厂方法模式来实现。在需要的地方实现它。

package com.iokays.sample.domain.customer;

import com.iokays.common.domain.mongodb.AbstractIdPropertyValueConverter;

public class CustomerIdConverter extends AbstractIdPropertyValueConverter<CustomerId> {

    @Override
    protected CustomerId create(String id) {
        return null != id ? new CustomerId(id) : null;
    }
}

然后在指定的字段添加@PropertyValueConverter注解。 @ValueConverter(CustomerIdConverter.class) 即可.

文档之间映射

同一个集合的对象字段映射的基本用法先介绍到这里,基于关联另一个集合的映射,直接使用 @DBRef@DocumentReference 来处理.

首先我们来介绍下 @DBRef, 我们先定义了两个集合。

package com.iokays.sample.domain.person;

import com.iokays.common.domain.mongodb.AbstractAggregateRoot;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.DBRef;
import org.springframework.data.mongodb.core.mapping.Document;

import java.util.List;

@Document
public class Person extends AbstractAggregateRoot<Person> {

    @Indexed
    private Integer ssn;

    @DBRef
    private List<Account> accounts;

    protected Person() {
        super();
    }

    public Person(Integer ssn, List<Account> accounts) {
        this();
        this.ssn = ssn;
        this.accounts = accounts;
    }

    @Override
    public boolean sameIdentityAs(Person other) {
        return this.ssn.equals(other.ssn);
    }

}
package com.iokays.sample.domain.person;

import com.iokays.common.domain.mongodb.IdentifiedValueObject;
import org.springframework.data.mongodb.core.mapping.Document;

@Document
public class Account extends IdentifiedValueObject<Account> {

    private Float total;

    protected Account() {
        super();
    }

    public Account(Float total) {
        this();
        this.total = total;
    }

    @Override
    public boolean sameValueAs(Account other) {
        return total.equals(other.total);
    }
}

在Mongodb中的数据结构如下:

 {
    _id: '673dac34c6afa520422ecd01',
    ssn: 100,
    accounts: [
      DBRef('account', '673dac34c6afa520422eccff'),
      DBRef('account', '673dac34c6afa520422ecd00')
    ],
    concurrencyVersion: 1,
    _class: 'com.iokays.sample.domain.person.Person'
 }

{
  _id: ObjectId('673dac34c6afa520422eccff'),
  total: 100,
  _class: 'com.iokays.sample.domain.person.Account'
},
{
  _id: ObjectId('673dac34c6afa520422ecd00'),
  total: 101,
  _class: 'com.iokays.sample.domain.person.Account'
}

@DocumentReference 更灵活,它们可以是任何东西,单个值、整个文档,其映射关系是使用注解上这个属性 lookup 来实现:基本上可以存储在 MongoDB 中的任何东西。更多可以参考 https://www.iokays.com/spring-data-mongodb/mongodb/mapping/document-references.html 来了解.

未完待续…​

领域驱动设计(DDD)之领域层与Spring Data MongoDB融合实践讲完了,但基于Reactor的实现没有介绍, 我们也了解到以前介绍的Spring Data, 所以对比Spring Data JPA 发现, 其实有很多概念重叠,但是也存在一些差异。 下篇将介绍 Kotlin与Java项目架构的融合实践践