从混乱代码中拆出边界:关注点分离与可测试性
一个下单方法的测试,本地时间下午跑是绿的,晚上 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 行)。它做了六件事:
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 + "}";
}这段代码能正确工作。它的问题要到修改和测试的时候才暴露出来。
三、关注点按什么拆
「关注点」不是一个很精确的词。在服务端代码里,可以用两个问题来识别:
- 谁会要求修改这段代码? 计价规则由运营决定,JSON 格式由接口约定决定,SQL 由表结构决定。它们变化的原因不同、频率也不同。
- 这段代码有没有副作用,或者依赖不受控的输入? 网络调用、数据库写入、当前时间、随机数,都会让同样的输入得到不同的结果。
按这两个问题,上面的方法可以拆出这些关注点:
| 关注点 | 变化原因 | 是否可控 | 拆分后放在哪里 |
|---|---|---|---|
| 表单解析与 JSON 响应 | 接口约定 | 可控 | 最外层的适配器 |
| 数量范围校验 | 业务规则 | 可控 | 请求对象的构造校验 |
| 计价规则 | 运营策略 | 可控 | 纯函数 PricingPolicy |
| 查询商品价格 | 价格服务的协议 | 不可控:网络 | 端口 PriceCatalog + HTTP 实现 |
| 当前时间 | — | 不可控:时钟 | 注入 java.time.Clock |
| 订单号 | 编号规则 | 不可控:随机数 | 端口 OrderNumbers |
| 保存订单 | 表结构 | 不可控:数据库 | 端口 OrderRepository + JDBC 实现 |
四、拆分之后的结构
业务核心只依赖 JDK,不知道 HTTP、JSON 和 JDBC 的存在:
/** 计价规则:纯函数,输入相同则输出相同 */
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、调用方法后再查库断言金额。拆分后,业务核心可以直接测:
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 个端到端测试 | 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」:这是在绕过边界。更好的做法是把那段逻辑提取成一个独立的类。
小结
关注点分离的出发点是:不同的变化原因和不可控的副作用,不应该挤在同一段代码里。可测试性是检验它最直接的反馈。一段需要启动数据库、依赖当前时间、只能查库断言的代码,往往也是一段改一处规则就要担心整条链路的代码。拆分之后,规则可以逐条验证,外部依赖可以单独替换,端到端测试只需要关心装配是否正确。
下一篇 读懂一个系统的设计 换一个方向:不是拆自己的代码,而是看成熟的系统怎样划分模型、接口和实现。
配套实验
- codesphere-labs/design/separation-of-concerns:拆分前后的测试对照,用时区固定白天与晚间两种结果(验证记录)
可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。
参考资料