2460 字
约 8 分钟
16
S18-A19 · 分层的越界:Controller 里的那三行业务逻辑

一个 Controller 里有三行"顺手"的代码:算运费、校验库存、拼优惠。它们最初只是"方便",半年后变成事实上的业务逻辑——因为三个接口都复制了这三行,而真正的 Service 里反而没有。改运费规则时,先改 Service 再改三个 Controller,改漏一个就是"两个接口运费不一致"。

分层越界的可怕之处在于它每一次都很小、都有正当理由,这个"发现时已成唯一实现"的性质,决定了两件事:它不可能通过"加强评审"治好(因为每一处看起来都很小),只能靠"结构上不留复制空间 + 可执行约束"。
而你发现它时它已经成为唯一的实现。判据不在"该不该分层",而在"这条逻辑以后只会在一个地方变吗"。


1. 三种越界的形态

形态 表现
上层做下层的事 Controller 算业务规则
下层知道上层 DAO 依赖 DTO/请求对象
跨层直连 Controller 直接用 DAO

第一项最常见,而它的正当理由是"就三行,放 Service 太重"——而"三行"会在半年后变成三十行。第一项还有个"时间"机制:它从"临时调试"开始("先在这里试一下"),然后被人复制到第二个接口,于是"临时"变成了"约定"。

第二项还会"反向传染":一旦 DAO 接受了一个 DTO,上层就没法换掉那个 DTO(因为 DAO 依赖它),于是"接口演进"变成了"全链路改名"。
第二项最隐蔽:DAO 的方法签名里出现了 HttpServletRequest 或某个 DTO,于是数据层"知道"了 Web 层。

2. 为什么它会持续发生

原因 说明
就近实现成本低 少写一个方法
评审难以界定 "三行"看不出问题
复用通过复制 第二个人复制第一处
缺少单一出口 没有"该放哪"的约定

第三项是关键机制:**越界不会扩散到"所有地方",而是扩散到"复制了那段代码的地方"**第三项还有一层"沉默"的代价:复制的几份之间不会互相报错,于是"两个接口运费不同"只能靠用户发现,而它不触发任何监控。
——于是它形成"多份实现",而每份都在各自演化。

第一项的"就近"是真实压力:评审时"为什么不在 Controller 直接做"这个问题,答案往往是"少写一层",而它是有效率的。所以禁止越界需要给出"该放哪"的明确答案,否则人会用脚投票。

3. 每种越界的代价

越界 代价
Controller 算规则 多接口不一致、无法复用
DAO 依赖上层对象 数据层无法独立测试
跨层直连 事务与校验被绕过
下层返回 DTO 上层无法自由演进

第二项还有个"编译"层面的表现:数据层的模块被迫依赖 web 模块,于是"单独构建数据层"变成不可能——而这正是"微服务拆分"时最先撞到的墙。
第二项的直接后果是"DAO 测试要构造 Web 对象",于是"没人写 DAO 测试"——而这会漏掉 SQL 层的错误。

第三项的后果更严重:Controller 直接调 DAO 时,事务注解通常不生效(在 Controller 层没有事务边界),于是"部分成功"。

// 反例:Controller 里算规则 + 直连 DAO
@PostMapping("/orders")
public Resp create(@RequestBody Req req) {
    var freight = calcFreight(req);          // 业务规则在此
    var stock = stockMapper.get(req.sku());  // 直连 DAO(无事务)
    if (stock < req.qty()) return Resp.fail("库存不足");
    orderMapper.insert(...);                 // 两次写不在同一事务
}

// 正例:Controller 只做协议转换
@PostMapping("/orders")
public Resp create(@RequestBody @Valid Req req) {
    return Resp.ok(orderService.create(CreateCmd.from(req)));
}

"越界"的搜索方式要具体到"可复制的代码"上,而不是"看起来像业务"的代码:

// 三步定位(十分钟)
1 搜 Controller 里的 if/for/运算(业务规则的形状)
2 搜 Controller 里对 Mapper/Repository 的直接引用
3 搜同一段 SQL/表达式是否出现在两个以上 Controller
// 第 3 步是关键:出现两次就已经是"多份实现"

4. 怎么让它"无法发生"

手段 效果
包结构 + 依赖检查(ArchUnit) 编译/CI 拦截
下层只接受"基本类型/DTO-free"参数 从签名上隔断
Controller 只允许一行委托 约定 + 评审
单出口原则(一个业务动作一个 Service 方法) 从结构上消除复制

