2864 字
约 9 分钟
18
S07-A03 · Spring 生态边界:什么时候该离开框架

Spring 的默认叙事是"什么都能做":Web、事务、集成、调度、缓存、安全,一层注解就接上了。但架构决策要回答的不是"能不能",而是在这个位置引入框架,换来什么、锁住什么、什么时候该退出来。这篇文章谈退出条件。


1. 它解决的问题域,与它不解决的问题

它擅长 它不负责
组件装配与生命周期 领域模型怎么划(聚合、边界、不变式)
声明式事务的边界管理 事务之外的一致性策略(跨服务、跨存储)
Web 层抽象(映射、绑定、校验、异常) 接口契约的稳定性与版本策略
与中间件集成(消息、缓存、任务) 吞吐与延迟的实际水平

把第二列交给框架是常见的错位:框架能保证"事务提交了",不能保证"这次提交在业务上正确"。框架管机制,架构管策略,混淆两者的结果是配置文件里堆满业务决策。


2. 三条判据:什么该由框架做

判据 通过的条件 反例
横切关注点 与业务语义无关,多个模块重复出现 订单状态机、计价规则
可替换性 换实现不影响业务代码 业务逻辑被绑在某个注解语义上
测试成本 引入框架后测试更容易写 每个用例都要起容器

三条里只要"可替换性"不通过,就不该交给框架——因为框架的抽象一旦泄漏,业务代码就被迫理解它的内部规则。


3. 过度的四个信号

信号 具体表现 判断方法
为简单查询写三层 Controller → Service → Mapper,方法体只有一行转发 数一下每层的"有效行数"
注解堆叠 一个方法上 6 个注解,读不出执行顺序 新人能否在 5 分钟内说清调用链
配置比代码多 application.yml 比业务类长 配置里有多少是"默认值抄一遍"
启动时间成为问题 冷启动秒级影响扩缩容与发布 记录启动耗时与 bean 数趋势

第一类的反例对照:

// 三层转发:价值趋近于零
public OrderDto get(Long id) { return repo.find(id).map(mapper::toDto).orElseThrow(); }

// 直接用 JDBC 模板,省掉两层空洞
var dto = jdbc.queryForObject(
    "select id, amount, status from orders where id = ?",
    (rs, i) -> new OrderDto(rs.getLong(1), rs.getBigDecimal(2), rs.getString(3)), id);

判据不是"层数多少",而是每层是否做了不可省略的转换或决策。只做转发的层会在重构时最先被绕过。


4. 该用原生手段的四类场景

场景 为什么不套框架 用什么
批量数据处理 事务边界是整批,声明式事务按方法切不合适 JdbcTemplate.batchUpdate、游标读、分批提交
极致延迟路径 代理、拦截器、反射都在关键路径上 直接 Servlet / 手写编解码
一次性任务 只为跑一次的脚本引入容器不划算 main + 少量库
作为库被引用 使用者不该被迫继承你的容器 纯 POJO API,无 @Component
// 批量:显式控制批次与事务边界,而不是靠 @Transactional 包住整个循环
try (var ps = conn.prepareStatement("insert into events(id, payload) values (?, ?)")) {
    int n = 0;
    for (var e : batch) {
        ps.setLong(1, e.id()); ps.setBytes(2, e.payload()); ps.addBatch();
        if (++n % 500 == 0) { ps.executeBatch(); conn.commit(); }
    }
    ps.executeBatch(); conn.commit();
}

批量场景下最贵的错误是"一个大事务包十万行":锁持有时间长、undo 膨胀、失败要整体重来。


5. 与其它选择的对照

选择 收益 代价
Spring Boot 生态完整、招聘容易、集成成本低 启动与内存开销、隐式行为多
Quarkus / Micronaut 编译期装配、启动与内存更省 生态与资料较少,部分库不兼容
纯 Servlet + 少量库 依赖最小、行为全透明 装配、事务、校验都要自己写
函数式/轻量框架 请求模型简单、可预测 复杂集成需自建

迁移代价要按"被隐式使用的能力"估算,而不是按代码行数:用到了声明式事务、AOP、条件装配、自动配置扩展点的项目,迁移成本远高于看上去的规模。只用了 Web + DI 的项目,迁移是几天;用了 8 个 starter 的项目,迁移是几个月。


6. 自动配置的双刃

约定优于配置的收益是"默认能跑",代价是行为藏在依赖里。排查靠三样东西:

# 启动时打印条件评估报告:哪些自动配置生效、哪些因条件不满足被跳过
java -jar app.jar --debug
# 运行期核对实际生效的 bean 与来源
curl -s localhost:8080/actuator/conditions | jq '.contexts[].beans'
curl -s localhost:8080/actuator/beans | jq '.contexts[].beans | keys'

判读顺序是:症状发生在哪个 bean → 它由哪个自动配置类提供 → 该类的条件是否成立 → 谁把它排除了。@ConditionalOnMissingBean 是最容易踩的一条:你自己定义的 bean 只有在自动配置之前注册才能覆盖它,顺序不对就是"写了不生效"。


7. 哪些功能不该用代理实现

