程云来 / 杭州
Back to Blog·Spring
·About 5 min

Spring(一):对象为什么不该自己创建依赖

从创建订单的一个 new 开始,理解控制反转、依赖注入和面向接口编程如何改变对象之间的关系。


先看一个被写死的订单服务

假设订单创建需要调用支付服务。最直接的写法,是在业务类里把依赖 new 出来:

java
class OrderService {
    private final PaymentService payment = new PaymentService();

    void createOrder(Order order) {
        payment.charge(order.total());
    }
}

这段代码能运行,但 OrderService 同时承担了两件事:处理订单业务,以及决定支付实现如何创建。以后换成模拟支付、支付宝支付,或者测试时不真正扣款,都要修改这个类。

问题不在 new 这个关键字本身,而在依赖方向:业务对象主动寻找具体实现。

把创建权移到外部

先让业务类只表达自己需要什么:

java
interface PaymentGateway {
    void charge(long cents);
}

class OrderService {
    private final PaymentGateway payment;

    OrderService(PaymentGateway payment) {
        this.payment = payment;
    }

    void createOrder(Order order) {
        payment.charge(order.total());
    }
}

现在 OrderService 不知道支付对象来自哪里,也不关心它是线上实现还是测试替身。依赖从构造器进入,业务代码只使用接口。

这就是依赖注入(Dependency Injection,DI):对象需要的依赖由外部传入。它实现了控制反转(Inversion of Control,IoC):对象不再控制依赖的创建和查找。

正在绘制图表…
查看 Mermaid 源码
flowchart LR
    order[OrderService] --> contract[PaymentGateway 接口]
    live[StripePaymentGateway] --> contract
    test[FakePaymentGateway] --> contract
    container[组装代码] --> order
    container --> live

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

组装代码决定使用哪个实现,OrderService 只依赖接口。Spring 容器做的事情,就是把这段组装关系从业务代码中接管过来。

Spring 只是把组装关系声明出来

用 Spring 时,代码通常会写成:

java
@Service
class OrderService {
    private final PaymentGateway payment;

    OrderService(PaymentGateway payment) {
        this.payment = payment;
    }
}

@Configuration
class PaymentConfig {
    @Bean
    PaymentGateway paymentGateway() {
        return new StripePaymentGateway();
    }
}

@Service 让组件进入候选集合,@Bean 明确告诉容器如何创建一个 PaymentGateway。容器读取这些声明,先创建支付实现,再把它传给 OrderService

调用关系可以写成:

text
读取组件声明
    ↓
创建 StripePaymentGateway
    ↓
调用 OrderService 构造器
    ↓
得到已组装的 OrderService

注解不是思想本身。即使不用 Spring,也可以手写一个 PaymentGatewayOrderService 的组装函数;Spring 只是把对象图、生命周期和配置管理做成了通用容器。

为什么优先使用构造器注入

构造器注入有三个直接结果:

  1. 对象创建完成时,必要依赖已经存在;
  2. 依赖关系可以从构造器签名中读出来;
  3. 测试可以直接传入 Fake 实现,不需要启动容器。
java
var fake = new FakePaymentGateway();
var service = new OrderService(fake);

service.createOrder(new Order(1000));

如果一个类需要十几个依赖,问题通常不是“构造器太长”,而是这个类承担了太多职责。依赖注入会把结构暴露出来,迫使我们重新划分模块。

两个容易混淆的边界

IoC 不等于“所有对象都必须交给 Spring”。简单的值对象、纯函数和一次性数据结构,不需要注册成 Bean。

DI 也不等于“到处调用容器取对象”。下面这种 Service Locator 会重新隐藏依赖:

java
class OrderService {
    void createOrder(Order order) {
        var payment = context.getBean(PaymentGateway.class);
        payment.charge(order.total());
    }
}

读代码的人无法从构造器知道它需要支付服务,测试也必须准备一个全局容器。依赖注入要求依赖显式地出现在对象边界上。

下一篇的问题是:依赖交给容器后,容器究竟何时创建对象、如何处理对象之间的依赖,以及对象何时可以被安全使用?

参考:Spring Framework IoC Container