Skip to content

从贫血模型到充血模型

约 2124 字大约 7 分钟

2026-02-09

更新时间:2026-02-09
适合读者:正在做 DDD 落地、被“业务逻辑四处散落”困扰的后端工程师

先说结论

这次重构最关键的变化只有一句话:

让领域对象自己维护业务不变量,让应用层从“规则实现者”回到“流程编排者”。

落地后直接收益是:

  • 规则不再散落在 Handler/Listener/Util,修改点更集中。
  • 状态流转从“字符串 + if-else”变成“语义方法 + 枚举约束”。
  • 单测颗粒度变小,核心逻辑不需要依赖数据库就能验证。

1. 背景:为什么要从贫血走向充血

在监测事件场景里,最初模型是典型的“贫血模型”:

  • 实体只有字段,不表达业务语义。
  • 创建、校验、关闭、状态决策都在应用层散落实现。
  • 同一条规则在多个入口被复制,最后出现“改一处漏三处”。

这类代码短期看“开发快”,长期会遇到三个问题:

  1. 一致性差:不同入口对同一规则理解不同。
  2. 可维护性差:排查一个状态问题要跨多层追逻辑。
  3. 可测试性差:规则绑在流程代码里,单测成本高。

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. 改造原则:先收敛,再下沉

这次改造遵循三条简单原则:

  1. 把业务不变量收回领域对象:对象在创建和状态变更时自校验。
  2. 把状态表达升级为类型:用枚举和语义方法替代魔法值。
  3. 应用层只做编排:保留流程控制,不直接操纵业务细节。

换句话说,不是“把所有逻辑都塞进实体”,而是让“最应该由领域表达的规则”回到领域。


4. 充血模型一:事件实体负责创建与关闭

改造后,EventWarning 直接提供语义方法:createAiWarningclose

@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 个可观测指标评估效果:

  1. 规则集中度:同一规则是否只在一个领域方法里维护。
  2. 入口一致性:新增入口时是否天然复用已有规则。
  3. 测试速度:核心逻辑能否在纯单测层快速覆盖。
  4. 变更成本:状态机或校验规则变更时影响面是否可控。

如果这 4 项都在向好,说明“充血改造”不是概念升级,而是工程质量升级。


9. 渐进式落地建议(避免大爆炸重构)

建议按以下顺序推进:

  1. 先抓高频高风险对象:例如事件、订单、工单等核心实体。
  2. 先收创建与状态流转:这两类逻辑最容易产生不一致。
  3. 再收策略匹配与阈值判断:把 if-else 树迁回策略对象。
  4. 最后清理应用层重复代码:让应用层只保留编排语义。

这个顺序的好处是:每一步都有业务收益,同时可控回滚。


10. 常见误区

误区 1:充血模型 = 把所有逻辑塞进实体

不是。实体承载的是“本领域强相关的不变量和行为”,跨聚合编排仍应放在应用层或领域服务。

误区 2:用了枚举就等于 DDD

不是。关键是“语义和约束是否被模型持有”,而不仅是常量写法变化。

误区 3:一次性全量改造才算成功

不是。真实项目应该增量演进,优先解决最痛点的规则分裂问题。


11. 收尾:一句话经验

充血模型的核心不是“方法变多”,而是“业务不变量有且只有一个可信入口”。

当你把入口收拢,系统会同时得到三件事:可读性、可测试性、抗变更能力。

————————到底啦!————————