spring 依赖注入怎么写?附 3 种注入方式+使用场景对比

编程狮(w3cschool.cn) 2026-08-31 17:24:09 浏览数 (59)
反馈

依赖注入就是"对象不自己 new 依赖,而是由 Spring 容器把依赖送进来"。你只需要在类里声明"我要什么",Spring 负责创建对象、找到它依赖的对象、按顺序组装好再交给你。这一步也叫控制反转,也就是对象的创建权从业务代码转移到了容器。

很多初学者刚接触 Spring 时被一堆注解绕晕:有的把注解写在字段上,有的写在构造方法上,还有人写在 setter 里,看上去都能跑通,于是随手挑一个用到底。等到自己 new 一个对象报空指针,或者项目启动时报循环依赖,才发现三种写法的差别比想象中大得多。今天这篇文章,编程狮就把字段注入、构造器注入、Setter 注入三种写法连同完整可编译代码一次讲清,说透为什么官方推荐构造器注入,最后给你一张选型对比表。

Spring 依赖注入三种方式示意图

一、依赖注入是什么,为什么需要它

把这四个字拆开看:"依赖"是 A 类干活需要 B 类,"注入"是 B 不是 A 自己 new 出来的,而是从外面送进来的。 送东西的人就是 Spring 容器。

不用依赖注入时,代码是这么写的,OrderService 既管业务逻辑、又管去哪找依赖:

public class OrderService {
    // 自己在内部 new,相当于把实现类焊死在身上
    private UserDao userDao = new UserDaoImpl();


    public String findUserName(Long id) {
        return userDao.getNameById(id);
    }
}

这段代码看着没什么问题,需求一变就露馅:想把 UserDaoImpl 换成缓存实现,必须改 OrderService 的源码;想写单元测试,没法把真实的数据库访问换掉。根源就是这个类把"找依赖"的活也揽了。

依赖注入把后一半职责交给容器,业务类只管声明"我需要一个 UserDao"。

1.1 一个最小可运行示例

下面是一份完整可编译的 Spring 示例,展示了容器是怎么把依赖送进来的:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Service;


interface UserDao {
    String getNameById(Long id);
}


@Service
class UserDaoImpl implements UserDao {
    public String getNameById(Long id) {
        return "编程狮用户" + id;
    }
}


@Service
class OrderService {
    private final UserDao userDao;


    // 只声明"我需要一个 UserDao",不关心它是谁、怎么来的
    public OrderService(UserDao userDao) {
        this.userDao = userDao;
    }


    public String findUserName(Long id) {
        return userDao.getNameById(id);
    }
}


@Configuration
@ComponentScan(basePackageClasses = OrderService.class)
class AppConfig {
}


public class DiDemo {
    public static void main(String[] args) {
        AnnotationConfigApplicationContext ctx =
                new AnnotationConfigApplicationContext(AppConfig.class);


        OrderService service = ctx.getBean(OrderService.class);
        System.out.println(service.findUserName(1L));   // 编程狮用户1


        ctx.close();
    }
}

注意 OrderService 里没有 new,也没有任何 Spring 的 API,它就是个普通类。容器启动时会做四件事:扫描到 @Service;发现 OrderService 的构造方法需要一个 UserDao;找到唯一实现 UserDaoImpl 并实例化;把它传给构造方法,得到组装完成的 bean。

想按官方文档的节奏把容器的工作细节补齐,可以顺着 Spring 教程 的 IoC 章节系统过一遍。

1.2 常见误解

误解一:依赖注入就等于写个 @Autowired 不对。注解只是告诉容器"这里要注入",注入方式由标注的位置决定:标在字段上是字段注入,标在构造方法上是构造器注入,标在 setter 上是 Setter 注入。而且从 Spring 4.3 开始,一个类只有一个构造方法时连注解都能省。

误解二:注入的一定得是接口。 注入具体类完全没问题。只是面向接口写,将来换实现、做 mock 都更省事,所以才成了常见约定,而不是语法要求。

误解三:容器会兜底一切,循环依赖也不怕。 容器能兜住一部分,但兜住不等于问题消失——字段注入下两个类互相依赖还能启动成功,这种"能跑"反而把设计问题藏得更深。

二、方式一:字段注入,写法最短但问题最多

字段注入是网上示例里最常见的一种,因为它的代码量最少:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;


@Service
public class OrderService {


    @Autowired
    private UserDao userDao;


