一、环境说明
- Spring Boot 4.1.0,Java 17
- RuoYi-Vue 3.9.2,前后端分离,后端启动模块为
ruoyi-admin - 两个例子分别取自:
- 例子一:
com.ruoyi.mall商品模块(自己扩展的业务代码),注释式开发最典型的写法 - 例子二:
com.ruoyi.framework.config.CaptchaConfig验证码模块(框架自带),配置类 +@Bean的写法
- 例子一:
IOC(控制反转)和 DI(依赖注入)其实是一件事的两面:对象的创建权从业务代码手里交给 Spring 容器,这叫 IOC;业务代码需要用什么对象,由容器在启动时塞进来,这叫 DI。判断一段代码有没有用 Spring,看它是不是还在自己 new 依赖对象就够了一半。
下面先看容器是怎么起来的,再看两个例子怎么用注解创建对象、注入对象。
二、Spring 环境的启动
2.1 启动类 RuoYiApplication
1 | package com.ruoyi; |
@SpringBootApplication 是三个注解的组合,拆开看才能真正理解后面那些注解为什么生效:
| 组合注解 | 作用 |
|---|---|
@SpringBootConfiguration |
标记这是一个配置类,内部就是 @Configuration |
@EnableAutoConfiguration |
按依赖自动装配,比如引了 mybatis-spring-boot-starter 就自动配 SqlSessionFactory |
@ComponentScan |
扫描启动类所在包及其所有子包,把 @Component、@Service、@Controller 等注解的类注册成 Bean |
第三条是关键。启动类放在 com.ruoyi 根包下,所以 com.ruoyi.mall、com.ruoyi.framework、com.ruoyi.system 全在扫描范围内。例子一里商品 Service 只写了一个 @Service 就能被容器管理,靠的就是这个默认扫描规则。如果把启动类挪到 com.ruoyi.admin 这种更深的包,com.ruoyi.mall 就扫不到了,还得手动写 scanBasePackages。
SpringApplication.run() 做的事依次是:创建 ApplicationContext 容器 → 解析配置类 → 扫描并注册所有 BeanDefinition → 实例化所有非懒加载的单例 Bean 并完成依赖注入 → 启动内嵌 Tomcat。第二步到第四步完成后,容器里每个 Bean 的依赖字段都已经有值了,这才是”启动完成”。
exclude = DataSourceAutoConfiguration.class 是 RuoYi 的特殊处理:框架用 Druid 自己配置了多数据源(DruidConfig),所以要先关掉 Spring Boot 的数据源自动装配,不然启动时两个数据源打架,直接报错。
2.2 Mapper 接口为什么也能被注入
1 | package com.ruoyi.framework.config; |
@MapperScan 告诉 MyBatis:com.ruoyi 下所有 mapper 包里的接口,都通过 MapperFactoryBean 生成动态代理对象注册进容器,Bean 名是接口名首字母小写。
这一点必须先说清楚,否则后面会看不懂:MallProductMapper 只是个 interface,一行实现都没有,凭什么能被注入?因为它注入进来的不是接口本身,而是 MyBatis 生成的代理对象,真正执行 SQL 的逻辑在代理里(读的是 MallProductMapper.xml)。
三、例子一:@Service 创建对象 + @Autowired 注入(商品模块)
3.1 用注解把自己写的类交给容器
1 | package com.ruoyi.mall.service.impl; |
@Service 的作用有三层:
- 语义标识:标明这是业务逻辑层。它本质上是
@Component的派生注解,Spring 在容器层面把它们一视同仁,写@Component一样能跑,只是可读性差一些(同类的还有@Controller表示控制层、@Repository表示持久层)。 - 注册 Bean:被
@ComponentScan扫到之后,Spring 通过反射调用无参构造创建实例,以单例形式放进容器,Bean 名默认取类名首字母小写,即mallProductServiceImpl。 - 支持别名:一个接口有多个实现时,可以写
@Service("xxx")指定 Bean 名,注入时再按名字区分。
类声明为 implements IMallProductService 不是随手写的。容器里注册的 Bean 类型既包含 MallProductServiceImpl,也包含它实现的接口 IMallProductService,所以外部可以只依赖接口。以后要换成 MallProductServiceImplV2,Controller 一行都不用改。
3.2 注入对象
1 |
|
@RestController = @Controller + @ResponseBody,而 @Controller 又是 @Component 的派生注解。所以 ShopController 本身也是容器里的一个 Bean,它里面的 @Autowired 字段才会被 Spring 处理——如果这个类没被容器管理,字段上的 @Autowired 就是普通注解,不会起任何作用,运行时 productService 会是 null。
@Autowired 的作用:**按类型(byType)**从容器里找出匹配的 Bean 赋给字段。
- 这里声明的类型是
IMallProductService,容器里这个类型的 Bean 只有mallProductServiceImpl一个,能唯一确定,注入成功。 - 找不到时会抛
NoSuchBeanDefinitionException,因为@Autowired的required默认为true,启动阶段直接失败,不会等到调用时才空指针。 - 找到多个时会抛
NoUniqueBeanDefinitionException,这时得配合@Qualifier("bean名")指定。 - 字段是
private也不需要 setter,因为注入是反射完成的,由AutowiredAnnotationBeanPostProcessor在 Bean 实例化后统一处理。
3.3 完整调用链
1 | 浏览器 / 前端 |
这条链路上三次”拿到对象”都不是 new 出来的:
- Controller 没有
new MallProductServiceImpl(),否则它又得自己去准备两个 Mapper,而 Mapper 是接口,根本 new 不出来; - Service 没有
new MallProductMapperImpl(),因为它不存在,代理是 MyBatis 在启动时生成的; - 如果不用 Spring,每一层都要自己管理
SqlSession、手动关闭连接,代码会长得没法看。
这就是 IOC/DI 的实际收益:创建和装配的活交给了容器,业务类只管用。
四、例子二:@Configuration + @Bean 创建对象 + @Resource 按名称注入(验证码模块)
4.1 为什么这里不能只用 @Service
验证码用的是 kaptcha 这个第三方 jar 里的 DefaultKaptcha。它的源码在别人包里,既没有 @Component,我们也没法进去加注解,扫描永远扫不到它。这类”第三方类”或者”必须先塞一堆配置才能创建的对象”,就得用 @Configuration + @Bean 手动注册。
而且项目里要两个配置不同的生成器:一个是字符验证码,一个是算术验证码,同一个类型要两个实例,用 @Bean 方法各创建一份最直接。
1 |
|
@Configuration:标记配置类,它本身也是 @Component 的派生,所以会被扫描、会被注册进容器。除此之外 Spring 还会用 CGLIB 给这个类生成一个代理子类,保证在配置类内部调用另一个 @Bean 方法时,返回的是容器里那个单例,而不是又执行一遍方法体 new 一个新对象。
@Bean:标在方法上,方法返回值就是要注册进容器的对象,Bean 名默认取方法名,可以用 name 属性改。方法体里 new 的对象、设置的属性,就是最终进入容器的那个实例。
对比例子一:@Service 是容器自己反射 new 出对象,@Bean 是我们自己 new 完交给容器。前者省事,后者灵活,能控制创建过程和参数。
4.2 注入对象
1 |
|
@Resource 是 JSR-250 标准注解(jakarta.annotation.Resource),不是 Spring 自己的,Spring 同样支持它。它和 @Autowired 最大的区别是默认按名称匹配:先拿字段名去容器里找同名的 Bean,找不到再退化成按类型匹配。
这里必须写 name,原因很直接:容器里有 captchaProducer 和 captchaProducerMath 两个都是 Producer 类型的 Bean,如果改成 @Autowired private Producer captchaProducer;,Spring 按类型找到两个,无法判断给哪个,启动直接抛 NoUniqueBeanDefinitionException。同类型多实例的场景只有两种做法:@Resource(name = "...") 或者 @Autowired + @Qualifier("...")。
字段类型声明为 Producer 而不是 DefaultKaptcha,是因为 DefaultKaptcha 实现的接口就是 Producer,Controller 只用到 createText() 和 createImage() 两个方法,依赖接口就够了。@Bean 方法上返回的是具体类,注入时用接口接收,两者不冲突。
4.3 完整调用链
1 | 浏览器 / 前端登录页 |
这个 Controller 里 4 个依赖全部是注入来的,没有一处 new:两个验证码生成器来自 @Bean,RedisCache 是 @Component,ISysConfigService 是 @Service。两种创建对象的方式在同一个类里同时出现,正好能对上。
五、两种写法对比
| 对比项 | 例子一(商品模块) | 例子二(验证码模块) |
|---|---|---|
| 创建对象的注解 | @Service(派生自 @Component) |
@Configuration + @Bean |
| 谁负责 new | Spring 反射调用无参构造 | 方法体里自己 new,交返回值 |
| 注入对象的注解 | @Autowired |
@Resource(name = "...") |
| 匹配方式 | 按类型 byType | 按名称 byName |
| 适用场景 | 自己写的类,一个接口一个实现 | 第三方类、需要复杂初始化、同类型要多个实例 |
六、小结
- 创建对象:自己写的类加
@Component/@Service/@Controller/@Repository,靠启动类的@ComponentScan扫到;第三方类或者需要手动配参数的对象,用@Configuration+@Bean注册。Mapper 接口靠@MapperScan生成代理,同样在容器里。 - 注入对象:
@Autowired按类型注入,是 Spring 的注解;@Resource按名称优先,是 JDK 标准注解。容器里同类型有多个 Bean 时,必须按名称指定,否则启动报错。 - 注解本身不干活,它们只是”声明”:告诉容器哪些类要创建、依赖往哪个字段塞。真正的创建和装配由
BeanFactory在启动阶段完成,所以注解写错了,通常不用等到运行时——启动就直接失败。