五分钟跑通
不手写代码。 先去生成器生成一个能直接跑的工程 —— H2 内存库、建表脚本、完整的 DDD 示例代码全都带好。你只负责 mvn spring-boot:run,然后在浏览器里看到它工作。
第一步:生成工程
打开 https://start.soda.hibuka.com,选模板,填三项,下载 zip。
| 填什么 | 说明 | 例子 |
|---|---|---|
| 项目名 | Maven 的 artifactId | my-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)、统一异常处理
第二步:直接跑
unzip my-soda-project.zip
cd my-soda-project
mvn spring-boot:run不需要装 MySQL,不需要装 Redis。 启动完成看这行:
Tomcat started on port 8080生成的工程默认就是「开箱能跑」的姿态:
| 配置 | 值 | 为什么 |
|---|---|---|
| 端口 | 8080 | application.yaml |
| Profile | dev | 默认激活 |
| 数据源 | 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} |
创建一条:
curl -X POST http://localhost:8080/api/creatProducer \
-H 'Content-Type: application/json' \
-d '{"name": "华东电子"}'返回:
{
"code": 200,
"message": "",
"data": {
"uid": "a1b2c3d4-...",
"name": "华东电子"
},
"success": true
}接口名里的 creat 少了个 e,是生成器模板的历史拼写,已经固定在示例代码里了。你自己的业务接口按规范写 create 就行 —— 这只是模板里的示例,不是框架约束。
再查一次,确认真的存进去了:
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 URL | jdbc:h2:mem:testdb |
| User Name | sa |
| Password | 留空 |
点 Connect,左侧就能看到 scoda_producer 和 scoda_consumer 两张表。执行:
SELECT uid, name, gmt_create FROM scoda_producer;表名带 scoda_ 前缀是模板的约定,不是框架强制的。schema-h2.sql 和 ScodaProducer 实体里的映射是一致的,改成你自己的表名两者都要改。
第五步:把代码读一遍 —— 一条命令怎么走完全程
这是这一页真正的重点。工程里的示例代码不是摆设,它把 soda 的每条链路都演了一遍。按下面这张表从上往下读,就能看懂一条命令的完整生命周期。
| 顺序 | 文件 | 在链路里的角色 |
|---|---|---|
| 1 | adapter/inbound/web/SampleController.java | HTTP 入口,把请求体直接转成 Command |
| 2 | application/sample/service/impl/SampleAppServiceImpl.java | 应用服务,调 BusFacade 交出去 |
| 3 | bus/facade/BusFacade(框架) | 把命令投进总线,找 CommandHandler |
| 4 | application/cqrs/handler/command/CreateProducerCommandHandler.java | 编排:校验 → 装配聚合根 → 存仓储 |
| 5 | domain/producer/Producer.java | 聚合根,改状态 + 挂领域事件 |
| 6 | domain/producer/repository/ProducerWriteRepository.java | 写仓储接口(领域层只依赖接口) |
| 7 | infrastructure/repositoryImpl/producer/ProducerWriteRepositoryImpl.java | 实现,落到 MyBatis-Plus |
| 8 | domain/producer/event/ProducerEvent.java | 领域事件,由聚合根产生 |
| 9 | application/cqrs/handler/evnet/ProducerEventHandler.java | 事件消费者,自动被调用 |
三个值得停下来看的地方
1. 事件是聚合根挂的,不是 Service 发的
Producer.create() 里这一行:
addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));addPendingEvent 是 protected 的 —— 只有聚合根自己能调。Service 层想绕过聚合根直接发个事件,编译都过不了。这是「事件必须源于领域状态变化」的强制保证。
事件此时只是挂起,还没发布。什么时候发?仓储 save() 之后。切面 RepositoryEventAspect 会在保存成功(有事务则在提交后)把挂起的事件取出来派发。
2. 领域层不知道 MyBatis-Plus 存在
domain/producer/repository/ProducerWriteRepository.java 是个纯接口,继承 WriteRepository<Producer>:
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 一发布,它自动被调用。
分层目录长什么样
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() 里加一行日志:
@Override
public void handle(ProducerEvent event) throws BaseException {
log.info("收到厂商事件:{},厂商:{}", event.getEventType(), event.getProducer().getName());
eventValidate(event);
}重启,再调一次 /api/creatProducer。控制台会打印这条 —— 说明事件从聚合根出发,穿过仓储,穿过总线,到了消费者手里。
第七步:换成生产形态
示例跑在 H2 + 进程内总线上。上生产要换两件事:
| 换什么 | 改哪里 |
|---|---|
| H2 → MySQL | application-dev.yaml 的 spring.datasource,另加 schema.sql 建表 |
| 进程内事件 → Redis Streams | soda.event.bus-type: redis + Redis 连接信息 |
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 - 领域层对基础设施零依赖
下一步: