2594 字
约 8 分钟
21
S06-A14 · 依赖注入容器的边界:什么时候该手工装配

一个 Spring 项目里有四百多个 Bean,启动要四十秒。某个模块加了 @Lazy 试图加快启动,结果运行时偶发拿到未初始化的代理,错误出现在很远的地方。最后发现问题不在 @Lazy,而在于这个模块根本不需要进入容器——它是一个纯计算模块,被依赖注入的便利裹挟着进了容器。

容器解决的是"对象之间怎么连起来",它不解决"这个对象该不该被谁拥有"。放进容器的成本很少被计算:启动时间、隐式依赖、循环依赖的可能、以及装配关系从代码里消失。


1. 容器的收益与成本

收益 成本
无需手工传递依赖 装配关系不在调用处可见
生命周期统一管理 启动时集中创建,无法按需
横切能力(事务、代理)易加 代理带来隐式行为与栈变化
配置与实现分离 依赖来源分散在注解、配置、条件里

第二行最容易被忽略:容器的创建是集中的。这意味着任何一个 Bean 初始化失败都会拖垮整个上下文,而手工装配时只有真正用到它的路径才会失败。

第四行是长期成本:一个 Bean 的最终实现可能是注解、配置开关、条件装配三者共同决定的,读代码无法确定"运行时到底是哪个"。这类不确定性在排错时最耗时。

2. 五类不必进容器的对象

类型 为什么 该怎么做
纯函数/无状态计算 无依赖、无生命周期 静态方法或普通 new
值对象(DTO、参数) 每次不同 直接构造
短生命周期临时对象 创建成本低 就地 new
工具类(格式化、转换) 无状态 静态方法
只在单处使用的辅助类 无需替换 直接 new

第五项在重构期最容易被破坏:为了减少 new,把临时对象也交给容器。这类对象通常是"每次调用都不同"的(比如一次请求的上下文),交给容器意味着要么用原型作用域,要么变成共享状态——后者是并发问题的来源。

第一项最典型:一个只有入参和返回值的计算类,进入容器后,它的使用者反而多了一个依赖注入点,测试时还要构造容器或 mock 注入。

// 不必进容器:纯计算,无状态,无依赖
public final class FeeCalculator {
    public static BigDecimal compute(BigDecimal amount, Rate rate) { ... }
}

// 进入容器后反而更麻烦:要么 mock 注入,要么起容器
@Service
public class FeeCalculator {
    public BigDecimal compute(...) { ... }
}

3. 五类应当进容器的对象

类型 为什么
有外部资源(连接、客户端) 需要统一关闭与复用
需要横切能力(事务、缓存、审计) 由代理实现
有配置差异的实现 需要按环境切换
需要单例共享状态 需要统一实例
需要被多处注入的横切组件 避免重复构造

第二项是容器不可替代的能力:声明式事务、方法级缓存、审计日志都依赖代理。手工装配时这些能力要么自己写,要么放弃。

可以用一行检查发现这类问题:某个类型在代码里被 new 了几次。资源型对象的 new 次数应当只有一处。

// 找出被 new 多次的资源型对象(按需替换类名)
grep -rn "new HttpClient\|new JedisPool\|new HikariDataSource" src/ | wc -l
// 结果 > 1 → 资源共享与关闭都会出问题,应改为容器单例

第一项值得展开:连接池、HTTP 客户端这类对象,如果每处 new 一个,资源会被悄悄放大——十个地方 new 十次,连接数就乘十。它们必须单例并由容器统一关闭。

4. 装配关系必须能被读出来

做法 可读性
构造器注入 + final 字段 高:依赖一目了然
字段注入(@Autowired 字段) 低:看不出一共有多少依赖
配置类集中装配(@Bean) 高:装配关系集中在一处
条件装配 + 配置开关 中:需要查配置才知道实际实现

可以用一个很直接的数字判断:构造器参数个数。超过五个通常说明这个类承担了太多职责;而字段注入让这个数字隐藏起来,问题就不会在评审时暴露。构造器注入把"依赖太多"变成肉眼可见。

