soda docs v17.0.1

五分钟跑通

不手写代码。 先去生成器生成一个能直接跑的工程 —— H2 内存库、建表脚本、完整的 DDD 示例代码全都带好。你只负责 mvn spring-boot:run,然后在浏览器里看到它工作。

跑通之后再回头看 核心概念 和 写一个命令,会快得多。

第一步:生成工程

打开 https://start.soda.hibuka.com,选模板,填三项,下载 zip。

填什么说明例子
项目名Maven 的 artifactIdmy-soda-project
包名Java 包名,后面所有代码都在它下面com.example.soda
Spring Boot 版本4.0 或 3.2,按你现有的技术栈选4.0

模板选 SODA Simple(单体架构)。需要多模块拆分的选 scoda-cloud。

生成的工程里已经包含:

  • 一套完整的 DDD 分层目录(adapter / application / domain / infrastructure)
  • 一个「厂商(Producer)」聚合根的完整 CRUD:命令、查询、事件、仓储
  • H2 内存库配置 + 建表脚本 schema-h2.sql
  • MyBatis-Plus、Knife4j(Swagger)、统一异常处理

第二步:直接跑

bash
unzip my-soda-project.zip
cd my-soda-project
mvn spring-boot:run

不需要装 MySQL,不需要装 Redis。 启动完成看这行:

text
Tomcat started on port 8080

生成的工程默认就是「开箱能跑」的姿态:

配置值为什么
端口8080application.yaml
Profiledev默认激活
数据源jdbc:h2:mem:testdb内存库,进程内
建表classpath:schema-h2.sql 自动执行每次启动重建
事件总线不配置 → SimpleEventBus进程内派发,不碰 Redis
技巧

H2 是内存库:停掉进程,数据就没了。这对"跑通"是优点(每次启动都是干净环境),对"演示持久化"是缺点。要换成 MySQL 只改 application-dev.yaml 的 datasource,Java 代码一行不动。

第三步:调四个接口

示例给了一组能覆盖四条链路的接口,全部是 POST:

接口走哪条链路请求体
/api/creatProducer命令(同步){"name": "华东电子"}
/api/creatProducerSync命令(异步){"name": "华东电子"}
/api/getProducer查询(单体){"uid": "上一步返回的 uid"}
/api/listProducers查询(分页){"pageNumber": 1, "pageSize": 10}

创建一条:

bash
curl -X POST http://localhost:8080/api/creatProducer \
  -H 'Content-Type: application/json' \
  -d '{"name": "华东电子"}'

返回:

json
{
  "code": 200,
  "message": "",
  "data": {
    "uid": "a1b2c3d4-...",
    "name": "华东电子"
  },
  "success": true
}
提示

接口名里的 creat 少了个 e,是生成器模板的历史拼写,已经固定在示例代码里了。你自己的业务接口按规范写 create 就行 —— 这只是模板里的示例,不是框架约束。

再查一次,确认真的存进去了:

bash
curl -X POST http://localhost:8080/api/listProducers \
  -H 'Content-Type: application/json' \
  -d '{"pageNumber": 1, "pageSize": 10}'

data 里应该能看到刚才那条记录。这一步是关键:命令走通了、写进了库、查询读出来了 —— 一条完整的 CQRS 闭环。

第四步:看数据库里到底有什么

H2 自带 Web 控制台,打开 http://localhost:8080/h2-console:

字段填什么
JDBC URLjdbc:h2:mem:testdb
User Namesa
Password留空

点 Connect,左侧就能看到 scoda_producer 和 scoda_consumer 两张表。执行:

sql
SELECT uid, name, gmt_create FROM scoda_producer;
技巧

表名带 scoda_ 前缀是模板的约定,不是框架强制的。schema-h2.sql 和 ScodaProducer 实体里的映射是一致的,改成你自己的表名两者都要改。

第五步:把代码读一遍 —— 一条命令怎么走完全程

这是这一页真正的重点。工程里的示例代码不是摆设,它把 soda 的每条链路都演了一遍。按下面这张表从上往下读,就能看懂一条命令的完整生命周期。

顺序文件在链路里的角色
1adapter/inbound/web/SampleController.javaHTTP 入口,把请求体直接转成 Command
2application/sample/service/impl/SampleAppServiceImpl.java应用服务,调 BusFacade 交出去
3bus/facade/BusFacade(框架)把命令投进总线,找 CommandHandler
4application/cqrs/handler/command/CreateProducerCommandHandler.java编排:校验 → 装配聚合根 → 存仓储
5domain/producer/Producer.java聚合根,改状态 + 挂领域事件
6domain/producer/repository/ProducerWriteRepository.java写仓储接口(领域层只依赖接口)
7infrastructure/repositoryImpl/producer/ProducerWriteRepositoryImpl.java实现,落到 MyBatis-Plus
8domain/producer/event/ProducerEvent.java领域事件,由聚合根产生
9application/cqrs/handler/evnet/ProducerEventHandler.java事件消费者,自动被调用

