领域事件
领域事件是「某个状态已经变了」的通知。它由聚合根在状态变更时产生 —— 谁改了状态,谁负责发通知,而不是在 Service 层手动 new 一个。它也不是技术日志:订阅方拿到事件后要按 eventType 决定做什么。
定义一个领域事件
继承 AbstractDomainEvent:
package com.example.demo.domain.producer.event;
import com.example.demo.domain.producer.Producer;
import com.hibuka.soda.domain.event.AbstractDomainEvent;
import lombok.Data;
@Data
public class ProducerEvent extends AbstractDomainEvent {
private Producer producer;
// Jackson 反序列化用的默认构造
public ProducerEvent() {
super();
}
public ProducerEvent(String eventType, Producer producer) {
setEventType(eventType);
setProducer(producer);
setEventId(BuKaUid.genEventUid());
}
}AbstractDomainEvent 已经处理好的字段:
| 字段 | 类型 | 说明 |
|---|---|---|
eventId | String | 构造时自动生成,eid- 前缀的雪花 ID |
eventType | String | 事件类型标识,由你设置 |
occurredOn | LocalDateTime | 构造时自动填 LocalDateTime.now() |
requestId | String | 链路追踪 ID |
callerUid | String | 调用方 UID |
userName | String | 用户名 |
authorities | String | 权限串 |
jti | String | JWT token id |
hopCount | Integer | 异步递归保护的跳数 |
AbstractDomainEvent 的默认构造里会生成一个 eventId(new ScodaSnowflake(1L, 1L).genEventUid())。如果你在业务构造器里又 setEventId(...) 覆盖了一次(像上面的例子),那最终生效的是你设的那个。两种都可以,只要保证 ID 全局唯一。
DomainEvent 接口上还带了一个 Jackson 注解:
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class")
public interface DomainEvent {
String getEventId();
LocalDateTime getOccurredOn();
String getEventType();
}@class 属性让 JSON 里带上具体类型,跨服务反序列化时才能还原成正确的子类。
用 eventType 枚举管理事件类型
不要用类名区分事件类型。用一个实现 CodeEnum 的枚举集中管理:
package com.example.demo.domain.enums;
import com.hibuka.soda.foundation.enums.CodeEnum;
import lombok.Getter;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Getter
public enum ProducerEventTypeEnum implements CodeEnum<Integer> {
/** 创建 PRODUCER */
CREATE_PRODUCER(1, "create_producer"),
/** 创建 CONSUMER */
CREATE_CONSUMER(0, "create_consumer"),
/** 未知 */
UNKNONW(-1, "unknown");
private final Integer code;
private final String desc;
ProducerEventTypeEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
private static final Map<Integer, ProducerEventTypeEnum> CODE_MAP =
Arrays.stream(values()).collect(Collectors.toUnmodifiableMap(
ProducerEventTypeEnum::getCode,
e -> e,
(a, b) -> { throw new IllegalStateException("duplicate code: " + a.getCode()); }
));
private static final Map<String, ProducerEventTypeEnum> DESC_MAP =
Arrays.stream(values()).collect(Collectors.toUnmodifiableMap(
ProducerEventTypeEnum::getDesc,
e -> e,
(a, b) -> { throw new IllegalStateException("duplicate description: " + a.getDesc()); }
));
public static Optional<ProducerEventTypeEnum> fromCode(Integer code) {
return Optional.ofNullable(CODE_MAP.get(code));
}
public static Optional<ProducerEventTypeEnum> fromDescription(String description) {
return Optional.ofNullable(DESC_MAP.get(description));
}
public static ProducerEventTypeEnum fromCodeOrUnknown(Integer code) {
ProducerEventTypeEnum v = CODE_MAP.get(code);
return v != null ? v : UNKNONW;
}
public static ProducerEventTypeEnum fromDescriptionOrUnknown(String description) {
ProducerEventTypeEnum v = DESC_MAP.get(description);
return v != null ? v : UNKNONW;
}
}CodeEnum<T> 定义在 com.hibuka.soda.foundation.enums:
public interface CodeEnum<T> {
T getCode();
String getDesc();
static <T, E extends Enum<?> & CodeEnum<T>> List<E> getAllEnumValues(Class<E> enumClass) { ... }
}它只要求两个方法:getCode() 返回业务编码,getDesc() 返回描述。getAllEnumValues(Class) 是静态方法,可以一次拿到枚举的所有值。
配套的 EnumUtils 提供按 code 反查:
public class EnumUtils {
public static <T, E extends Enum<?> & CodeEnum<T>> E getByCode(Class<E> enumClass, T code) { ... }
public static <T, E extends Enum<?> & CodeEnum<T>> Optional<E> getOptionalByCode(Class<E> enumClass, T code) { ... }
}getByCode 找不到时返回 null,getOptionalByCode 返回 Optional。
desc 用小写下划线(create_producer)而不是驼峰或大写,因为 eventType 会作为字符串写进事件载荷和日志,小写下划线在跨服务场景里更通用。
聚合根产生事件
事件由聚合根在业务方法里挂起:
@Data
public class Producer extends AbstractAggregateRoot {
private String uid;
private String name;
public boolean create(String name) {
this.uid = BuKaUid.genDomainUid();
this.name = name;
addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));
return true;
}
}addPendingEvent 有三个重要性质:
- 它是
protected的 —— 只有聚合内部能调用,外部无法往聚合里塞事件。 - 它是挂起而不是立即发布 —— 事件先放进
AbstractAggregateRoot内部的列表。 - 挂起时会自动注入上下文 —— 从
DomainEventContext(ThreadLocal)里取requestId、userName、jti、authorities、callerUid,为事件里还为 null 的字段补上。
AbstractAggregateRoot 同时实现了 DomainEvents:
public abstract class AbstractAggregateRoot extends AbstractDomainEvent implements DomainEvents {
private final List<AbstractDomainEvent> domainEvents = new ArrayList<>();
protected void addPendingEvent(AbstractDomainEvent event) { ... }
@Override
public Collection<AbstractDomainEvent> getDomainEvents() {
return Collections.unmodifiableList(domainEvents);
}
@Override
public void clearDomainEvents() {
domainEvents.clear();
}
}getDomainEvents() 返回的是不可修改视图 —— 外部能看能遍历,但改不了列表。
事件什么时候发布
这是 soda 里最关键的一个问题:事件在事务提交之后才发布,不是保存的瞬间。
流程是这样的:
CommandHandler.handle(command)
├─ 聚合根.create(...) → addPendingEvent(...) ← 事件挂起,还没发布
├─ writeRepository.save(domain) ← 切面在这里拦截
│ ├─ 执行真正的 save
│ ├─ 取走 domain.getDomainEvents() 并清空
│ ├─ 补全上下文(CommandContext 优先,再补 DomainEventContext)
│ └─ 如果有事务 → 注册 afterCommit 回调;没有 → 立即发布
└─ 返回
↓
事务提交成功
↓
afterCommit 回调触发
↓
eventBus.publish(event)拦截这一切的是 RepositoryEventAspect,切点写法是:
execution(* *..*Repository+.save(..)) ||
execution(* *..*Repository+.update(..)) ||
execution(* *..*Repository+.delete(..)) ||
execution(* *..*Repository+.operate(..))这个切点匹配的是名字以 Repository 结尾的类型,以及它的子类型(+ 表示含子类型),而不是某个特定接口。所以:
- 仓储接口叫
ProducerWriteRepository就够了 —— 实现类叫什么都行,ProducerRepoImpl也能拦到。 - 反过来,任何以
Repository结尾、且有save/update/delete/operate方法的类都会被拦截。参数第一个是AbstractAggregateRoot子类时才会真正处理事件,否则跳过。 - 用 Spring Data JPA 的话,
SimpleJpaRepository这个名字本身就以Repository结尾,会被切面看到 —— 但它的参数是实体,不是AbstractAggregateRoot,所以只是匹配上、处理时跳过,没有副作用。 - 真正会丢事件的是这种情况:接口和实现类的名字里都没有
Repository(比如ProducerStore)。切点不命中,事件静默消失。
具体机制见 事件总线与切面。
写 EventHandler
EventHandler 的接口是 EventHandler<T extends DomainEvent>:
package com.example.demo.application.cqrs.handler.event;
import com.example.demo.domain.consumer.event.ConsumerEvent;
import com.hibuka.soda.cqrs.event.EventHandler;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.foundation.io.ParamChecker;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class ConsumerEventHandler implements EventHandler<ConsumerEvent> {
@Override
public void handle(ConsumerEvent event) throws BaseException {
eventValidate(event);
// 后续动作:发消息、发通知、写读库
}
private void eventValidate(ConsumerEvent event) throws BaseException {
ParamChecker.notNull(event, "event");
ParamChecker.notNull(event.getConsumer(), "consumer");
}
}EventHandler 的 handle 返回 void —— EventHandler 不返回结果,结果通过副作用体现。
EventHandler 是自动注册的。总线在启动时扫描 List<EventHandler<? extends DomainEvent>>,用 GenericTypeResolver.resolveTypeArguments 解析出每个 EventHandler 对应的事件类型,建立索引。你不需要写任何注册代码。
同一个事件多个 Handler
可以。SimpleEventBus 内部是 Map<Class<? extends DomainEvent>, List<EventHandler>>,一个事件类型对应一个 EventHandler 列表。
按事件类型分支
EventHandler 收到的是同一个事件类,用 eventType 区分:
@Component
@RequiredArgsConstructor
public class ProducerEventHandler implements EventHandler<ProducerEvent> {
private final BusFacade busFacade;
@Override
public void handle(ProducerEvent event) throws BaseException {
eventValidate(event);
if (ProducerEventTypeEnum.CREATE_CONSUMER.getDesc().equals(event.getEventType())) {
logger.info("聚合1:{} 的事件,处理成功1", event.getProducer().getUid());
} else if (ProducerEventTypeEnum.CREATE_PRODUCER.getDesc().equals(event.getEventType())) {
logger.info("聚合1:{} 的事件,处理成功", event.getProducer().getUid());
}
}
}如果同一个事件的不同分支逻辑差别很大,更好的做法是拆成多个事件类,每个事件一个 EventHandler,而不是在同一个 EventHandler 里写一长串 if。事件类型枚举适合「同一次状态变化的多个侧面」,独立的变化应该用独立的事件类。
事件 → 命令:链路继续往下走
EventHandler 里可以发新命令,soda 会自动把上下文带过去:
@Override
public void handle(ProducerEvent event) throws BaseException {
CreateConsumerCommand command = new CreateConsumerCommand();
command.setName(event.getProducer().getName());
busFacade.sendCommand(command);
}CqrsAroundHandler 在拦截 CommandBus.send 时会发现新命令的 requestId 是 null,于是从 DomainEventContext 里取出来补上。requestId、userName、jti、authorities 全部延续同一条链路。
事件触发命令、命令又产生事件,如果不小心形成环,会无限循环。soda 用 hopCount 保护:超过 20 跳时抛 IllegalStateException("Async recursion too deep! hopCount=N")。详见 递归保护。
进程内投递 vs 跨服务投递
soda 有两种事件总线实现,接口完全一样(EventBus):
| 实现 | 类 | 何时生效 | 范围 |
|---|---|---|---|
| Simple | SimpleEventBus | soda.event.bus-type 未配置或为 simple | 单进程 |
| Redis Streams | RedisStreamEventBus | soda.event.bus-type=redis | 跨服务 |
用 Redis 总线时,事件会被序列化后写进 Redis Stream,消费组拉取后反序列化并投递给本地的 EventHandler。序列化的抽象是 com.hibuka.soda.event.EventSerializer,默认实现 JacksonEventSerializer。
soda.event.bus-type 的可选值在文档里常写成 spring / simple / redis。但当前版本里 soda-event-spring 模块已经被移除,代码里没有 Spring 事件总线的实现类 —— EventProperties 里 busType 的默认值虽然仍是 spring,但 SimpleEventBusAutoConfiguration 的条件是 havingValue = "simple", matchIfMissing = true,也就是不配置时走 Simple,配置成 spring 时反而没有实现类。没有 EventBus Bean 时,依赖它的 CqrsAroundHandler 会让应用启动失败。生产环境请用 simple 或 redis。
一个完整例子
领域事件:
@Data
public class ProducerEvent extends AbstractDomainEvent {
private Producer producer;
}聚合根:
public boolean create(String name) {
this.uid = BuKaUid.genDomainUid();
this.name = name;
addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));
return true;
}EventHandler:
@Component
public class ProducerEventHandler implements EventHandler<ProducerEvent> {
@Override
public void handle(ProducerEvent event) throws BaseException {
ParamChecker.notNull(event.getProducer(), "producer");
logger.info("producer created: {}", event.getProducer().getUid());
}
}这条链路自动打通了 —— 你没有写任何发布事件的代码,RepositoryEventAspect 在 save 之后替你做了。