ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

盒马DDD实践:贫血与充血模型的场景化选型与落地

盒马DDD实践:贫血与充血模型的场景化选型与落地 简介围绕领域驱动设计在真实业务中的落地这份 PDF 资料以盒马流程中心与基础资料为案例梳理领域模型从数据建模到对象建模的演进路径适合后端开发、架构设计人员以及正在准备 DDD 落地实践的团队参考。内容涉及失血模型、贫血模型、充血模型的差异与取舍以及依赖注入推荐构造器注入、Repository 模式实现、测试友好设计和微服务部署结构等知识点可帮助读者理解数据库设计与代码设计的边界。资料共 1 个 PDF 文件压缩包约 1.63MB以图文对照的讲稿形式呈现便于按页浏览与快速检索。目前已有 1790 人学习属于关注度较高的实践类案例。读者可借此对照自身项目判断团队当前处于哪类领域模型并参考盒马在流程中心采用 DATAMETHOD、在基础资料采用 DATAMETHODREPO 的做法形成可复用的建模与重构思路。1. 从盒马的两套代码风格说起为什么同一个团队里贫血和充血并存同一个技术团队里流程中心用贫血模型基础资料用充血模型这不是风格不统一而是被业务形态逼出来的选择。流程中心的核心复杂度在状态流转和编排实体本身规则薄把方法挂在对象上收益不大反而让流程编排的入口变散基础资料的核心复杂度在实体自身的业务规则约束规则跟着对象走才不会被各处 Service 复制粘贴成五份。很多团队一上来就问「DDD 该用充血还是贫血」正确问法应该是「这个限界上下文里复杂度长在哪」。盒马的实践给出的是一个分场景选型的样本数据建模负责把关系理清楚对象建模负责把行为收拢到该在的地方两者不是对立关系而是同一套领域模型在不同层的表达。这套东西适合已经有一定业务体量、被「数据库设计即系统设计」坑过的后端团队尤其适合正在做服务拆分、需要给领域划边界的人。2. 失血、贫血、充血三种领域模型的分界与选型依据2.1 数据建模和对象建模到底差在哪Data Modeling 的思路是先定表字段、主键、外键、索引定完系统结构基本就定了。数据字典就是领域模型外键就是关系Manager 类负责把逻辑组织起来。它的优点是直观任何能写 SQL 的人都能看懂系统长什么样。缺点是一旦业务规则复杂逻辑会散落在 Manager、Service、定时任务、MQ 消费者里同一个规则改动要改 n 处。Object Modeling 的思路相反假设内存无限大、永远不宕机那么持久化就是一个无关紧要的细节这叫 Persistence Ignorance。对象之间的关系就是业务关系方法就是业务动作。但现实是内存有限、会宕机、会重启所以数据库仍然存在只是它的职责被压缩成两件事持久化和高效查询QUERY。也就是说Object Modeling 不是不要数据库而是不让数据库结构反向决定代码结构。这两条路的取舍可以归结成一句话数据建模优化的是「怎么查得快」对象建模优化的是「怎么改得动」。系统的读侧压力大、规则稳定数据建模占优系统的写侧规则频繁变化、状态机复杂对象建模占优。2.2 三种模型的判断标准区分失血、贫血、充血不要看类名看三件事数据和行为是否在同一个类里、领域对象是否持有仓储Repository、业务规则是否可以被其他类绕过。模型组成业务逻辑位置典型场景失血模型只有字段的 POJO全部散在 Service / ManagerCRUD 台账、配置表贫血模型DATA METHOD部分方法在对象内编排在 Service状态流转、流程编排充血模型DATA METHOD REPO方法内直接调用仓储完成聚合主数据、有强规则的实体盒马流程中心落在贫血模型区间对象上挂了少量行为但真正的流转编排仍然由流程引擎和服务层承担因为流程的复杂度不在单个实体上而在实体之间的时序。盒马基础资料落在充血模型区间品类、门店、商品这类主数据有自己的不变量约束把这些约束写成对象方法、并让它通过 Repository 拉取自己需要的关联数据能显著减少跨服务查库的胶水代码。2.3 从失血迁移到充血的最小步骤不要整体重构按聚合根逐个迁移步骤如下。选一个业务规则最集中的实体通常是主数据统计当前所有地方对它字段的写入点。把「字段校验 状态变更」收拢成对象方法禁止外部直接 set。引入仓储接口只暴露聚合根级别的方法不要暴露单表 CRUD。把原来在 Service 里的查询逻辑改成先取聚合、再在内存里判断。// 充血模型规则内聚在对象内外部只能通过行为改变状态 public class Category { private CategoryId id; private CategoryStatus status; private ListCategoryRule rules; private final CategoryRepository categoryRepository; // 构造器注入依赖显式可见测试友好 public Category(CategoryId id, CategoryRepository categoryRepository) { this.id id; this.categoryRepository categoryRepository; } // 上架是一个业务动作不是一次字段赋值 public void publish(Operator operator) { // 不变量必须有至少一条规则且规则不能互相冲突 if (rules null || rules.isEmpty()) { throw new IllegalStateException(category has no rule); } if (hasConflictRule()) { throw new IllegalStateException(category rule conflict); } // 需要外部数据时通过仓储获取而不是让调用方传进来 if (categoryRepository.hasOfflineStore(this.id)) { throw new IllegalStateException(offline store not ready); } this.status CategoryStatus.PUBLISHED; } }这段代码的关键点有三个。一是publish代替了setStatus(PUBLISHED)把校验、跨数据检查、状态变更绑成一个不可分割的动作调用方无法绕过规则。二是仓储通过构造器注入对象在构造完成时就具备了完整的协作能力。三是异常类型用 IllegalStateException 而不是业务异常是因为这属于不变量被破坏应该由上层统一转成对外错误码。提示充血模型里注入 Repository 是有争议的做法。判断标准是「这个对象是否需要为了维护自身不变量而读取外部数据」。如果只是为了给查询做缓存就不要注入。3. 依赖注入在领域对象上的真实约束与测试友好性3.1 Spring 容器里的对象和 new 出来的对象不是一回事依赖注入在 runtime 里通常是 singleton 对象而且只有落在 Spring 扫描范围内的类Component、Service、Repository 等才能用 Autowired 拿到注入。这意味着一个残酷的现实你用new Category(...)造出来的领域对象是拿不到任何注入的。很多团队迁移到充血模型后第一周就会踩这个坑——对象方法里categoryRepository是 null报空指针然后开始怀疑人生。常见做法有两种。一种是让领域对象不依赖容器仓储由外部在构造时传进来另一种是领域对象仍然从容器取但只取无状态的领域服务。我一般推荐前者因为领域对象的生命周期不该被容器管容器管的是单例的无状态组件。// 推荐构造器注入依赖显式测试时可自由替换 Service public class CategoryApplicationService { private final CategoryRepository categoryRepository; private final DomainEventPublisher eventPublisher; // 只有一个构造器时 Spring 会自动注入不需要 Autowired public CategoryApplicationService(CategoryRepository categoryRepository, DomainEventPublisher eventPublisher) { this.categoryRepository categoryRepository; this.eventPublisher eventPublisher; } public void publishCategory(CategoryId id, Operator operator) { // 从仓储取出完整聚合注意这里是聚合而不是单表行 Category category categoryRepository.findById(id) .orElseThrow(() - new IllegalArgumentException(category not found)); // 行为在领域对象上应用服务只做编排 category.publish(operator); categoryRepository.save(category); eventPublisher.publish(new CategoryPublishedEvent(id, operator)); } }用构造器注入而不是字段注入收益很直接字段是 final 的对象构造完整性由编译器保证测试时不需要启动 Spring 容器直接 new 一个应用服务传 mock 的仓储进去就行哪个依赖是必需的看构造器签名一目了然。字段注入的Autowired private Foo foo;在单元测试里必须靠反射工具塞值写起来别扭读起来也看不出这个类到底依赖了多少东西。3.2 测试友好性取决于模型设计而不是测试框架失血模型和贫血模型是天然测试友好的——因为它们本来就没什么可测的一个只有 getter/setter 的 POJO测它等于测编译器。真正需要测试友好的是充血模型对象内部调用了仓储如果不做依赖注入你就只能连数据库测。这里有个容易忽略的点构造器注入让「必须 mock 哪个对象」变成显式契约。上面Category的构造器要求传CategoryRepository写测试的人一眼就知道要准备这个桩。如果换成字段注入或者在方法内部new一个仓储实现测试者就得去读实现代码才知道依赖是什么。对比一下两种写法的测试成本注入方式单元测试是否需要容器依赖是否显式是否能发现循环依赖字段注入 Autowired需要或需要反射工具否启动时才报错构造器注入不需要是编译期或启动早期暴露方法内 new无法替换否无法发现4. 盒马模式下的 Repository 实现与部署结构4.1 Repository 该暴露什么粒度的方法Repository 的常见误用是把它做成 DAO 的改名版一个聚合根配一套单表 CRUD结果领域对象里全是碎片化查询。盒马模式下的做法是Repository 只以聚合为单位暴露方法返回的必须是完整的聚合根不能是半成品 DTO。// 仓储接口定义在领域层实现在基础设施层 public interface CategoryRepository { // 按聚合根 ID 取完整聚合 OptionalCategory findById(CategoryId id); // 保存整个聚合内部处理新增/更新差异 void save(Category category); // 领域层需要的判定型查询语义化命名 boolean hasOfflineStore(CategoryId id); // 批量取避免循环单查 ListCategory findByIds(CollectionCategoryId ids); } // 实现层用 MyBatis 或 JPA 均可关键是把多表拼装收在这里 Repository public class CategoryRepositoryImpl implements CategoryRepository { private final CategoryMapper categoryMapper; private final CategoryRuleMapper ruleMapper; Override public OptionalCategory findById(CategoryId id) { CategoryDO data categoryMapper.selectById(id.value()); if (data null) { return Optional.empty(); } // 一次把规则捞全避免领域对象内部再触发查询 ListCategoryRule rules ruleMapper.selectByCategoryId(id.value()); return Optional.of(data.toDomain(rules)); } Override public boolean hasOfflineStore(CategoryId id) { // 判定型查询走 count不要为了一个布尔值把整行捞回来 return categoryMapper.countOfflineStore(id.value()) 0; } }接口放在领域层、实现放在基础设施层方向是倒置的领域层不依赖具体持久化技术实现层依赖领域层定义的接口。hasOfflineStore这种语义化判定方法很关键它让领域对象里写的是if (repo.hasOfflineStore(id))而不是让对象自己拼 SQL 条件。另外要注意findById里一次性把规则捞全否则领域对象内部触发懒加载查询在事务边界外就会出问题。4.2 命名和参数上的几个具体约定CategoryDO和Category分开是不可省的。DO 是持久化结构字段和表一一对应可以带isDeleted、gmtCreate这类技术字段领域对象只保留业务属性不带技术字段。转换放在toDomain里别让 Mapper 直接返回领域对象否则表结构一改领域对象跟着抖。参数上聚合根的 ID 用值对象包装而不是裸 String 或 Long好处是编译期就能防止把门店 ID 传成品类 ID。仓储方法尽量不要接受一堆零散条件如果查询条件超过三个说明这个查询不属于仓储应该单独建一个读模型Read Model走 CQRS 的路子。批量方法要成对出现。findByIds存在的原因是避免在循环里调findById这是 N1 查询最典型的来源在主数据场景下尤其致命因为品类下的门店可能成百上千。4.3 部署结构上应用服务和领域对象的分层盒马模式下的部署结构里应用服务是进程边界领域对象活在进程内仓储实现跨进程访问数据库和缓存。典型的分层是接入层HTTP / RPC 入口只做参数解析和鉴权不写业务判断应用层编排用例管事务、管事件发布不写领域规则领域层聚合、实体、值对象、领域服务、仓储接口纯内存逻辑基础设施层仓储实现、Mapper、消息发送、外部接口适配分层带来的部署灵活性在于应用层可以独立拆成微服务领域层跟着走基础设施层按依赖方向被反向引用。需要注意的一点是事务边界事务应该开在应用层一次用例一个事务不要把事务加到仓储方法上否则一个用例涉及多个聚合时会出现事务嵌套。5. 充血模型的排错经验与聚合边界的验证技巧充血模型上线后最常见的三类问题我按踩坑频率排一下。第一类是懒加载穿透。领域对象里持有仓储如果仓储返回的对象在事务外还有未加载的关联调用方法时就会抛 LazyInitializationException。验证方法是写一个不启动 Spring 容器的单元测试用 mock 仓储返回完整聚合看对象方法能否纯内存跑通。如果 mock 得费劲说明聚合边界划错了。第二类是不变量被绕过。有人图省事在别的服务里直接调categoryMapper.updateStatus(id, PUBLISHED)规则就废了。防护手段是让 Mapper 只在仓储实现层可见领域对象和外部服务拿不到 Mapper再配合代码扫描规则禁止在领域层之外出现对 DO 的直接写操作。第三类是聚合过大导致的性能问题。一个聚合里塞了几百个实体每次加载都全量拉取。判断聚合是否过大的标准是「这些对象是否需要强一致地一起变更」如果只是查询时想一起返回那不属于同一个聚合应该走读模型。// 用纯内存测试验证聚合边界是否合理 class CategoryTest { Test void publish_should_fail_when_rule_conflict() { // 构造一个 mock 仓储只桩出方法内部真正用到的那两个调用 CategoryRepository repo mock(CategoryRepository.class); when(repo.hasOfflineStore(any())).thenReturn(false); Category category new Category(CategoryId.of(C001), repo); category.addRule(new CategoryRule(R1, ConflictType.A)); category.addRule(new CategoryRule(R2, ConflictType.A)); // 与 R1 冲突 // 纯内存执行不需要数据库、不需要 Spring 容器 assertThrows(IllegalStateException.class, () - category.publish(Operator.of(u1))); } }这个测试的价值不在于覆盖率而在于它是一个边界探针如果写这个测试时需要 mock 十几个依赖说明这个聚合捆了太多东西该拆。如果完全不需要 mock 仓储说明这个对象其实不依赖外部数据仓储注入就是多余的可以退化成贫血模型。最后一个技巧是关于演进节奏的。从失血迁到充血不要按模块切要按变更频率切。变更最频繁的主数据先迁规则稳定的台账类数据留在失血模型里反而更省心。每迁一个聚合把原来的 Service 方法标记为 deprecated 但保留两个迭代周期用日志对比新旧逻辑的输出差异差异为零再删旧路径。这比一次性重构的风险低得多也更容易说服团队里持怀疑态度的人。本文还有配套的精品资源点击获取
返回列表