Skip to content

从混乱代码中拆出边界:关注点分离与可测试性 ​

一个下单方法的测试,本地时间下午跑是绿的,晚上 8 点 51 分跑变成了红的:期望 144.00,实际 134.00。代码没变,数据没变,变的只是测试运行的时间。往计价规则里注入一个边界错误,这个测试照样通过。把同一段逻辑拆开之后,8 个测试 49ms 跑完,同一个错误被一个名为「二十二点整不再优惠」的测试精确指出。难测的代码,通常是关注点没有分开的代码。

本文用一个完整的下单服务做对照实验,说明关注点按什么维度拆、为什么分了三层不等于分离了关注点,以及怎样用可测试性检验边界是否清楚。实验环境:JDK 21.0.5、JUnit 5(Platform 1.13.4)、MySQL 8.4.11。

一、先说结论 ​

  • 关注点按「变化原因」和「副作用」拆:业务规则、外部 I/O、时间与随机数、数据格式、事务与重试,是服务端代码里最常见的几类关注点。
  • 分了 Controller、Service、Repository 三层,不代表关注点已经分开。一个 Service 方法里仍然可以同时调 HTTP、读系统时间、拼 SQL、算价格。
  • 可测试性是检验边界的反馈:必须启动容器、必须连外部服务、结果依赖当前时间、只能查数据库断言,这四个信号都说明有关注点混在了一起。
  • 改进的手段很朴素:规则写成纯函数;外部依赖由业务侧定义端口;时间通过 Clock 注入;格式转换留在最外层。
  • 不要为了测试而设计:不必把所有方法改成 public,也不必给每个类配一个接口。只在真正需要替换的地方引入端口。

二、一段典型的混合代码 ​

下面是一个简化的下单方法(完整代码 46 行)。它做了六件事:

java
public String create(Map<String, String> form) throws Exception {
    // 1. 校验:HTTP 表单格式与业务规则混在一起
    int quantity = Integer.parseInt(form.getOrDefault("quantity", "1"));
    if (quantity < 1 || quantity > 99) return "{\"error\":\"quantity out of range\"}";

    // 2. 调价格服务:网络 I/O
    HttpResponse<String> resp = HttpClient.newHttpClient().send(…);
    BigDecimal unitPrice = new BigDecimal(resp.body().trim());

    // 3. 计价规则:会员 9 折;20:00—22:00 满 100 减 10
    BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(quantity));
    if (member) amount = amount.multiply(new BigDecimal("0.9"));
    LocalTime now = LocalTime.now();                          // 直接读系统时间
    if (!now.isBefore(LocalTime.of(20, 0)) && now.isBefore(LocalTime.of(22, 0))
            && amount.compareTo(new BigDecimal("100")) >= 0) {
        amount = amount.subtract(BigDecimal.TEN);
    }

    // 4. 订单号:日期 + 随机数
    String orderNo = "ORD-" + LocalDate.now().format(BASIC_ISO_DATE)
            + "-" + String.format("%06d", new Random().nextInt(1_000_000));

    // 5. 持久化:直接拿 JDBC 连接
    try (Connection c = DriverManager.getConnection(jdbcUrl); …) { … }

    // 6. 响应格式:手工拼 JSON
    return "{\"orderNo\":\"" + orderNo + "\",\"amount\":" + amount + "}";
}

这段代码能正确工作。它的问题要到修改和测试的时候才暴露出来。

三、关注点按什么拆 ​

「关注点」不是一个很精确的词。在服务端代码里,可以用两个问题来识别:

  1. 谁会要求修改这段代码? 计价规则由运营决定,JSON 格式由接口约定决定,SQL 由表结构决定。它们变化的原因不同、频率也不同。
  2. 这段代码有没有副作用,或者依赖不受控的输入? 网络调用、数据库写入、当前时间、随机数,都会让同样的输入得到不同的结果。

按这两个问题,上面的方法可以拆出这些关注点:

关注点变化原因是否可控拆分后放在哪里
表单解析与 JSON 响应接口约定可控最外层的适配器
数量范围校验业务规则可控请求对象的构造校验
计价规则运营策略可控纯函数 PricingPolicy
查询商品价格价格服务的协议不可控:网络端口 PriceCatalog + HTTP 实现
当前时间—不可控:时钟注入 java.time.Clock
订单号编号规则不可控:随机数端口 OrderNumbers
保存订单表结构不可控:数据库端口 OrderRepository + JDBC 实现