三个值得停下来看的地方

1. 事件是聚合根挂的,不是 Service 发的

Producer.create() 里这一行:

java
addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));

addPendingEvent 是 protected 的 —— 只有聚合根自己能调。Service 层想绕过聚合根直接发个事件,编译都过不了。这是「事件必须源于领域状态变化」的强制保证。

事件此时只是挂起,还没发布。什么时候发?仓储 save() 之后。切面 RepositoryEventAspect 会在保存成功(有事务则在提交后)把挂起的事件取出来派发。

2. 领域层不知道 MyBatis-Plus 存在

domain/producer/repository/ProducerWriteRepository.java 是个纯接口,继承 WriteRepository<Producer>:

java
public interface ProducerWriteRepository extends WriteRepository<Producer> {
}

WriteRepository 固定四个方法:save / update / delete / operate。CommandHandler 注入的是这个接口,不是 ProducerWriteRepositoryImpl。

实现类在 infrastructure 层,用 MyBatis-Plus 落库。换成 JPA、换成 Redis、换成内存 Map,领域层都不用改。

注意

仓储类型的名字里要有 Repository。 发布事件的切点是 *..*Repository+,匹配「名字以 Repository 结尾的类型 + 它的子类型」。接口叫 ProducerWriteRepository,实现类叫什么都会被拦到;但如果接口改成 ProducerStore、实现类叫 ProducerStoreImpl,切面就不认识它们了 —— 事件会静默地不再发布,不报错,只是没了。

3. Handler 不需要注册

ProducerEventHandler 上有 @Component,然后就没了 —— 没有 subscribe,没有注册表,没有 XML。框架启动时扫描所有 EventHandler,按泛型解析出它处理的事件类型并建索引。ProducerEvent 一发布,它自动被调用。

分层目录长什么样

text
src/main/java/com/example/soda/
├── adapter/inbound/web/         # 入口:Controller、DTO、组装器、异常处理
├── application/                 # 应用层:Command / Query / Handler / 应用服务
│   ├── cqrs/command/
│   ├── cqrs/query/
│   ├── cqrs/handler/command/
│   ├── cqrs/handler/query/
│   └── cqrs/handler/evnet/      # 事件消费者(模板里就是这个拼写)
├── domain/                      # 领域层:聚合根、事件、仓储接口
│   ├── producer/
│   ├── consumer/
│   └── enums/
└── infrastructure/              # 基础设施:DO、Mapper、仓储实现、外部客户端
    ├── acl/mysql/
    ├── acl/redis/
    ├── repositoryImpl/
    └── assembler/

依赖方向只有一个:adapter → application → domain,infrastructure 反过来实现 domain 的接口。domain 包里不出现任何 Spring / MyBatis / Redis 的 import。

第六步:改一行,看它动

在 ProducerEventHandler.handle() 里加一行日志:

java
@Override
public void handle(ProducerEvent event) throws BaseException {
    log.info("收到厂商事件:{},厂商:{}", event.getEventType(), event.getProducer().getName());
    eventValidate(event);
}

重启,再调一次 /api/creatProducer。控制台会打印这条 —— 说明事件从聚合根出发,穿过仓储,穿过总线,到了消费者手里。

第七步:换成生产形态

示例跑在 H2 + 进程内总线上。上生产要换两件事:

换什么改哪里
H2 → MySQLapplication-dev.yaml 的 spring.datasource,另加 schema.sql 建表
进程内事件 → Redis Streamssoda.event.bus-type: redis + Redis 连接信息
yaml
soda:
  event:
    bus-type: redis
    redis:
      host: ${SODA_REDIS_HOST}
      password: ${SODA_REDIS_PASSWORD}
      stream:
        idempotency:
          enabled: true
重要

一定要显式写 bus-type。不写虽然能跑(回落到进程内总线),但意图不明确;写成 spring 则一个 EventBus 都不会创建,应用启动时直接报 NoSuchBeanDefinitionException。详见 配置项。

你已经跑通了什么

到这为止,一个完整的 soda 应用已经有了:

  • 聚合根保护状态变更,事件由它产生
  • 命令走写链路(同步 + 异步),查询走读链路(单体 + 分页)
  • 保存时事件自动发布,消费者自动收到
  • 统一返回结构 Result / PageResult
  • 领域层对基础设施零依赖

下一步: