CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
依赖注入概念
这一课你将能做到
说清 IoC 与 DI 的关系:IoC 是「控制权反转」的思想,DI 是「由容器注入依赖」的实现手段
对比构造器注入、Setter 注入、字段注入三者的利弊,并解释为什么构造器注入是默认推荐
用 50 行代码手写一个最小 DI 容器,理解 Spring 容器「反射 + 单例缓存」的工作原理
为什么要学
没有 DI 的代码长这样:private final TrackRepository repo = new JdbcTrackRepository();——Service 自己 new 依赖,后果三连:单元测试没法把 MySQL 换成 Mock、换实现要改源码重新编译、读类签名根本看不出它依赖谁。Spring 的核心价值之一就是把「对象怎么来」这件事从你的代码里拿走。
面试角度:「什么是 IoC/DI」「为什么推荐构造器注入」「Bean 默认作用域与线程安全」是 Spring 面试的第一道门槛,概念不清后面全塌。本课先手写原理,第 sb-ioc 课再对注解,顺序不能反。
先建立图像
传统写法:Service 自己 new 依赖
┌────────────────────────────┐
│ TrackService │
│ repo = new JdbcRepo() ←── 内部写死,测试换不掉
└────────────────────────────┘
DI 写法:依赖从构造器传入,谁创建谁说了算是外部的事
┌────────────────────┐ ┌───────────────────┐
│ TrackService │ ←───── │ 容器 / 测试代码 │
│ (需要 Repository) │ 注入 │ 决定给哪个实现 │
└────────────────────┘ └───────────────────┘
控制权变化:new 的控制权 从 你的代码 → 反转给 容器(IoC)
实现手段: 容器通过构造器 把依赖传进来 (DI)
跟着做
步骤 1:先看「没有 DI」的痛
public class LegacyDemo {
static interface OrderRepository { String findUser(long orderId); }
static class OrderService {
// 依赖在内部写死:测试无法替换、换 MySQL 要改源码
private final OrderRepository repo = new OrderRepository() {
public String findUser(long orderId) { return "db-user-" + orderId; }
};
String userName(long orderId) { return repo.findUser(orderId); }
}
public static void main(String[] args) {
System.out.println(new OrderService().userName(42)); // 想换 Mock?改代码吧
}
}
步骤 2:构造器注入 + 接口翻转依赖
新建 DiDemo.java,完整可运行:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class DiDemo {
interface OrderRepository { String findUser(long orderId); }
static class MysqlOrderRepository implements OrderRepository {
public String findUser(long orderId) { return "db-user-" + orderId; }
}
/** 依赖从构造器传入:类只声明「需要什么」,不关心「给什么」 */
static class OrderService {
private final OrderRepository repository;
OrderService(OrderRepository repository) {
this.repository = repository;
}
String userName(long orderId) { return repository.findUser(orderId); }
}
public static void main(String[] args) {
OrderService prod = new OrderService(new MysqlOrderRepository());
System.out.println(prod.userName(42));
OrderService test = new OrderService(id -> "mock-user-" + id); // 测试直接换实现
System.out.println(test.userName(42));
}
}
运行:java DiDemo.java。两行输出不同——同一个 Service 类,依赖由外部决定,这就是 DI 全部价值的最小体现。
步骤 3:手写一个最小 DI 容器 继续在 DiDemo.java 中追加,并用 main 验证:
/** 最小 IoC 容器:绑定接口→实现,递归构造,单例缓存 */
static class Container {
private final Map<Class<?>, Class<?>> bindings = new ConcurrentHashMap<>();
private final Map<Class<?>, Object> singletons = new ConcurrentHashMap<>();
<T> void bind(Class<T> api, Class<? extends T> impl) {
bindings.put(api, impl);
}
@SuppressWarnings("unchecked")
<T> T get(Class<T> api) {
return (T) singletons.computeIfAbsent(api, type -> {
try {
Class<?> impl = bindings.getOrDefault(type, type);
var ctor = impl.getDeclaredConstructors()[0];
Object[] args = new Object[ctor.getParameterCount()];
for (int i = 0; i < args.length; i++) {
args[i] = get(ctor.getParameterTypes()[i]); // 递归注入依赖
}
return ctor.newInstance(args);
} catch (Exception e) {
throw new IllegalStateException("无法创建 " + api.getName(), e);
}
});
}
}
public static void main(String[] args) {
Container container = new Container();
container.bind(OrderRepository.class, MysqlOrderRepository.class);
OrderService s1 = container.get(OrderService.class);
OrderService s2 = container.get(OrderService.class);
System.out.println(s1.userName(42));
System.out.println(s1 == s2); // true:单例缓存生效
}
这 20 行就是 Spring 容器的心智模型:绑定表 + 反射调构造器递归装配 + 单例缓存 。Spring 只是把它做成了注解驱动、Bean 生命周期管理、AOP 织入的工业级版本。
讲清楚要点
IoC 是思想,DI 是手段 :控制反转说的是「对象的创建与装配权从业务代码转移给容器」;依赖注入是这个转移的具体实现(构造器/Setter/字段传入)。面试先分概念再谈实现。
构造器注入是默认推荐 :依赖不可变(可 final)、依赖一目了然(看构造器签名)、对象构造完成即可用(没有半初始化状态)、单测直接 new 不需要容器、循环依赖在启动期直接暴露而不是静默掩盖。
字段注入的两宗罪 :@Autowired 打在字段上,依赖隐藏在类内部(不 new 出来根本不知道需要什么);脱离容器无法实例化,单测必须借助反射工具。仅适合快速原型。
注入接口而非实现 :Service 依赖 TrackRepository 接口,容器按类型装配;多实现时用 @Primary 指定默认、@Qualifier 按名字挑选——测试时整体替换为 Mock。
单例 ≠ 无状态 :Spring Bean 默认 singleton,一个实例服务所有请求线程;Bean 内不存请求级可变状态(不用普通成员变量当计数器),就天然线程安全(呼应第 8 课)。
依赖方向要单向向下 :Controller → Service → Repository,每层只认识下一层的接口;反向依赖与循环依赖都是设计坏味道,Spring 只能解决循环注入,治不了设计问题。
你可能会懵的地方
IoC 和 DI 是同一个东西吗?
不是。IoC 是设计原则(谁来控制对象生命周期),DI 是最主流的实现方式(注入依赖);Service Locator 是另一种实现(主动向容器要),但会把查找逻辑混进代码,Spring 社区不推荐。
没有框架需要 DI 吗?
需要。手写工厂、参数层层透传都是朴素的依赖管理;DI 容器只是把这件事自动化。本课的 Container 在任何无框架项目里都能直接用。
为什么构造器注入能提前暴露循环依赖?
A 的构造器需要 B、B 的构造器需要 A,容器创建 A 时必须先有 B,创建 B 又必须先有 A——无解,启动即报 BeanCurrentlyInCreationException。字段注入可以「先造后补」,问题被推迟到运行期。
@Component/@Service/@Repository 有区别吗?
语义分层标注(组件/服务/持久层),对组件扫描而言效果相同;@Repository 会附带持久层异常转换。本质都是「把我注册为 Bean」。
什么是 Bean 的作用域?
singleton(默认,容器内一个实例)、prototype(每次获取新建)、以及 web 场景的 request/session。选 prototype 时注意:注入它的单例 Bean 只会在启动时注入一次,prototype 语义并不会随请求自动生效。
错法对照 错法 问题 正解 Service 内部 new Repository 测试换不掉、实现写死 构造器注入接口 字段 @Autowired 图省事 依赖隐藏、单测困难 构造器注入(Spring 4.3+ 单构造器免注解) 在单例 Bean 里用成员变量存请求状态 并发串数据 请求级数据放方法参数/ThreadLocal,计数用原子类或 Redis 注入具体实现类 多实现时歧义、无法替换 注入接口类型 用 setter 注入必选依赖 允许半初始化对象存在 Setter 注入只留给真正可选的依赖
卡点急救
单测里 new 不出 Service(字段注入) :改构造器注入后直接 new 并传 Mock;过渡期可用 ReflectionTestUtils 补字段,但方向是重构而不是凑合。
NoUniqueBeanDefinitionException :同接口多个实现,容器不知道选谁;@Primary 指默认,@Qualifier("name") 精确指定。
BeanCurrentlyInCreationException(循环依赖) :先看依赖方向是否设计错了;真有互相调用需求,抽第三个类或用事件解耦,不要靠 Spring 的三级缓存侥幸过关。
面试常问
什么是 IoC?什么是 DI?两者什么关系?
可背版:IoC 控制反转是一种设计思想——对象的创建与依赖装配的控制权从应用代码反转给容器;DI 依赖注入是实现 IoC 的主要方式,容器在创建对象时把它需要的依赖通过构造器/Setter 传入。效果是解耦与可测试:业务类只声明依赖,不负责创建。
为什么推荐构造器注入而不是字段注入?
可背版:① 依赖可声明为 final,不可变;② 构造器签名即依赖清单,一目了然;③ 对象构造完成即可用,不存在半初始化状态;④ 单元测试无需容器,直接 new 传 Mock;⑤ 循环依赖在启动期失败,而非运行期静默出错。
Spring Bean 默认作用域是什么?要注意什么?
默认 singleton:容器内单例、所有请求线程共享。注意两点:一是不保存请求级可变状态,否则并发串数据;二是注入 prototype 到 singleton 时只会注入一次,需要每次新实例要借助 ObjectProvider 或 @Lookup。
自测三问
手写 Container 里 singletons.computeIfAbsent 起到什么作用?
构造器注入为什么能让单测不需要 Spring 容器?
Controller → Service → Repository 的依赖箭头,为什么不允许反向?
小结一句话 IoC 把「new 的权力」交给容器,DI 用构造器把依赖送回来;构造器注入 = 不可变、可测试、坏设计早失败。
延伸