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 层 | 一次性执行,装配无复用价值 |
| 纯计算库 | 任何容器依赖 | 使用方不该被迫接受你的技术栈 |
| 已有稳定框架的遗留系统 | 为统一而重写 | 迁移风险高于架构收益 |
自测
- 三条判据里,哪一条不通过时最该拒绝把逻辑交给框架?
- "为简单查询写三层"的判断标准是什么,不是层数?
- 批量处理为什么不适合用一个大
@Transactional包住整个循环? @ConditionalOnMissingBean失效通常是什么原因?- 域模型不依赖框架,对"能离开框架"有什么具体作用?
- Boot 2 → 3 迁移除包名替换外,还必须核对哪两件事?