# milky **Repository Path**: geran/milky ## Basic Information - **Project Name**: milky - **Description**: a simple ddd framework - **Primary Language**: Java - **License**: Apache-2.0 - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 7 - **Created**: 2026-07-29 - **Last Updated**: 2026-07-29 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Milky 轻量级 **DDD + CQRS** Java 框架。领域对象通过 **命令总线** 表达业务能力,**持久化由框架统一调度**,配合 **线程内事件驱动** 与 **内存事务**,减少面条式 Service + 多次 DAO 调用。 - **Java 8** · **Spring Boot 2.7** · Maven 多模块 - 发布至 [Maven Central](https://central.sonatype.com/)(`com.stellariver.milky`) - 完整示例见 `demo` 模块;脚本说明见 [scripts/README.md](scripts/README.md) ## 目录 - [项目介绍](#项目介绍) - [功能一览](#功能一览) - [模块结构](#模块结构) - [快速开始](#快速开始) - [运行 Demo 与测试](#运行-demo-与测试) - [版本与支持](#版本与支持) - [设计思想](#设计思想)(详细背景与 demo walkthrough) --- ## 项目介绍 本项目定位为一个基于 DDD + CQRS 的 Java 开箱即用式框架。2021 年底笔者接触了 [Axon](https://www.axoniq.io/) 框架,Axon 深度拥抱 DDD/CQRS,但其推荐 JPA 在国内使用较少,且深度封装导致上手有一定困难。Milky 在其思想启发下,做了更轻量的实现,现已应用于多个生产项目。 **核心特点:** - 通过领域能力封装防止面条代码:所有领域对象方法经 **命令总线** 路由,每个方法代表一项领域能力,模型设计与代码实现强一致 - **持久化由框架调用**,业务代码专注领域逻辑,不写 DAO 调用 - **线程内事件驱动**(观察者模式),适应频繁变更;`@FinalEventRouter` 应对「先成功后发消息」类场景 - **内存事务**:单次请求内多次聚合变更先缓存在 Context,末尾统一落库,对开发者透明 - **聚合根与 DO 多对多映射**,方便限界上下文设计 - **限流 / 熔断**开箱即用,兼容 Apollo、Nacos 等配置中心动态刷新 --- ## 功能一览 ### 领域层(`domain-support`) | 能力 | 说明 | |------|------| | `CommandBus` | 命令路由;`accept` / `driveByEvent`;批量命令;`Typed` 上下文参数 | | 聚合根 | `@CommandHandler` / `@ConstructorHandler` / `@DeleteHandler` | | 持久化解耦 | `DaoAdapter` + `DAOWrapper` + MapStruct **merge** 更新(null 字段保留 DB 原值) | | 内存事务 | 请求内变更缓存于 `Context`,末尾统一持久化 + 事务提交 | | `EventBus` | `@EventRouter` 事件驱动命令;`@FinalEventRouter` 请求全部成功后再执行副作用 | | 拦截器 | `@Intercept` 命令执行前后横切 | | 并发 | `ConcurrentOperate` 可插拔锁(Starter 默认 JVM 锁;`infrastructure-base` 提供 DB 锁) | | 追踪 | `InvokeTrace` / `Trail` 记录一次 invocation 内的命令与拦截轨迹 | | `DomainTunnel` | 聚合根之间只读查询的抽象入口 | ### Spring 集成(`milky-spring-boot-starter` / `spring-partner`) | 能力 | 说明 | |------|------| | `@EnableMilky` | 扫描并注册 Milky 组件(DaoAdapter、EventRouters、Interceptors 等) | | `@StaticWire` | 类似 `@Autowired`,用于 **static 字段**注入接口实现 | | `UniqueIdBuilder` | DB 号段式分布式 ID(预取号段) | | HTTP RPC Bridge | 声明式 HTTP 远程调用(`RpcHttpProxyGroup` / `RpcHttpProxyEntry`) | | `milky.*` 配置 | 线程池、TraceId key、扫描包等(`MilkProperties`) | ### 横切与稳定性(`aspectj-tool` / `common-tool`) | 注解 / 组件 | 说明 | |-------------|------| | `@Validate` | 方法入参 JSR-303 校验 | | `@Log` | 方法日志 | | `@RateLimit` | 方法级限流 | | `@TLC` | 为指定 `BaseQuery` 开启线程级缓存 | | `EntranceAspect` | 入口切面基类,可挂自定义 `AnnotationInterceptor` | | `MilkyStableSupport` | Guava 限流 + Resilience4j 熔断,支持配置中心动态刷新 | | `StateMachine` | 轻量状态机 | | `BaseQuery` | 带线程级 / barrier 缓存的 Repository 查询抽象 | | `EnhancedExecutor` | 增强线程池(ThreadLocal 传递、异步异常日志) | | `SLambda` | 序列化 Lambda 元数据(字段名引用) | ### 基础设施(`infrastructure-base`) | 能力 | 说明 | |------|------| | `MilkyMapper` | MyBatis-Plus 增强:乐观锁 `tryUpdate` / `updateOrThrow`、幂等逻辑删除、游标分批遍历 | | TypeHandler | JSON / List JSON / Map JSON / 可空枚举 / 全文等 | | `LockMapper` | 基于数据库的分布式锁 | | 插件 | `SqlLogFilter`、`DeepPageFilter`、`RateLimiterInnerInterceptor` 等 | ### 通用基础(`common-base`) `Result` / `PageResult`、统一异常(`BizEx` / `SysEx` + `ErrorEnum`)、分页查询基类、校验注解(`@Divisible`、`@ExactDivision`、`@OfEnum` 等)、`TraceIdContext`、枚举序列化等。 ### Demo 覆盖场景(`demo`) - 电商 **Item + Inventory** 限界上下文(创建商品 → 事件驱动创建库存) - `@FinalEventRouter` 延迟发 MQ - 多聚合根映射同一 DO(`CombineItem`) - 内存事务回滚(`MemoryTxTest`) - HTTP RPC Bridge(`test/remote_bridge` 套件) - AspectJ 校验 / 日志 / 入口拦截(`RpcAspect`) - SSE 延迟消息、FTP 配置等示例 --- ## 模块结构 ``` milky/ ├── common-base # 基础类型、校验注解、统一响应 ├── common-tool # 工具库、状态机、稳定性、BaseQuery ├── aspectj-tool # AspectJ 切面(Validate / Log / RateLimit / TLC) ├── domain-support # DDD/CQRS 核心(CommandBus、EventBus) ├── infrastructure-base # MyBatis-Plus 增强、DB 锁、TypeHandler ├── spring-partner # Spring 扩展(StaticWire、号段 ID、HTTP Bridge) ├── milky-spring-boot-starter ├── milky-dependencies # BOM ├── demo # 示例应用 └── test/ # jtest 端到端测试(Python) ``` --- ## 快速开始 ### Maven 依赖 推荐使用 BOM 统一版本(当前 `${revision}` 见根 `pom.xml`,如 `0.3.8-SNAPSHOT`): ```xml com.stellariver.milky milky-dependencies 0.3.8-SNAPSHOT pom import com.stellariver.milky milky-spring-boot-starter ``` ### 启用框架 ```java @SpringBootApplication @EnableMilky(scanPackages = "com.example.myapp") public class Application { } ``` ### 最小领域示例 ```java public class Item extends AggregateRoot { Long itemId; String title; @CommandHandler public void handle(ItemTitleUpdateCommand cmd, Context ctx) { this.title = cmd.getUpdateTitle(); ctx.publish(ItemTitleUpdatedEvent.builder().itemId(itemId).build()); } } // 应用层(配合 @Transactional 开启数据库事务) CommandBus.accept(command, parameters); ``` --- ## 运行 Demo 与测试 ### 单元 / 集成测试 需配置 `JAVA_HOME` 与 Maven(或使用 Maven Wrapper): ```shell mvn clean test ``` ```shell .\mvnw clean test ``` ### 端到端集成测试(jtest) 需 Python [uv](https://github.com/astral-sh/uv),首次在 `test/` 下执行 `uv sync`: ```powershell .\jtest.ps1 -kd # 编译 + install 内部模块 + 复制 demo/libs .\jtest.ps1 -a # 跑全部套件 .\jtest.ps1 item_update # 单个套件 ``` | 套件 | 说明 | |------|------| | `item_flow` | 更新标题后 GET 校验 | | `item_update` | 调用 `/item/update` | | `item_publish` | `/item/publish` 被 `RpcAspect` 拦截 | | `remote_bridge` | HTTP RPC Bridge | --- ## 版本与支持 | 产品线 | JDK | Spring Boot | 版本线 | 说明 | |--------|-----|-------------|--------|------| | 当前主线 | 8 | 2.7.x | `0.3.x` | 日常迭代 | | 规划中 | 17 | 3.x | `0.4.x` | 独立分支,Jakarta 迁移 | ## License Apache 2.0 --- ## 设计思想 > 以下为框架设计背景与 demo 详解,建议按需阅读。 ### 基本思想 两年前刚开始接触 Java 时,见到了最常见的代码结构:设计库表结构,构建 MyBatis 的 XML 和 Mapper 接口,然后写 Service 层,最后再搞一个 `@Controller` 或者 `@Dubbo` 之类的 Provider,典型三层架构。国内不少业务项目基本按照这个结构开发。其编程的核心是处理数据库,即用户的请求,最终落地为更改数据库,而编程的指导思想也是,这个请求过来,最终要怎么样改数据库。 ![三层模式](readme-resource/three-level.png) 其实对于一般的业务这种思路基本够用,Spring 本身所提供的 `@Controller`、`@Service`、`@Repository` 注解也似乎在暗中鼓励程序员按照这种模式编写相应程序。其实如果我们细细思考这种编程方法,你能意识到,这种编程范式,其实用 C 语言或者 JS 甚至 Visual Basic 都可以完成,不需要面向对象的语言,只需要面向过程语言即可。 所谓面向过程就是说,程序的组织结构是一段一段的代码,相互调用,中间用一些变量传递,核心是这个过程,以及这个过程的入参与出参。这是非常直观的思路,也是高级语言在发明阶段的主要模式。随着计算机行业的发展,上世纪 80 年代,以 C++ 和 Java 为代表的面向对象语言开始大行其道,并使用这些面向对象的语言构建了诸多大型软件系统,整个行业出现 OPP → OOP 的转变。 > OPP : Oriented Procedure Programming > OOP : Oriented Object Programming 那么什么是面向对象编程,以下解释节选自维基百科: > Object-oriented programming (OOP) is a programming paradigm based on the concept of "objects", which can contain ***data*** and ***code***. The data is in the form of fields (often known as ***attributes or properties***), and the code is ***in the form of procedures*** (often known as methods). 通过上述表述也可以看出,其本质要求是一份程序中的主要结构应该是一些有成员变量且有实例方法的对象。而此时你再看三层结构的代码,你会发现,其实这是典型的面向过程,所有的业务对象只是作为参数传递。 ![领域服务抽象](readme-resource/domain-service.png) 针对以上问题,业界有一种比较流行的基于 DDD 的解决方法,抽象出一个业务对象,并将一些 Service 层的代码移动到业务对象内部,业务对象的实例方法只能修改自己的成员变量,一些不能移动进去的方法(比如数据库读写)构成一个领域服务层,领域服务层进行从数据库读取领域对象,调用相应的业务对象实例方法,并完成持久化。 关于领域服务,我一直感觉这是一种无法自洽的东西,如果我写了领域服务的代码,这对于我来说,是一个疙瘩,是我无法定位的东西。 * ***持久层与业务层解耦*** 假设我们有一台理想电脑,内存无限大,且永不宕机,那么就没必要进行持久化了,所有的数据都在内存里面,所以持久化并不是一个程序中的业务内容,它只是我们应对不可理想化的电脑采取的主动规避手段,那这就可以推理出另外一个结果,即业务层应该是可以与持久化分离的。Milky 就实现了这个设计。 * ***奇怪的领域服务*** ***领域服务***,这种明显硬造出来的词,是一种极其不负责任的行为,它无法被感性的认识,也就无法被理解。所谓无法感性的认识就是:你无法解释这个东西,无法用自己过去的经历或者你生活中的某种具象的事情来类比。其实就是组织一下数据库读,调用一些领域对象的实例方法,写一下数据库。所以我要把这个领域服务去掉,两个部分需要去掉,第一是数据库的读写,第二个是由谁来调用领域对象的领域能力。 数据库的读写如何去掉,后文会详细解释。关于领域对象的方法调用。读书的时候,讲解面向对象原理的时候,经常性会举出一个例子,如果有一个 Dog 对象,应该是 Dog 对象本身有属性、皮毛颜色,同时 Dog 对象有方法,比如 `dog.wangwang()`。而一个狗莫名其妙并不会叫,什么情况它才会叫呢?当被别人踩了一脚的时候。那么平移到 CS/BS 结构中,应该是某个对象接受某个请求,发生了某种行为。所以应该是 `dog.listen("jumpCommand")`,然后做出相应行为。这样才实现了完整的面向对象,即一个一个业务对象在接受一个一个请求,做出相应反应。Milky 彻底去掉了领域服务。 * ***内部事件总线*** 有时候领域服务还会做这种事情,某个请求同时要更改两个表里面的两条关联的记录,DomainService 会同时组织对于这两个对象的读取存储以及相应的参数传递,有时候还要加事务。其实这种模式对于迭代很麻烦,因为如果某个变更,需要针对性的对某种特殊情况,特殊的修改第二个对象,这样就开始有 if else。另外一种情况是,可能有一些并不在主链路的需求,也会叠加进主链路的代码中,最后一个方法又臭又长。有经验的程序员会及早的应用观察者模式,采用事件传递,修改完第一个对象,发出一个事件,第一个对象并不关心谁消费这个事件,只要写一个观察器,针对性针对某个事件做出响应,这就隔离开了两个对象,如果有什么不在主链路的动作,也可以另外再写一个观察者。Milky 提供非常优雅的方法实现了这种观察者模式。 ### Milky demo 分析 福报厂是我开始大量写 Java 的公司,因为在电商业务线,所以也以电商为例做了一个简单 demo。在较大型的电商架构中,商品信息的存储肯定有一个中心化的存储机构,但是这个中心化的存储机构一般是不负责库存的管理的,因为库存管理太过复杂,一般单独一个部分专门负责库存。因为需要处理秒杀、供应链、仓储等一堆问题。 对于电商系统最基本的一个需求,就是卖家需要将商品信息录入系统,同时需要能修改商品信息,涨涨价、改改图之类的。根据刚才的说法,商品主信息和库存信息应该是分开存储的,且有两个团队维护,那么这里就可以抽象出两个业务对象,商品(Item)和库存(Inventory),然后我们简单列举需求: > 成员变量 | Item | Inventory | |------|-----------| | 商品 id | 商品 id | | 标题 | 仓 Code | | 库存 | 库存数量 | > 领域能力(实例方法) | Item | Inventory | |------|-----------| | 创建商品 | 创建库存 | | 修改标题 | 修改库存 | 现在我们设计好了这两个对象,已经可以开始写程序了。 #### 领域对象 Java 实现 ```Java public class Item extends AggregateRoot { Long itemId; String title; Long amount; @CommandHandler(dependencies = "userInfo") public Item(ItemCreateCommand command, Context context) { this.itemId = command.getItemId(); this.title = command.getTitle(); this.amount = command.getAmount(); UserInfo userInfo = TypedEnums.userInfo.extractFrom(context.getDependencies()); context.publish(ItemCreatedEvent.builder().itemId(itemId).title(title).build()); } @SneakyThrows @CommandHandler public Context handle(ItemTitleUpdateCommand command, Context context) { String originalTitle = this.title; this.title = command.getUpdateTitle(); ItemTitleUpdatedEvent event = ItemTitleUpdatedEvent.builder() .itemId(itemId).originalTitle(originalTitle).updatedTitle(title).build(); context.getMetaData().put(TypedEnums.markHandle, Clock.currentTimeMillis()); Thread.sleep(10L); context.publish(event); return context; } } ``` 以上代码清晰地表明了一个业务对象具有的属性和领域能力,即可以接受创建一个商品的请求,或者更新一个商品标题请求,而对象本身拥有 id、标题、库存数量这三个属性,而且相应的核心业务代码里面是没有从数据库读入或者写入数据库的动作。以下为库存对象的相应属性和能力,结构类似。 ```java public class Inventory extends AggregateRoot { private Long itemId; private Long amount; @Override public String getAggregateId() { return itemId.toString(); } @CommandHandler public Inventory(InventoryCreateCommand command, Context context) { this.itemId = command.getItemId(); this.amount = command.getInitAmount(); InventoryCreatedEvent event = InventoryCreatedEvent.builder() .itemId(itemId).initAmount(amount) .build(); context.publish(event); } @CommandHandler public void handleInventoryUpdateCommand(InventoryUpdateCommand command, Context context) { Long originalAmount = this.amount; this.amount = command.getUpdateAmount(); InventoryUpdateEvent event = InventoryUpdateEvent.builder().itemId(itemId) .originalAmount(originalAmount).updateAmount(amount).build(); context.publish(event); } } ``` Milky 要求所有业务对象继承 `AggregateRoot`,并实现 `getAggregateId()` 方法,一般情况下直接返回 `id.toString()` 即可,要求同一个类型的对象 id 不能重复。 #### 持久化实现 当然数据肯定还是要持久化的。Milky 的数据库读取与更新由框架完成,只需要针对相应对象实现对应接口,如下代码范例: ```Java public class ItemDAOAdapter implements DaoAdapter { @Override public Item toAggregate(@NonNull Object dataObject) { ItemDO itemDO = (ItemDO) dataObject; return Convertor.INST.to(itemDO); } @Override public Object toDataObject(Item item, DataObjectInfo dataObjectInfo) { return Convertor.INST.to(item); } @Override public DataObjectInfo dataObjectInfo(String aggregateId) { Long primaryId = Long.parseLong(aggregateId); return DataObjectInfo.builder().clazz(ItemDO.class).primaryId(primaryId).build(); } @Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE, nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) public interface Convertor { Convertor INST = Mappers.getMapper(Convertor.class); @BeanMapping(builder = @Builder(disableBuilder = true)) Item to(ItemDO itemDO); @BeanMapping(builder = @Builder(disableBuilder = true)) ItemDO to(Item item); } } ``` Milky 抽象出了 **DataObject** 的概念,其实就是一般数据库的 ORM 对象(XXDO 之类)。DO 对象的持久化是另外一层,如下代码。一般这里是真实发生数据库存储的地方。你也许会有疑问为什么不能将这两层融合——这么设计的主要原因是,某类领域对象在持久化的时候可以根据类型不同,持久化不同的数据库记录。比如一个手机存储的数据库记录包含材质、品牌、3G/4G/5G 等属性,一件衣服的数据库记录不会存储这些手机的属性,但会有一些共有信息,比如品牌、价格、折扣。当我们处理业务对象只需要品牌、价格、折扣这三个信息时,这两条数据库记录都会经过 `DaoAdapter` 变成我们需要的领域对象。但是持久化时,需要转换为相应数据库 ORM 对象,再进行存储。 这里面其实隐藏了一个很麻烦的问题:对于一个已经在数据库的手机存储记录,假设我们只需要更新其价格,那么业务对象只有价格这个信息,当领域对象反向转换为数据库对象时,其他品牌之类的属性肯定是 null。一般的思路是使用 update selective 的方式,即更新不为 null 的字段——但 update selective 也有问题,如果某个字段就是要改为 null,这样反而无法更新。为了解决这个问题,Milky 采用 **merge** 方案:把内存中更新的 `updateDataObject` 与数据库中的对象 merge。利用 null 值的特性——如果 `updatedDataObject` 某个字段为 null,则以数据库为准;如果数据库本身对象就是 null,说明这是一个新建对象,直接用 update 对象。Milky 应用 MapStruct 代码生成完成 merge,具体见 `ItemDAOWrapper` 内部的 `Merger` 类实现。 ```java public class ItemDAOWrapper implements DAOWrapper { final ItemDOMapper itemDOMapper; @Override public int batchSave(@NonNull List itemDOs) { return itemDOs.stream().map(itemDOMapper::insert).reduce(0, Integer::sum); } @Override public int batchUpdate(@NonNull List itemDOs) { return itemDOs.stream().map(itemDOMapper::updateById).reduce(0, Integer::sum); } @Override public Map batchGetByPrimaryIds(@NonNull Set itemIds) { List itemDOs = itemDOMapper.selectBatchIds(itemIds); return Collect.toMap(itemDOs, ItemDO::getItemId); } @Override public ItemDO merge(@NonNull ItemDO priority, @NonNull ItemDO original) { return Merger.INST.merge(priority, original); } @Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE, nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) public interface Merger { Merger INST = Mappers.getMapper(Merger.class); @BeanMapping(builder = @Builder(disableBuilder = true)) ItemDO merge(ItemDO priority, @MappingTarget ItemDO original); } } ``` #### 观察者模式 观察者模式是一种很典型、非常好用的设计模式,它所强调的是通过事件交互、减少耦合:A 发出事件,B 观察这个事件,但 A 并不关心 B 是否消费这个事件,或者有多少人消费这个事件。 ![观察者模式](readme-resource/observer.webp) 回到上述的例子,因为商品的领域对象和库存的领域对象是分开的,所以商品创建的消息应该触发库存对象的生成,而商品对象本身并不关心谁消费这个事件。作为用户只需要写一个 `EventRouter` 来路由这种事件,如下代码。事件路由器路由商品创建事件,进而驱动一个创建库存的 Command,然后这个命令送上命令总线,就直接执行了库存的领域能力。 ```java @RequiredArgsConstructor public class EventRoutersForItem implements EventRouters { final MqService mqService; @EventRouter public void inventory(ItemCreatedEvent event, Context context) { InventoryCreateCommand command = InventoryCreateCommand.builder() .itemId(event.getItemId()).initAmount(100L).build(); CommandBus.driveByEvent(command, event); } @FinalEventRouter public void mqCreated(List events, Context context) { events.forEach(event -> { ItemCreatedMessage message = ItemCreatedMessage.builder() .itemId(event.getItemId()).title(event.getTitle()).build(); mqService.sendMessage(message); }); } } ``` 上述代码还有一个 `@FinalEventRouter`。这是 Milky 的一个特殊设计:对于普通 `@EventRouter`,当事件发生以后直接路由即可;有些特殊的路由,可能要等整个请求完成再发生路由,比如对外发消息。回到本文的例子,商品创建之后会继续创建库存,如果商品创建之后立即发消息(普通 EventRouter),而创建库存失败了,消息已经发出去了,但创建商品的数据库记录因为库存创建失败而发生回滚,这样就发生了一致性问题。所以设计了 `@FinalEventRouter` 避免这种问题——消息最后统一发送。同时 `@FinalEventRouter` 还可以设置 `Order`,且 `Order` 不能设置为 0,这里事关一些内存事务的问题。 #### 内存事务 第一个问题:什么是事务?对于应用层,主要关注两件事:第一是并发修改问题(经典的 i++ 问题);第二是回滚的问题——比如在一个请求里面,你先改了 A,然后准备改 B,改 B 的时候发现搞错了,这时候你需要把 A 恢复。 ![传统数据库事务](readme-resource/database_tx.png) 分析上图可以很明显的看见,对于数据库至少有两次交互。不管是第二步写 B 的过程中有没有问题,对于一般的业务来说,因为计算量其实不是很复杂,请求的大部分耗时都在数据库和网络 IO 上——如果能减少一次数据库 IO,那简直幸福死了。Milky 天生提供这种能力,你啥都不用干: ![内存事务](readme-resource/memory_tx.png) 以上 Milky 支持的内存事务能力,尤其是针对大批量操作,非常有意义。应用层配合 `@Transactional`,由 `CommandBus.accept` 在请求末尾统一落库并提交事务(见 `MemoryTxTest`)。 但是,编程界有一句至理名言:没有银弹。也有一种情况需要特别注意: ![不能使用内存事务的场景](readme-resource/disable_memory_tx.png) 当同一请求内需要先读取外部已提交的数据(如其他请求写入的记录)再驱动 Milky 命令时,需确保读写时序与事务边界符合业务预期;具体场景请参考 `demo` 模块中的 `MemoryTxTest` 与相关注释。