2290 字
约 7 分钟
16
S18-X12 · 分层架构的边界:Controller 该有多薄

Controller 变胖不是风格问题,是耦合问题。它一旦开始判断业务、拼 SQL、写事务,就同时绑住三样东西:HTTP 协议、业务规则、持久化细节。任何一样变化都要动它,而它又必须起容器、造 MockMvc 才能测。这篇只谈边界落在哪里、怎么判断越界、越界以后往哪搬。


1. 三层各自的独占职责

层 独占职责 不该出现 判据(出现即越界)
Controller 协议转换、参数绑定、格式校验、状态码与响应体形态 业务分支、事务、SQL、循环拼装 出现业务状态的 if、@Transactional、循环里调 Service
Service 用例编排、事务边界、领域规则、跨聚合协作 HTTP 对象、HttpServletRequest、ResponseEntity 签名含 *Request/*Response,或 import jakarta.servlet
Repository 持久化、查询组装、映射 业务规则、事务编排、远程调用 出现业务条件分支,或在 Mapper 里调 Feign/RestClient

用法是逐行 grep,不是逐行读代码:@Transactional 出现在 @RestController 类里即是硬伤。

1.1 「薄」的可操作判据

不靠感觉,用四条机械标准:

判据 合规 越界信号
方法体行数 通常 3–15 行 超过 30 行基本含业务逻辑
缩进深度 不超过 2 层 出现嵌套 if/for
调用的 Service 数 一般 1 个 连续调 3 个以上再手工拼装
返回对象 直接返回 Service 结果或一次转换 方法内 new 复合响应体并逐字段赋值
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderApplicationService orderService;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public OrderDetailResponse create(@Valid @RequestBody CreateOrderRequest request) {
        return OrderDetailResponse.from(orderService.create(request.toCommand()));
    }

    @GetMapping("/{id}")
    public OrderDetailResponse get(@PathVariable Long id) {
        return OrderDetailResponse.from(orderService.detail(id));
    }
}

2. Controller 变胖的六种形态

按频率排列。共同点是都能写出「看起来正常」的版本,只是职责错位。

# 形态 典型写法 该上移到哪 代价
1 拼返回体 循环里补用户名、补文案 Service 或 Assembler 契约散落,改字段要动 N 个 Controller
2 做权限判断 if (!user.isAdmin()) throw 方法级注解 + 拦截器 规则无法复用,接口之间不一致
3 写事务 Controller 上加 @Transactional Service 用例方法 事务覆盖序列化与网络写,连接占用拉长
4 多 Service 拼装 aService.find() + bService.find() 手工 join 应用服务编排或查询侧聚合 一次查询变 N 次调用
5 做缓存 Controller 里手写 redisTemplate Service 或缓存切面 缓存键规则分散,失效无从下手
6 做计算 金额换算、状态机推进、时间窗口 Service 或领域对象 规则被复制到多个入口,改一处漏一处
// 越界:一个方法里同时混进权限、缓存、事务与拼装
@PostMapping("/{id}/confirm")
public ResponseEntity<?> confirm(@PathVariable Long id, HttpServletRequest req) {
    Long userId = (Long) req.getSession().getAttribute("uid");
    Order o = orderService.get(id);
    if (!o.getUserId().equals(userId) && !req.isUserInRole("ADMIN")) {   // 权限
        return ResponseEntity.status(403).body("no");
    }
    redisTemplate.delete("order:detail:" + id);                          // 缓存
    o.setStatus(2);
    orderService.update(o);                                              // 事务缺失
    User u = userService.get(o.getUserId());                             // 拼装
    return ResponseEntity.ok(Map.of("orderNo", o.getOrderNo(), "userName", u.getName()));
}

3. Service 变胖的下一个陷阱

Controller 瘦下来之后逻辑会全堆到 Service,于是出现第二个坑:万能 Service。症状是类越来越长、方法间共享私有字段、单测要 mock 十几个依赖。

拆法 拆的依据 适用信号 风险
按用例拆 一个写用例一个类(CreateOrderHandler) 方法之间几乎不共享状态 类数量增加,编排层变多
按聚合拆 一个聚合根一个 Service 存在明确聚合根与不变量 跨聚合事务需额外编排
按读写拆 写走领域对象,读走查询服务直出 DTO 查询侧复杂、报表多 写模型与读模型容易漂移

编排层只做转发与事务组合:

@Service
@RequiredArgsConstructor
public class OrderApplicationService {

    private final CreateOrderHandler createHandler;
    private final CancelOrderHandler cancelHandler;
    private final OrderQueryService queryService;

    @Transactional
    public OrderDetail create(CreateOrderCommand cmd) { return createHandler.handle(cmd); }

    @Transactional
    public void cancel(CancelOrderCommand cmd) { cancelHandler.handle(cmd); }

    public OrderDetail detail(Long id) { return queryService.detail(id); }
}

3.1 贫血模型的连带效应

Service 变胖往往和贫血模型是一对:实体只有 getter/setter,规则全写在 Service,「状态能否从 A 到 B」在 pay、cancel、refund 各写一遍。规则搬回实体,Service 自然变瘦:

public class Order {
    private OrderStatus status;

    public void cancel(String reason) {
        if (status != OrderStatus.CREATED && status != OrderStatus.PAID) {
            throw new IllegalStateException("当前状态不可取消: " + status);
        }
        this.status = OrderStatus.CANCELED;
    }
}

4. 跨层调用、DTO 与异常

4.1 Controller 直接调 Mapper 的代价

丢掉的能力 后果
事务边界可控 一个请求里多次独立查询,无法在同一快照上读
规则收口 权限校验、租户过滤、逻辑删除无处安放
可替换性 表结构变化直接冒到 HTTP 层

唯一例外:纯只读、无规则、无复用的字典查询。

4.2 DTO 转换放哪

位置 做法 适用 不适用
Controller 内静态工厂 OrderDetailResponse.from(detail) 简单映射 字段多、有条件映射
独立 Assembler OrderAssembler.toDetail(order) 多接口复用映射 一次性接口
MapStruct 编译期生成实现 字段多、需批量映射 映射含业务分支
Service 返回 DTO 查询侧直出 DTO CQRS 读模型 写模型(污染领域层)

原则:领域对象不外泄出 Service,DTO 不进 Service 入参(写用例用 Command)。这条边界比「用不用 MapStruct」重要得多。

4.3 异常在哪一层转换

只做一次,放在最外层:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> onBusiness(BusinessException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponse(ex.getCode(), ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> onInvalid(MethodArgumentNotValidException ex) {
        return ResponseEntity.badRequest().body(new ErrorResponse("PARAM_INVALID", "参数校验失败"));
    }
}

Service 抛业务异常,领域层抛语义异常(IllegalStateException),@RestControllerAdvice 翻译成状态码与错误体;中间层不 catch 再包装。


5. 分层的可测性差异

层 测试类型 起 Spring 容器 典型用例
Controller 契约测试(@WebMvcTest) 否(切片) 参数校验、状态码、序列化字段名
Service 单元 + 集成测试 写用例需要 用例分支、事务回滚、异常路径
Repository @DataJpaTest / Testcontainers 需要 SQL 正确性、索引命中

Controller 越薄,切片测试价值越高:mock 一个 Service 就能断言契约;一旦含业务逻辑,切片测试退化成「再测一遍 Service」。

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;
    @MockBean OrderApplicationService orderService;

    @Test
    void create_returns201_and_bodyShape() throws Exception {
        given(orderService.create(any())).willReturn(OrderFixture.detail());
        mvc.perform(post("/api/v1/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"items\":[{\"skuId\":1,\"qty\":2}]}"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.orderNo").exists());
    }
}

6. 排错:分层被破坏的六种表现

现象 根因 处理
改字段要动多个 Controller 返回体在 Controller 里手工拼装 抽 Assembler,响应体收口
线上出现部分更新的脏数据 事务注解加在 Controller 或 private 方法上 移入 Service 的 public 用例方法
单测跑一次要十几秒 Service 依赖 Servlet 对象或静态上下文 剥离 HTTP 对象,签名换 Command
LazyInitializationException Controller 访问未加载关联 查询侧直出 DTO,实体不带出事务
两个 Service 互相注入 用例被切到了错误的边界 抽公共下游 Service,或合并用例
「薄」得离谱,逻辑进了 XxxUtils 用工具类规避分层 工具类只放无状态纯函数

最后一条最易被忽略:把逻辑搬进 OrderUtils,Controller 薄了,耦合没少,还更难被测出。


7. 边界:什么时候这套判据不适用

场景 是否坚持三层 说明
CRUD 后台、字段进出库 可放宽 确无业务规则时 Controller 可直连查询服务
纯查询报表接口 可放宽 Service 查询服务直出 DTO
强规则核心域 必须收紧 权限、状态机、金额计算一律下沉
小团队、迭代快 结构可简化 层数可减,但事务与 HTTP 不能混

一句话:分层不是为了层数,而是「回答一次变更只需要看一个地方」。


8. 自测

  1. 你会用哪几条机械判据判断一个 Controller 是否越界?
  2. Controller 上出现 @Transactional 会带来哪些具体后果?
  3. 「按用例拆 Service」和「按聚合拆 Service」分别适合什么信号?
  4. 把逻辑从 Controller 搬到 XxxUtils 为什么不算解决耦合?
  5. DTO 与 Command 分别应该出现在哪一层的签名里?
  6. Controller 的切片测试真正能守住的是什么?
S18-X12 · 分层架构的边界:Controller 该有多薄
https://www.clxhxhhr.top/posts/5862/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。