    public String findUserName(Long id) {
        return userDao.getNameById(id);
    }
}

看起来干净利落,问题却不少,而且都是那种"平时没事、一出问题就很难查"的类型:

第一,脱离容器就废了。 字段是私有的,又没有构造方法可以传值,容器之外没人能给 userDao 赋值:

public class FieldInjectProblem {
    public static void main(String[] args) {
        OrderService service = new OrderService();
        System.out.println(service.findUserName(1L));   // NullPointerException
    }
}

这行代码能编译、能运行,只是在调用那一刻抛出空指针异常。新手常在写单元测试或手动 new 一个工具类时撞上它。

第二,依赖无法声明为 final 字段注入要求容器在对象构造完成之后再通过反射塞值,所以字段不能是 final,对象的状态也就谈不上不可变。

第三,单元测试只能靠容器或反射。 想替换依赖,要么启动整个 Spring 上下文(慢),要么用反射强行改私有字段(脏)。而构造器注入只需一行 new OrderService(fakeDao)

第四,依赖关系被藏起来了。 看一个类的公开签名,你完全不知道它依赖了多少东西,于是依赖从两三个悄悄涨到十几个,类的职责早该拆了却没人发现。

第五,循环依赖被掩盖。 字段注入允许 Spring 先把"半成品"对象放进去,因此两个类互相依赖也能启动,设计缺陷就这样被推迟到了运行时。

三、方式二:构造器注入,官方推荐的写法

同样的需求,改成构造器注入是这样:

import org.springframework.stereotype.Service;


@Service
public class OrderService {


    private final UserDao userDao;
    private final SmsClient smsClient;


    // Spring 4.3 起,类里只有一个构造方法时,@Autowired 可以省略
    public OrderService(UserDao userDao, SmsClient smsClient) {
        this.userDao = userDao;
        this.smsClient = smsClient;
    }


    public String findUserName(Long id) {
        return userDao.getNameById(id);
    }
}

就多了一个构造方法,换来的是四个实打实的好处:

依赖不可变。 字段可以声明为 final,一旦在构造方法里赋值就不能再改,对象从头到尾状态一致,不会出现"构造完了但依赖还是 null"的半成品。

对象构造完成即处于可用状态。 所有依赖在构造时一次性到位,不存在"先 new 出来、稍后再填"的中间态。

单元测试极简。 不需要容器,不需要反射,直接传一个假的进去就行:

public class ConstructorInjectTest {
    public static void main(String[] args) {
        UserDao fakeDao = new UserDao() {
            public String getNameById(Long id) {
                return "测试用户";
            }
        };
        OrderService service = new OrderService(fakeDao, null);
        System.out.println(service.findUserName(1L));   // 测试用户
    }
}

循环依赖在启动期就暴露。 构造器注入要求依赖先就绪,因此 A 依赖 B、B 又依赖 A 时,容器会在启动时直接抛出 BeanCurrentlyInCreationException,而不是等到某个请求打进来才炸。这是优点不是缺点——早失败比晚失败便宜得多。

嫌每个依赖都写一遍赋值语句太啰嗦,可以用 Lombok 的 @RequiredArgsConstructor 自动生成构造方法,字段声明为 private final 即可。

注解、Bean 作用域、生命周期这些细节记不全很正常,随手翻 Spring 速查手册 比翻源码快得多。

四、方式三:Setter 注入与循环依赖的兜底方案

Setter 注入把注解标在 setter 方法上:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;


@Service
public class ReportService {


    private MailSender mailSender = new ConsoleMailSender();   // 有默认值


    @Autowired
    public void setMailSender(MailSender mailSender) {
        this.mailSender = mailSender;
    }


    public void send(String to) {
        mailSender.send(to, "报表已生成");
    }
}

它的定位很清晰:用于"可选依赖",也就是不注入也能正常工作、注入了就覆盖默认行为的场景。 比如发消息的类默认往控制台打印,容器里配了邮件实现就改发邮件。

Setter 注入还能兜住一类棘手场景——循环依赖。构造器注入下 A、B 互相依赖会启动失败,Setter 注入允许"先造对象、后填依赖",死结就解开了。既不想改 Setter 注入、又暂时动不了代码结构,可以用 @Lazy

import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;


@Service
public class OrderService {


    private final UserService userService;


    // 注入的是一个代理,真正调用时才去容器里取真实对象
    public OrderService(@Lazy UserService userService) {
        this.userService = userService;
    }


