通用仓储:GenericRepository 与 Entity ↔ PO 契约
GenericRepository是 DDK 里领域层与持久层之间的接缝。这篇讲它的契约、MyBatis-Plus 实现怎样工作、保存时的并发语义,以及什么时候不该用它。
一、分层意图
domain 层 UserRepository extends GenericRepository<User, UserId>
↑ 只依赖接口,接口只认识领域实体与类型化标识
─────────────────────────────────────────
infrastructure 层 UserRepositoryImpl extends GenericRepositoryImpl<User, UserId, UserPO, UserMapper>
↓ 实现类知道 PO、知道 MyBatis-Plusddk-core 里的 GenericRepository 不依赖任何持久层框架,领域层可以在没有数据库的情况下被单元测试。MyBatis-Plus 相关的东西全在 ddk-mybatis 模块。
二、接口契约
public interface GenericRepository<E, ID> {
E create(E entity);
List<E> createAll(List<E> entities);
E update(E entity); // 按加载时的版本保存,冲突时抛 ConcurrentUpdateException
List<E> updateAll(List<E> entities);
Optional<E> find(ID id);
List<E> findAll(List<ID> ids);
boolean remove(ID id);
long removeAll(List<ID> ids);
PageResponse<E> page(PageQuery pageQuery);
long count();
boolean existsById(ID id);
}几点约定:
- 泛型顺序是
<E, ID>,实体在前,与 Spring Data 的习惯一致。 ID推荐用类型化标识,如UserId,让「把订单 ID 当用户 ID 传」在编译期报错。实现会把Identifier拆成主键值;用Long也可以。- 写方法返回保存后的实体。 数据库生成的主键、推进后的版本号都在返回值上,传入的实例不会被就地修改。推荐给聚合根预生成标识(雪花 ID),创建时就没有这个区别。
find返回Optional,查不到时不返回 null。- 单个与批量用不同的方法名(
find/findAll),避免ID本身是List时的重载歧义。 E刻意不约束为AggregateRoot:领域模型基类可以只用一部分。如果E是聚合根,写入成功后会排空并发布它累积的领域事件。
三、MyBatis-Plus 实现
GenericRepositoryImpl<E, ID, P, M> 的四个泛型参数是领域实体、标识、持久化对象和 MyBatis Mapper。它只做两件事:在进出口做 Entity ↔ PO 转换,其余委托给 MyBatis-Plus。子类只要声明泛型:
@Repository
public class UserRepositoryImpl
extends GenericRepositoryImpl<User, UserId, UserPO, UserMapper>
implements UserRepository {
}泛型实参靠 GenericTypeResolver 反射解析,所以子类必须直接填具体类型。中间再插一层泛型抽象类会解析失败,初始化时抛异常。
分页的实现
@Override
public PageResponse<E> page(PageQuery query) {
Page<P> page = MybatisPlusPageAdapter.toPage(query);
super.page(page, QueryParser.parse(query));
return MybatisPlusPageAdapter.toPageResponse(page, mapperProvider.lookup(pClass, eClass)::map);
}MybatisPlusPageAdapter 负责在 ddk-core 的纯 POJO(PageQuery/PageResponse)和 MyBatis-Plus 的 IPage 之间转换,把框架类型挡在 ddk-mybatis 模块内。
排序信息由 PageQuery.addSort(...) 维护,并在 QueryParser.parse(query) 中追加到 QueryWrapper:
query.addSort("createTime", "DESC");需要注意:排序字段名会转成下划线列名并传给 MyBatis-Plus。对外开放查询参数时,业务侧仍应做字段白名单映射,避免任意字段排序。
四、保存与并发
聚合的版本号映射到 PO 上带 @Version 的字段,由 MyBatis-Plus 的乐观锁插件在更新时拼上 WHERE version = ? 并自增,ddk-mybatis-starter 默认开启这个插件。映射本身写在你的转换器里:PO → 实体时经由 restore(..., version) 回填,实体 → PO 时带上 version()。
update 检查影响行数:
update(order)
po = toPo(order)
if updateById(po) == 0 版本已被推进,或记录已被删除
throw ConcurrentUpdateException 不写入,也不发布 order 上登记的事件
publish(order.drainDomainEvents()) 交给发布器,订阅方在事务提交后收到
return toEntity(po) 带有推进后的版本号- 冲突时抛出而不是静默返回:否则基于旧状态的修改看起来成功了,它登记的领域事件还会被发布出去,下游收到一件并没有发生的事。
ConcurrentUpdateException的错误码是CONCURRENT_UPDATE,Web starter 返回 409。调用方应重新加载聚合后重试,或把冲突告诉用户。updateAll逐条更新而不是updateBatchById:批量执行拿不到每一行的影响行数,发现不了其中某一条的冲突。- 同一个聚合在一个用例里要保存两次时,第二次使用第一次
update的返回值,否则会因为版本号过期而冲突。
为什么版本检查放在聚合根上、子表怎样保存,见 持久化与重建。
五、条件查询:@Query 注解
继承 PageQuery 并在字段上标注 @Query,QueryParser 会把它们拼成 QueryWrapper:
public class UserPageQuery extends PageQuery {
@Query(operator = Operator.LIKE)
private String username;
@Query(value = "phone_number", operator = Operator.EQ)
private String phoneNumber;
@Query(operator = Operator.GE)
private LocalDateTime createTimeStart;
}@Query.value() 不设置时,取字段名的下划线形式作为列名。Operator 提供了 16 种操作符(EQ、NE、GT、GE、LT、LE、LIKE、IN 等)。
这套机制的边界要清楚:它只能表达「字段 + 操作符 + 值」这种平铺条件。嵌套的 AND/OR 分组、跨表关联、子查询都表达不了。遇到那些情况,直接写 Mapper XML,不要试图扩展这个注解。
六、使用前要知道的事
6.1 必须显式注册双向映射器
GenericRepositoryImpl 靠 MapperProvider 完成 Entity ↔ PO 转换,两个方向各算一次注册,漏一个就在首次调用时抛 MissingMapperException:
@Component
@EnhancedMapper(source = User.class, target = UserPO.class)
public class UserPoConverter implements ObjectMapper<User, UserPO> { ... }
@Component
@EnhancedMapper(source = UserPO.class, target = User.class)
public class UserEntityConverter implements ObjectMapper<UserPO, User> { ... }早期版本在找不到映射器时会退回一个把数据映射成空对象的 DefaultMapper,随后在别处抛 ClassCastException;现在是在查找时立刻失败,并在异常消息里给出补救写法。详见 对象映射文档。
5.2 分页大小有默认上限,但业务仍应收敛
@Min(value = 1, message = "每页数量不能小于1")
private Long pageSize = 10L;PageQuery.setPageSize() 会把值限制在 1..200。这个上限能挡住明显的超大分页,但生产接口仍建议根据业务场景收得更窄,例如列表页 50、后台导出走异步任务。
6.2 分页大小有默认上限,但业务仍应收敛
PageQuery.setPageSize() 会把值限制在 1..200。这个上限能挡住明显的超大分页,但生产接口仍建议按场景收得更窄,例如列表页 50、后台导出走异步任务。
6.3 page 返回的是完整聚合
分页查询会把每一行都重建成聚合。列表页只需要几个字段时,更好的做法是绕过仓储、直接用 Mapper 查询只读模型,理由见 CQRS 不是两套系统。
七、什么时候不要用 GenericRepository
- 聚合跨多张表时。
GenericRepositoryImpl假设一个实体对应一个 PO 对应一张表。聚合根带子实体(订单 + 订单行)时,仓储需要手写,在一个事务里处理主子表,并让子表的修改先通过根表的版本检查。这在 DDD 实践中是常态,所以通用仓储更适合简单聚合;复杂聚合的仓储直接实现GenericRepository接口。 - 需要复杂查询时。 直接定义 Mapper 方法与 XML,比硬套
@Query清楚。 - 需要批量高性能写入时。
saveBatch默认按 1000 条分批执行,百万级导入要用rewriteBatchedStatements或LOAD DATA。
相关文档
- 对象映射:MapperProvider 与 @EnhancedMapper
- 领域模型基类
- DDK MyBatis Starter
- 持久化与重建:重建与创建的区别、版本号与子表的保存方式