第一种与第三种是可维护的形态。字段注入的问题不只是"不推荐",而是它让一个类有多少依赖变得不可数——三十个字段注入的类,在代码里看起来是三十个字段,而不是三十个依赖。

// 构造器注入:依赖在签名上,测试不需要容器
public class OrderService {
    private final OrderRepo repo;
    private final FeeCalculator fee;

    public OrderService(OrderRepo repo, FeeCalculator fee) {
        this.repo = repo;
        this.fee = fee;
    }
}

// 测试直接 new,不需要 Spring 上下文
var svc = new OrderService(new FakeRepo(), new FeeCalculator());

5. 启动时间与依赖图

现象 原因 处理方向
启动慢 Bean 数量多或初始化重 拆分上下文、延迟初始化
循环依赖 两个 Bean 互相注入 引入第三方或改设计
偶发拿到代理对象 @Lazy 或 AOP 未初始化 去懒加载,改按需创建
某 Bean 初始化失败全站不可用 集中于同一上下文 隔离到独立模块

第三项是"为了快"引入 @Lazy 的典型代价:懒加载把错误从启动期推到了运行期。启动失败的代价是明确的(发布不通过),运行期失败的代价是隐性的(偶发、难复现)。

// 两种初始化的取舍
启动期失败:发布卡住、错误明确、可回滚        ← 更可控
运行期失败:偶发、依赖流量、定位困难          ← 更难处理
// 结论:只有在"初始化确实很重且不影响主路径"时才用懒加载

装配关系还需要能被检查。一个可用的做法是把"谁注入了谁"打印出来,与预期的分层对照:

// 启动时输出依赖图摘要,用于人工核对分层是否被打破
web  → service (12)   期望 12,实际 12
web  → repository (3) 期望 0,实际 3   ← 分层被绕过
service → repository (18)
// 只需一行统计,就能发现"为了省事直接注入下层"的扩散趋势

6. 排错:五种"容器带来的怪问题"

现象 成因 处理方向
改实现后行为没变 条件装配选中了另一个 Bean 打印实际选中的实现
事务不生效 方法被同类内部调用,绕过代理 拆类或改调用方式
缓存不生效 同上,代理未被经过 同上
单例里出现并发问题 有状态 Bean 被当单例 改为无状态或改作用域
启动顺序问题 依赖初始化顺序不确定 用显式依赖或事件

验证方法很简单:在方法上加日志,看它是否被调用。事务方法内部调用时,外部调用的日志会打,内部调用的不会——因为内部那次没有经过代理。

前两项是同一根因的两个表现:代理只在"从容器取出并调用"时生效。同一个类内部方法互相调用,等于绕过了代理,事务与缓存都不会生效。这个坑在重构时最容易踩——把一个类的方法拆分后忘记调整调用路径。

7. 边界:什么情况不必讲究

场景 为什么 做法
极小应用(几个类) 手工装配更快 直接在 main 里 new
一次性脚本 无生命周期管理需求 不用容器
函数式计算密集模块 无依赖、无资源 静态方法
已用其他装配方式(如 Go 的手工注入) 该语言没有容器文化 保持手工装配

与分层有关的边界值得单独记:容器不该成为跨层调用的通道。如果允许 web 层直接注入数据层,那么分层只是目录结构,不是约束。约束只能靠架构测试(如依赖方向检查)来守,容器本身不提供。

判断口径一句话:这个对象需不需要"统一创建、统一关闭、统一加强"。三者都不需要,容器只是多一层间接。


8. 自测

  1. 容器解决的是什么问题?它不解决什么问题?
  2. 为什么"创建集中"既是收益也是成本?举出集中创建带来的具体失败形态。
  3. 五类不必进容器的对象中,哪一类最常被错误注册?错误注册后测试成本如何变化?
  4. 为什么连接池、HTTP 客户端必须单例并由容器关闭?
  5. @Lazy 把哪类成本从启动期转移到了运行期?什么情况下才值得这么做?
  6. "事务不生效"与"容器带来的怪问题"的共同根因是什么?重构时为什么容易踩到?
S06-A14 · 依赖注入容器的边界:什么时候该手工装配
https://www.clxhxhhr.top/posts/6411/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。