代理只对"经过容器调用的方法"生效,所以下面这些用 AOP 做,等于埋一个只在特定调用路径下失效的坑:

功能 为什么代理不合适 替代
内部方法调用的日志/审计 自调用不走代理 显式调用或事件
细粒度计费/计量 需要精确到每次执行,代理会漏 在服务入口显式埋点
事务内的重试 重试与事务边界冲突 外层重试 + 幂等
权限判定 依赖参数与上下文,切点难表达 拦截器/过滤器,或显式校验
缓存 key 生成与失效语义复杂 显式缓存 API + 明确 TTL

一句话:代理适合"与业务无关、且一定能被调用到"的横切逻辑,其余交给显式代码,可读性反而更高。


8. 依赖注入的滥用

用法 症状 约束建议
字段注入 无法 final、单测要靠反射或容器 团队统一构造注入
注入 ApplicationContext 当服务定位器 依赖关系不可见、启动期无法校验 禁止;需要延迟获取用 ObjectProvider
注入过多依赖(>7 个) 类职责过载 视为拆分信号
循环依赖靠 @Lazy 绕过 设计问题被掩盖 允许启动失败,逼出重构

构造注入的真正价值不是风格,而是让依赖图在编译期可见、在测试里可替换。字段注入把这两个能力都交换掉了。


9. 模块化与包结构治理

@ComponentScan 的默认范围是启动类所在包及其子包,这条规则一旦被"图省事"放宽到 com.example,模块边界就消失了。约束要靠可执行的规则,而不是文档:

@AnalyzeClasses(packages = "com.example.order")
class ModuleBoundaryTest {
    @ArchTest static final ArchRule domain_isolation =
        noClasses().that().resideInAPackage("..order.domain..")
            .should().dependOnClassesThat().resideInAPackage("..order.web..");
    @ArchTest static final ArchRule no_framework_in_domain =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat().resideInAnyPackage("org.springframework..");
}

域模型不依赖框架,是"能离开框架"的前提。反过来,域里塞满 @Entity、@JsonIgnore、@Transactional 之后,退出成本会随时间单调上升。


10. 测试策略:容器成本决定比例

测试形态 单次成本 适合验证
纯单测(无容器) 毫秒级 领域逻辑、算法、状态机
切片测试(@WebMvcTest、@DataJpaTest) 百毫秒~秒级 映射、序列化、SQL
全量 @SpringBootTest 数秒起,随上下文数量线性增长 装配正确性、跨层契约
@WebMvcTest(OrderController.class)   // 只起 Web 层,不加载数据层
class OrderControllerTest { /* MockMvc */ }

成本结构决定比例:纯单测应占多数,全量测试只覆盖装配与关键链路。上下文缓存是隐形开销——不同 @MockBean 组合会产生新的上下文,测试越多越慢,直到有人开始 @Disabled。


11. 升级路径:Boot 2 → 3 的通用方法

javax → jakarta 迁移的本质是包名替换 + 依赖版本对齐 + 行为差异核对。可复用的方法:

grep -rl "javax\.\(servlet\|persistence\|validation\|annotation\)" --include=*.java . | wc -l
# 批量改写优先用 OpenRewrite 而不是 sed
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
  -Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0

然后按三件事收尾:确认没有残留 javax 传递依赖(dependency:tree 搜一遍)、核对安全与序列化相关的默认行为变化、用金丝雀确认启动耗时与内存变化在预期内。


排错

现象 常见原因 处理
自定义 bean 不生效 @ConditionalOnMissingBean 顺序靠后 用 @AutoConfigureBefore 或显式排除自动配置
加了注解但没反应 自调用绕过代理 拆到另一个 bean,或改显式调用
启动越来越慢 上下文分裂、扫描范围过大 收窄 @ComponentScan,减少 @MockBean 组合
升级后 404/415 jakarta 包与消息转换器不匹配 核对依赖树与 HttpMessageConverter 注册
事务没回滚 异常被 catch、或方法非 public 见事务专题;此处只确认代理是否生效
测试偶发失败 上下文缓存与共享状态 隔离可变状态,避免静态缓存

边界

场景 不需要容器 理由
单个小服务(几个接口) 完整 Boot 栈 装配收益 < 启动与内存成本
Lambda / 短生命周期函数 冷启动敏感的框架 启动时间直接变成用户等待
脚本任务 DI、AOP、Web 层 一次性执行,装配无复用价值
纯计算库 任何容器依赖 使用方不该被迫接受你的技术栈
已有稳定框架的遗留系统 为统一而重写 迁移风险高于架构收益

自测

  1. 三条判据里,哪一条不通过时最该拒绝把逻辑交给框架?
  2. "为简单查询写三层"的判断标准是什么,不是层数?
  3. 批量处理为什么不适合用一个大 @Transactional 包住整个循环?
  4. @ConditionalOnMissingBean 失效通常是什么原因?
  5. 域模型不依赖框架,对"能离开框架"有什么具体作用?
  6. Boot 2 → 3 迁移除包名替换外,还必须核对哪两件事?
S07-A03 · Spring 生态边界:什么时候该离开框架
https://www.clxhxhhr.top/posts/6070/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。