从贫血模型到充血模型
约 2124 字大约 7 分钟
2026-02-09
更新时间:2026-02-09
适合读者:正在做 DDD 落地、被“业务逻辑四处散落”困扰的后端工程师
先说结论
这次重构最关键的变化只有一句话:
让领域对象自己维护业务不变量,让应用层从“规则实现者”回到“流程编排者”。
落地后直接收益是:
- 规则不再散落在
Handler/Listener/Util,修改点更集中。 - 状态流转从“字符串 + if-else”变成“语义方法 + 枚举约束”。
- 单测颗粒度变小,核心逻辑不需要依赖数据库就能验证。
1. 背景:为什么要从贫血走向充血
在监测事件场景里,最初模型是典型的“贫血模型”:
- 实体只有字段,不表达业务语义。
- 创建、校验、关闭、状态决策都在应用层散落实现。
- 同一条规则在多个入口被复制,最后出现“改一处漏三处”。
这类代码短期看“开发快”,长期会遇到三个问题:
- 一致性差:不同入口对同一规则理解不同。
- 可维护性差:排查一个状态问题要跨多层追逻辑。
- 可测试性差:规则绑在流程代码里,单测成本高。
2. 贫血模型长什么样
2.1 实体只有属性
// EventWarning.java(贫血)
@Data
@Accessors(chain = true)
public class EventWarning {
private String id;
private String eventId;
private String treeId;
private String eventType;
private Long eventTime;
private Long endTime;
private Integer status;
}2.2 规则散落在应用层
// WarningListener.java(业务规则散落)
private void createWarning(String treeId, String deviceCode, Set<String> eventTypes) {
long now = System.currentTimeMillis() / 1000;
EventWarning warning = new EventWarning()
.setTreeId(treeId)
.setEventId(IdUtil.nextId())
.setEventType(String.join(",", eventTypes))
.setEventTime(now)
.setEndTime(now + RandomUtil.randomInt(1, 5));
warningGateway.insert(warning);
}// 关闭逻辑也散落在外
private void closeWarning(EventWarning warning) {
long endTime = System.currentTimeMillis() / 1000;
warningGateway.updateEndTime(warning.getEventId(), endTime);
}问题不在“代码能不能跑”,而在于:谁对业务规则负责并保证一致执行。
3. 改造原则:先收敛,再下沉
这次改造遵循三条简单原则:
- 把业务不变量收回领域对象:对象在创建和状态变更时自校验。
- 把状态表达升级为类型:用枚举和语义方法替代魔法值。
- 应用层只做编排:保留流程控制,不直接操纵业务细节。
换句话说,不是“把所有逻辑都塞进实体”,而是让“最应该由领域表达的规则”回到领域。
4. 充血模型一:事件实体负责创建与关闭
改造后,EventWarning 直接提供语义方法:createAiWarning、close。
@Data
@Accessors(chain = true)
public class EventWarning {
private static final int MIN_AUTO_CLOSE_SECONDS = 1;
private String id;
private String eventId;
private String treeId;
private String deviceCode;
private String eventType;
private Long eventTime;
private Long creationTime;
private Long lastModificationTime;
private Long endTime;
public static EventWarning createAiWarning(String treeId,
String deviceCode,
String eventId,
String eventType,
long eventTime,
int autoCloseSeconds) {
if (StringUtils.isBlank(treeId)) {
throw new IllegalArgumentException("treeId 不能为空");
}
if (StringUtils.isBlank(deviceCode)) {
throw new IllegalArgumentException("deviceCode 不能为空");
}
if (StringUtils.isBlank(eventId)) {
throw new IllegalArgumentException("eventId 不能为空");
}
if (StringUtils.isBlank(eventType)) {
throw new IllegalArgumentException("eventType 不能为空");
}
if (autoCloseSeconds < MIN_AUTO_CLOSE_SECONDS) {
throw new IllegalArgumentException("autoCloseSeconds 不能小于 1");
}
return new EventWarning()
.setTreeId(treeId)
.setDeviceCode(deviceCode)
.setEventId(eventId)
.setEventType(eventType)
.setEventTime(eventTime)
.setCreationTime(eventTime)
.setEndTime(eventTime + autoCloseSeconds);
}
public void close(long endTime) {
if (StringUtils.isBlank(this.eventId)) {
throw new IllegalArgumentException("eventId 不能为空,无法关闭事件");
}
if (this.eventTime != null && endTime < this.eventTime) {
throw new IllegalArgumentException("结束时间不能早于事件时间");
}
this.endTime = endTime;
this.lastModificationTime = endTime;
}
}这样做的价值是:任何入口只要创建/关闭事件,都必须经过同一套规则。
5. 充血模型二:汇总实体负责状态决策
5.1 先用枚举收敛状态定义
@Getter
@AllArgsConstructor
public enum AlertSummaryStatus {
WARNING("预警中"),
PENDING("待处置"),
PROCESSING("处置中"),
AUDIT("待审核"),
CLOSED("结束");
private final String code;
public boolean matches(String code) {
return this.code.equals(code);
}
}5.2 汇总实体决定初始状态
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class AlertSummary {
private String id;
private String treeId;
private String level;
private String content;
private String status;
private String deviceType;
private String alarmType;
private LocalDateTime alarmTime;
private String concerns;
private String suggestions;
public static AlertSummary create(String treeId,
String level,
String content,
LocalDateTime alarmTime,
String deviceType,
String alarmType,
boolean strategyMatched) {
if (StringUtils.isBlank(treeId)) throw new IllegalArgumentException("treeId 不能为空");
if (StringUtils.isBlank(level)) throw new IllegalArgumentException("level 不能为空");
if (StringUtils.isBlank(content)) throw new IllegalArgumentException("content 不能为空");
if (alarmTime == null) throw new IllegalArgumentException("alarmTime 不能为空");
if (StringUtils.isBlank(deviceType)) throw new IllegalArgumentException("deviceType 不能为空");
if (StringUtils.isBlank(alarmType)) throw new IllegalArgumentException("alarmType 不能为空");
return AlertSummary.builder()
.treeId(treeId)
.level(level)
.content(content)
.alarmTime(alarmTime)
.deviceType(deviceType)
.alarmType(alarmType)
.status(resolveInitialStatus(strategyMatched))
.build();
}
public static String resolveInitialStatus(boolean strategyMatched) {
return strategyMatched
? AlertSummaryStatus.PENDING.getCode()
: AlertSummaryStatus.WARNING.getCode();
}
public boolean shouldCreateDisposeRecord() {
return AlertSummaryStatus.PENDING.matches(this.status);
}
}核心变化:应用层不再决定状态细节,只问领域“该不该创建处置记录”。
6. 充血模型三:策略实体负责匹配规则
6.1 告警策略:字段匹配规则内聚
@Data
@Accessors(chain = true)
public class AlertPolicy {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private String treeIds; // 可能是 ["T1","T2"] 或 T1,T2
private String levels; // 同上
private String deviceTypes; // 同上
private String alarmTypes; // 同上
private Boolean enabled;
private String policyName;
public boolean matches(String treeId, String level, String deviceType, Object alarmTypesParam) {
if (!matchesField(this.treeIds, treeId)) return false;
if (StringUtils.isNotBlank(this.levels)
&& StringUtils.isNotBlank(level)
&& !matchesField(this.levels, level)) return false;
if (!matchesField(this.deviceTypes, deviceType)) return false;
if (StringUtils.isBlank(this.alarmTypes)) return true;
if (alarmTypesParam instanceof Set<?> typeSet) {
return typeSet.stream()
.map(String::valueOf)
.anyMatch(type -> matchesField(this.alarmTypes, type));
}
if (alarmTypesParam instanceof String type) {
return matchesField(this.alarmTypes, type);
}
return false;
}
private boolean matchesField(String jsonArrayOrCsv, String targetValue) {
if (StringUtils.isBlank(jsonArrayOrCsv)) return true;
if (StringUtils.isBlank(targetValue)) return false;
try {
if (jsonArrayOrCsv.trim().startsWith("[")) {
ArrayNode arrayNode = (ArrayNode) OBJECT_MAPPER.readTree(jsonArrayOrCsv);
if (arrayNode == null || arrayNode.isEmpty()) return false;
for (int i = 0; i < arrayNode.size(); i++) {
if (targetValue.equals(cleanValue(arrayNode.get(i).asText()))) {
return true;
}
}
return false;
}
String[] valueArray = jsonArrayOrCsv.split(",");
for (String value : valueArray) {
if (targetValue.equals(cleanValue(value))) {
return true;
}
}
return false;
} catch (Exception e) {
return false;
}
}
private String cleanValue(String value) {
String trimmed = value.trim();
if (trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length() >= 2) {
return trimmed.substring(1, trimmed.length() - 1);
}
return trimmed;
}
}6.2 阈值策略:事件类型 + 置信度判定
@Data
@Accessors(chain = true)
public class ConfidencePolicy {
private String eventType;
private BigDecimal warningValue;
public boolean supportsEventType(String eventType) {
return StringUtils.isNotBlank(this.eventType) && this.eventType.equals(eventType);
}
public boolean isConfidenceMatched(Double confidence) {
if (confidence == null || warningValue == null) {
return false;
}
return BigDecimal.valueOf(confidence).compareTo(warningValue) >= 0;
}
}策略对象成为“规则载体”后,应用层只负责挑选和调用策略,不再拼装细节判断。
7. 应用层瘦身:从规则实现者到规则编排者
7.1 创建事件(应用层更薄)
private void createWarningRecord(String treeId, String deviceCode, Set<String> eventTypes) {
long now = System.currentTimeMillis() / 1000;
EventWarning warning = EventWarning.createAiWarning(
treeId,
deviceCode,
IdUtil.nextId(),
String.join(",", eventTypes),
now,
RandomUtil.randomInt(1, 5)
);
warningGateway.insert(warning);
}7.2 关闭事件(应用层只触发行为)
private void closeWarning(EventWarning warning) {
long endTime = System.currentTimeMillis() / 1000;
warning.close(endTime);
warningGateway.updateEndTime(warning.getEventId(), warning.getEndTime());
}7.3 创建汇总(状态由领域决定)
private void saveAlertSummary(TreeInfo tree, AiResult result, boolean strategyMatched) {
LocalDateTime now = LocalDateTime.now();
AlertSummary summary = AlertSummary.create(
tree.getTreeId(),
result.getLevel(),
result.getContent(),
now,
"SENSOR",
"ENV_CHANGE",
strategyMatched
).setConcerns(JsonUtils.toJson(result.getConcerns()))
.setSuggestions(JsonUtils.toJson(result.getSuggestions()));
String summaryId = summaryGateway.insert(summary);
if (summary.shouldCreateDisposeRecord()) {
disposeRecordGateway.insert(summaryId, summary.getStatus(), now);
}
}一句话总结应用层职责变化:
- 改造前:应用层实现规则。
- 改造后:应用层编排规则。
8. 重构收益:怎么判断改造真的有效
可以用下面 4 个可观测指标评估效果:
- 规则集中度:同一规则是否只在一个领域方法里维护。
- 入口一致性:新增入口时是否天然复用已有规则。
- 测试速度:核心逻辑能否在纯单测层快速覆盖。
- 变更成本:状态机或校验规则变更时影响面是否可控。
如果这 4 项都在向好,说明“充血改造”不是概念升级,而是工程质量升级。
9. 渐进式落地建议(避免大爆炸重构)
建议按以下顺序推进:
- 先抓高频高风险对象:例如事件、订单、工单等核心实体。
- 先收创建与状态流转:这两类逻辑最容易产生不一致。
- 再收策略匹配与阈值判断:把 if-else 树迁回策略对象。
- 最后清理应用层重复代码:让应用层只保留编排语义。
这个顺序的好处是:每一步都有业务收益,同时可控回滚。
10. 常见误区
误区 1:充血模型 = 把所有逻辑塞进实体
不是。实体承载的是“本领域强相关的不变量和行为”,跨聚合编排仍应放在应用层或领域服务。
误区 2:用了枚举就等于 DDD
不是。关键是“语义和约束是否被模型持有”,而不仅是常量写法变化。
误区 3:一次性全量改造才算成功
不是。真实项目应该增量演进,优先解决最痛点的规则分裂问题。
11. 收尾:一句话经验
充血模型的核心不是“方法变多”,而是“业务不变量有且只有一个可信入口”。
当你把入口收拢,系统会同时得到三件事:可读性、可测试性、抗变更能力。