第一项最硬:把"Controller 不得依赖 Mapper"写成可执行断言,违反即红。第一项的落地成本很低:一个测试类、三条断言、十分钟。而它带来的收益是"半年内不会长回来",这与文档约定的效果差别巨大。

第四项在实践中的写法是"一个用例一个方法":OrderService.createOrder(cmd)、OrderService.cancelOrder(cmd),而不是"一个实体一个 Service 十个通用方法"。后者会诱发"在 Controller 里拼调用"。
第四项最根本:如果每个业务动作都有唯一的 Service 方法,那 Controller 里就没有"可复制的东西"——复制的前提是"这段逻辑只存在于 Controller"。

// ArchUnit:把分层写成断言
@ArchTest static final ArchRule controller不依赖Mapper =
    noClasses().that().haveSimpleNameEndingWith("Controller")
        .should().dependOnClassesThat().haveSimpleNameEndingWith("Mapper");

@ArchTest static final ArchRule domain不依赖web =
    noClasses().that().resideInAPackage("..domain..")
        .should().dependOnClassesThat().resideInAPackage("..web..");

5. 已经越界了怎么办

步骤 动作
1 找"复制了几份" 用搜索定位
2 抽成一个 Service 方法 单一实现
3 逐个替换调用点 每批可验证
4 加依赖检查 防复发

第一步最容易被跳过:只看一处改动,会以为"就这一处",而实际有三处。所以"搜索同名逻辑"是迁移的起点。

第四项是关键:不加上检查,半年后还会长回来。而这类检查的成本很低(一个测试类)。

迁移时有个"顺序"上的取舍:先抽实现,还是先删重复?经验是先抽(新方法上线),再逐个替换,最后删旧代码——这样每一步都能独立验证,而"一次改三个 Controller"很难回滚。

// 迁移的三步(每步可验证)
1 新增 OrderService.createOrder(内部逻辑从 Controller 搬过来)
2 一个 Controller 切到新方法 → 跑该接口的测试
3 其余 Controller 逐个切;全部切完再删 Controller 里的旧逻辑

6. 排错:五种"分层没起作用"

现象 成因 处理方向
两个接口结果不一致 规则复制在多处 抽单一实现
事务不生效 跨层直连、无事务边界 收进 Service
DAO 测试写不下去 依赖上层对象 改签名
改一处要改三处 无单一出口 单出口原则
越界总在复发 无检查 加 ArchUnit

第五项是"治理"问题:没有可执行约束时,分层只是"建议",而建议在交付压力下必然失效。第五项还有个"隐性"代价:越界会让人对"分层"这件事失去信任("反正都在 Controller 里"),于是后续的架构约束更难推。

最后一条"度量"方式:统计 Controller 的平均行数。它不精确,但很有效——一个 Controller 方法超过 15 行,通常已经开始做业务了。这个数可以放进评审清单,作为"要不要看一层"的信号。

// 三个粗略指标(用于发现越界趋势,不做硬门槛)
Controller 方法平均行数 > 12        → 有越界倾向
Controller 里出现 > 3 个 Mapper 引用 → 直连迹象
同一表达式/常量在 2 个 Controller 出现 → 已复制

7. 边界:什么情况"上层做点事"是合理的

场景 为什么 做法
纯协议转换(DTO→Cmd) 属于边界职责 允许
参数格式校验(@Valid) 边界职责 允许
响应包装(错误码映射) 边界职责 允许
极简 CRUD(无业务规则) 无"规则"可越界 允许

最后一条与"团队规模"有关的边界:人少时"上层做点事"的代价低(改动集中),人多时代价高(跨人协作)。所以同一个项目的不同阶段,允许的越界程度不同——而"约定"应当随规模收紧。
判断口径一句话:这条逻辑"以后会不会因为业务原因变化"。会(运费规则、库存策略),必须在 Service;不会(字段改名、格式转换),放边界层是合理的。


8. 自测

  1. 三种越界形态里,哪一种最隐蔽?"DAO 依赖上层对象"造成什么直接后果?
  2. 为什么越界"不扩散到所有地方,而是扩散到复制过的地方"?
  3. "Controller 直连 DAO"为什么会导致事务不生效?
  4. 哪两种手段最根本?为什么"单出口原则"能从结构上消除复制?
  5. 已经越界时,第一步该做什么?为什么"不加检查就会长回来"?
  6. 判断"上层能否做点事"的那个问题是什么?
S18-A19 · 分层的越界:Controller 里的那三行业务逻辑
https://www.clxhxhhr.top/posts/6545/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。