四、拆分之后的结构 ​

拆分前:OrderCreationService.create()一个 46 行的方法表单HTTP规则时间随机JDBC测试它必须同时控制这六样东西拆分后业务核心(只依赖 JDK)PricingPolicy 纯函数OrderCreation 编排OrderController表单 → 请求 → JSONClock可注入的时间HTTP 价格服务JDBC 订单仓库随机编号外层实现业务核心定义的端口(箭头指向被依赖的一方)
图 1 · 拆分前,一个方法同时接触六类关注点;拆分后,业务核心只依赖自己定义的端口,HTTP、JDBC、随机数和系统时间都留在外层

业务核心只依赖 JDK,不知道 HTTP、JSON 和 JDBC 的存在:

java
/** 计价规则:纯函数,输入相同则输出相同 */
public final class PricingPolicy {
    public BigDecimal price(BigDecimal unitPrice, int quantity, boolean member, LocalTime at) {
        BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(quantity));
        if (member) amount = amount.multiply(new BigDecimal("0.9"));
        boolean evening = !at.isBefore(LocalTime.of(20, 0)) && at.isBefore(LocalTime.of(22, 0));
        if (evening && amount.compareTo(new BigDecimal("100")) >= 0) amount = amount.subtract(BigDecimal.TEN);
        return amount.setScale(2, RoundingMode.HALF_UP);
    }
}

// 端口:由业务侧按自己的需要定义
public interface PriceCatalog { Optional<BigDecimal> unitPrice(String sku); }
public interface OrderRepository { void save(Order order); }
public interface OrderNumbers { String next(LocalDate day); }

/** 应用服务:只负责编排 */
public final class OrderCreation {
    // 构造器注入 catalog、repository、numbers、pricing、clock

    public Order create(OrderRequest req) {
        BigDecimal unitPrice = catalog.unitPrice(req.sku()).orElseThrow(() -> new UnknownSkuException(req.sku()));
        LocalDateTime now = LocalDateTime.now(clock);
        BigDecimal amount = pricing.price(unitPrice, req.quantity(), req.member(), now.toLocalTime());
        Order order = new Order(numbers.next(now.toLocalDate()), req.userId(), req.sku(), req.quantity(), amount, now);
        repository.save(order);
        return order;
    }
}

HTTP 调用、JDBC、随机编号和表单解析都搬到了外层的适配器里,它们实现业务核心定义的端口。依赖方向由外向内,这正是依赖倒置原则描述的结构。

注意几个没有做的事:

  • 没有给 PricingPolicy 定义接口。它是纯函数,测试时直接用真实实现,不需要替换。
  • 没有把私有方法改成 public 来测。规则本身就是一个独立的类,自然可以测试。
  • 端口只有三个,对应三个真正不可控的外部依赖。

五、实测:两套测试的差别 ​

拆分前,唯一能写的测试是端到端的:启动一个假的价格服务、连接真实的 MySQL、调用方法后再查库断言金额。拆分后,业务核心可以直接测:

java
class PricingPolicyTest {
    final PricingPolicy pricing = new PricingPolicy();

    @Test void 会员九折() {
        assertEquals(new BigDecimal("144.00"), pricing.price(new BigDecimal("80"), 2, true, LocalTime.of(12, 0)));
    }
    @Test void 二十二点整不再优惠() {
        assertEquals(new BigDecimal("144.00"), pricing.price(new BigDecimal("80"), 2, true, LocalTime.of(22, 0)));
    }
    // 另有:非会员按原价、晚间满一百减十、晚间不足一百不减
}

class OrderCreationTest {
    final List<Order> saved = new ArrayList<>();
    final Clock clock = Clock.fixed(Instant.parse("2026-09-21T12:30:00Z"), ZoneOffset.UTC);
    final OrderCreation creation = new OrderCreation(
            sku -> sku.equals("A1") ? Optional.of(new BigDecimal("80.00")) : Optional.empty(),
            saved::add,                                   // 内存中的仓库
            day -> "ORD-" + day + "-000001",              // 固定的编号
            new PricingPolicy(), clock);
    // 测试:保存的订单包含确定的编号、时间和金额;未知商品不保存;数量越界在构造请求时就被拒绝
}

端口都是只有一个方法的接口,测试里用 Lambda 就能实现,不需要 Mock 框架。

