soda docs v17.0.1

领域事件

领域事件是「某个状态已经变了」的通知。它由聚合根在状态变更时产生 —— 谁改了状态,谁负责发通知,而不是在 Service 层手动 new 一个。它也不是技术日志:订阅方拿到事件后要按 eventType 决定做什么。

定义一个领域事件

继承 AbstractDomainEvent:

java
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 已经处理好的字段:

字段类型说明
eventIdString构造时自动生成,eid- 前缀的雪花 ID
eventTypeString事件类型标识,由你设置
occurredOnLocalDateTime构造时自动填 LocalDateTime.now()
requestIdString链路追踪 ID
callerUidString调用方 UID
userNameString用户名
authoritiesString权限串
jtiStringJWT token id
hopCountInteger异步递归保护的跳数
提示

AbstractDomainEvent 的默认构造里会生成一个 eventId(new ScodaSnowflake(1L, 1L).genEventUid())。如果你在业务构造器里又 setEventId(...) 覆盖了一次(像上面的例子),那最终生效的是你设的那个。两种都可以,只要保证 ID 全局唯一。

DomainEvent 接口上还带了一个 Jackson 注解:

java
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class")
public interface DomainEvent {
    String getEventId();
    LocalDateTime getOccurredOn();
    String getEventType();
}

@class 属性让 JSON 里带上具体类型,跨服务反序列化时才能还原成正确的子类。

用 eventType 枚举管理事件类型

不要用类名区分事件类型。用一个实现 CodeEnum 的枚举集中管理:

java
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:

java
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 反查:

java
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 会作为字符串写进事件载荷和日志,小写下划线在跨服务场景里更通用。

聚合根产生事件

事件由聚合根在业务方法里挂起:

java
@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:

java
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 里最关键的一个问题:事件在事务提交之后才发布,不是保存的瞬间。

流程是这样的:

text
CommandHandler.handle(command)
  ├─ 聚合根.create(...) → addPendingEvent(...)   ← 事件挂起,还没发布
  ├─ writeRepository.save(domain)                  ← 切面在这里拦截
  │    ├─ 执行真正的 save
  │    ├─ 取走 domain.getDomainEvents() 并清空
  │    ├─ 补全上下文(CommandContext 优先,再补 DomainEventContext)
  │    └─ 如果有事务 → 注册 afterCommit 回调;没有 → 立即发布
  └─ 返回
                                          ↓
                              事务提交成功
                                          ↓
                              afterCommit 回调触发
                                          ↓
                              eventBus.publish(event)

拦截这一切的是 RepositoryEventAspect,切点写法是:

text
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>:

java
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 区分:

java
@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 会自动把上下文带过去:

java
@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):

实现类何时生效范围
SimpleSimpleEventBussoda.event.bus-type 未配置或为 simple单进程
Redis StreamsRedisStreamEventBussoda.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。

一个完整例子

领域事件:

java
@Data
public class ProducerEvent extends AbstractDomainEvent {
    private Producer producer;
}

聚合根:

java
public boolean create(String name) {
    this.uid = BuKaUid.genDomainUid();
    this.name = name;
    addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));
    return true;
}

EventHandler:

java
@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 之后替你做了。

下一步