    public void place(Long userId, Long orderId) {
        userService.addPoints(userId, 10);
    }
}

@Lazy 注入的不是真实对象而是一个代理,只有第一次真正调用它的方法时才会去容器里取,于是"创建时必须拿到成品"这个要求被绕开了。但要清楚:它是止痛药,不是治疗。 双向依赖通常意味着职责没划清,正经做法是把共用逻辑抽到第三个类里,让依赖变成单向。

五、怎么选:三种注入方式对比与踩坑清单

写法 依赖可变 能否脱离容器 new 单元测试成本 循环依赖 适用场景
字段注入 不能,必空指针 高,要容器或反射 被掩盖,能启动 快速验证的演示代码
构造器注入 否,可 final 能,直接传参 低,一行 new 启动期即报错 必选依赖,默认首选
Setter 注入 能,用默认值 允许,可后填 可选依赖、需要默认值

一句话决策:必选依赖一律用构造器注入,可选依赖用 Setter 注入,字段注入只在写演示代码时图省事。

踩坑清单:

  1. 字段注入的类千万别自己 new 拿到的对象依赖全是 null,一调用就空指针。
  2. 构造器注入启动报循环依赖,别急着改成字段注入。 报错是在提醒你该拆类了,改成字段注入只是把炸弹往后埋。
  3. 同类型有多个实现要指明。@Qualifier 按名字指定,或用 @Primary 标一个默认实现,否则容器不知道选谁。
  4. 不要给静态字段加注入注解。 Spring 会直接忽略 static 字段,它永远是 null。
  5. 别在一个类里混用多种注入方式。 构造器注入了三个依赖、又对第四个字段用注解,读代码的人很难判断哪个是必需的。

上面这些写法在 Spring Boot 项目里完全一致,自动配置和 Starter 只是把容器的启动过程藏了起来。想看这一层是怎么封装的,SpringBoot 教程 的起步章节讲得比较细。

Spring 三种依赖注入方式怎么选

总结

依赖注入记住三句话就够:必选依赖用构造器注入,依赖声明为 final,对象构造完成即处于可用状态;可选依赖用 Setter 注入,留好默认值;字段注入图一时省事,代价是空指针、难测试、循环依赖被掩盖。

  • 注入方式由注解标注的位置决定,不是由注解本身决定;
  • 构造器注入在 Spring 4.3 之后,单构造方法可以省略注解;
  • 循环依赖的正确解法是拆分类、理顺依赖方向,@Lazy 只是临时兜底;
  • 同类型多实现要靠 @Qualifier@Primary 消歧义。

延伸学习

  1. Spring 框架配套课程 —— 从 IoC 容器讲到 Bean 的完整生命周期。
  2. Spring IOC 与依赖注入笔记 —— 把容器启动流程与常见配置陷阱一次列清。
  3. 在线运行 Java 代码工具 —— 不用配环境,直接编译运行本文的示例代码。

常见问题

Q:Spring 4.3 之后,构造方法上的 @Autowired 到底还用不用写?

A:类里只有一个构造方法时不用写,容器会自动拿它来注入依赖。如果类里有多个构造方法,就必须在你希望容器选用的那个上面标注 @Autowired,否则容器不知道该用哪个,会启动失败。

Q:一个类有七八个依赖,构造方法长到没法看,怎么办?

A:先别急着换注入方式,这是类在告诉你"该拆了"。把其中几个内聚的依赖归并成一个新的服务类,往往构造器参数就降到三四个了。如果确实只是参数多、职责没乱,用 Lombok 的 @RequiredArgsConstructor 自动生成构造器即可,代码里不用写那一长串赋值。

Q:老项目全是字段注入,改造成构造器注入工作量会不会很大?

A:逐个类改,一次改一类,别一次性全量替换。改一个类的顺序是:把字段改成 private final;加构造方法接收这些依赖;删掉字段上的注解;跑一遍对应测试。没有测试覆盖的类先补一个冒烟测试再动手。新写的类一律用构造器注入,老类碰到再改,几轮迭代就能收敛。

Q:构造器注入为什么能在启动期就发现循环依赖?

A:因为它要求"依赖先就绪才能造自己"。容器创建 A 时发现 B 还没好,去创建 B 又发现 A 正在创建中,这个环当场就断,于是抛出 BeanCurrentlyInCreationException。字段注入允许先把未填完依赖的对象放进去,环能被打破,问题也就推迟到了运行时。

0 人点赞