拆分前:1 个端到端测试依赖:HTTP 桩 + MySQL,每次约 0.4~0.5 秒只能查库断言最终金额本地时间 20:51 运行:期望 144.00,实际 134.00注入「22:00 仍有优惠」的 bug:测试照样通过测试结果取决于它在几点运行拆分后:8 个单元测试依赖:无,全部 49ms 完成时间用 Clock.fixed,编号用固定实现晚间规则的四个边界各有一个测试同一个 bug:「二十二点整不再优惠」失败端到端测试仍然需要,但只验证装配JDK 21.0.5、JUnit 5(Platform 1.13.4)、MySQL 8.4.11 实测
图 2 · 同一段计价规则:拆分前只能写一个需要 MySQL 和 HTTP 桩的端到端测试,结果随运行时间变化,也发现不了边界错误;拆分后 8 个测试 49ms 跑完,边界错误被精确定位
拆分前拆分后
测试数量1 个端到端测试8 个单元测试
外部依赖假价格服务 + MySQL无
运行耗时约 0.4~0.5 秒49 ms(8 个合计)
本地时间 20:51 运行失败:期望 144.00,实际 134.00不受影响,时间由 Clock.fixed 控制
注入边界错误(22:00 整仍然优惠)仍然通过「二十二点整不再优惠」失败
失败时能看出什么最终金额不对哪一条规则、在哪个边界上不对

时区那一行是这样测的:同一个端到端测试放进容器,把 JVM 时区设为 UTC+6,本地时间变成 20:51,测试就红了。它测的其实是「现在几点」,而不是计价规则。

边界错误那一行更能说明问题:把 isBefore(22:00) 改成 !isAfter(22:00),22 点整也会享受优惠。端到端测试只在一个时刻运行,永远覆盖不到 22:00 这个边界;拆分后的规则可以把每个边界都写成一个用例。

端到端测试并没有被取消。它仍然用来验证 HTTP 适配器、JDBC 实现和整个装配是否正确,只是不再承担「验证每一条业务规则」的责任。

六、测试困难的四个信号 ​

写测试时遇到下面这些情况,说明有关注点混在了一起:

信号通常意味着改进方向
必须启动 Spring 容器或整个应用业务规则和框架装配、配置读取混在一起规则写成不依赖框架的类
必须连接外部服务或数据库规则和 I/O 混在一起由业务侧定义端口,外层实现
结果依赖当前时间或随机数直接调用了 now()、new Random()、UUID.randomUUID()注入 Clock 或生成器
只能查数据库或日志断言结果方法没有返回有意义的值,只产生副作用让计算返回结果,副作用单独执行

这四个信号也是代码评审时有用的检查项:看到业务方法里出现 LocalDateTime.now() 或 HttpClient,就可以问一句「这段怎么测」。

七、什么时候不必拆 ​

  • 逻辑很简单、几乎没有分支:一个把请求原样转发给下游的接口,端到端测试就够了。
  • 只有一种实现且不会替换的依赖:不必为了测试而引入端口,可以用 Testcontainers 这类工具直接测真实依赖。
  • 一次性的脚本和数据修复:拆分的成本收不回来。

拆分的目标是让会独立变化的东西可以独立修改和验证,而不是把代码拆得越碎越好。

八、常见误区 ​

  • 「分了三层就是分离了关注点」:三层只分开了代码的位置,一个 Service 方法里仍然可以混着规则、I/O 和时间。
  • 「可测试性就是 Mock 用得好」:大量 Mock 往往说明依赖太多。好的边界让大部分代码不需要 Mock。
  • 「每个类都要有接口」:只有需要替换的外部依赖才需要端口,纯计算的类直接使用真实实现。
  • 「单元测试能替代集成测试」:单元测试验证规则,集成测试验证装配和外部协议,两者覆盖的风险不同。
  • 「测不了就把方法改成 public」:这是在绕过边界。更好的做法是把那段逻辑提取成一个独立的类。

小结 ​

关注点分离的出发点是:不同的变化原因和不可控的副作用,不应该挤在同一段代码里。可测试性是检验它最直接的反馈。一段需要启动数据库、依赖当前时间、只能查库断言的代码,往往也是一段改一处规则就要担心整条链路的代码。拆分之后,规则可以逐条验证,外部依赖可以单独替换,端到端测试只需要关心装配是否正确。

下一篇 读懂一个系统的设计 换一个方向:不是拆自己的代码,而是看成熟的系统怎样划分模型、接口和实现。


配套实验

可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权