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 来了解.