一、引言
在构建Spring Boot Web应用时,我们通常采用控制层、业务逻辑层、数据访问三层架构,其核心目标之一是实现模块间的解耦,让每一层职责单一、独立演化。
然而,传统的“谁使用,谁创建”的对象管理方式(如在Controller中new Service)却与这一目标背道而驰,造成了层与层之间的硬编码耦合。这种耦合使得代码僵化、难以测试和维护——修改一个组件,往往需要牵连修改所有依赖它的组件。
Spring Boot框架为解决这一核心问题提供了优雅的答案:控制反转与依赖注入
二、控制反转与依赖注入如何实现低耦合
2.1控制反转与依赖注入到底是什么呢?
首先,通过概念来初步理解这两个名词。
控制反转:Inversion 0f Control,简称IOC。对象的创建控制权由程序自身转移到外部(容器),这种思想称为控制反转。
依赖注入: Dependency Injection,简称DI。容器为应用程序提供运行时,所依赖的资源,称之为依赖注入。
另外大家还需要知道Bean对象
Bean对象:IOC容器中创建、管理的对象,称之为Bean。
2.2传统代码的缺点:强耦合的订单案例
现在,我通过一个订单案例的代码来让大家理解这个两个概念。
在典型的Spring Boot Web应用中,我们普遍采用控制器层(Controller)、服务层(Service)、数据访问层(Dao/Mapper) 的三层架构。这种分层的目的就是为了解耦,让每一层各司其职。
但理想很丰满,现实却很骨感。在没有引入Springboot框架的帮助下我们可能会写出这样的代码:
controller层:
// 传统方式:强耦合的代码
@RestController // 即使有注解,但没有IoC容器,也无法自动注入
public class OrderController {
// 问题所在:Controller 直接「new」了一个具体的 Service 实现类
private OrderService orderService = new OrderServiceImpl();
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.getOrderById(id);
}
}
Service层:
// 订单服务接口
public interface OrderService {
Order getOrderById(Long id);
}
// 订单服务实现类
public class OrderServiceImpl implements OrderService {
// 问题同样存在:Service 直接「new」了一个具体的 Dao 实现类
private OrderDao orderDao = new OrderDaoImpl();
@Override
public Order getOrderById(Long id) {
return orderDao.findById(id);
}
}
这段代码的“痛点”非常明显:
-
高度耦合:
OrderController牢牢地绑定了OrderServiceImpl这个具体实现。如果有一天我们需要一个NewOrderServiceImpl(比如使用了新的业务逻辑),我们就必须修改OrderController的源代码,将new OrderServiceImpl()改为new NewOrderServiceImpl()。 -
难以测试:如果我们想对
OrderController进行单元测试,几乎不可能让它使用一个模拟的(Mock)OrderService。因为它的依赖是内部固化的,我们无法从外部替换。 -
违背开闭原则:对扩展开放,对修改关闭。这里,任何依赖的变更都导致了源代码的修改。
这种“谁使用,谁创建”的模式,导致了各层之间像锁链一样环环相扣,任何一个环节的变动都可能导致了源代码的修改。
2.3 控制反转与依赖注入的解决方案
那么我们引入控制反转与依赖注入的策略,不要在你的组件内部创建依赖,而是让外部容器(IOC容器)创建好,然后“注入”给你。
让我们看看改造后的代码:
controller层:在Controller中,我们也不再创建Service,而是声明:“我需要一个OrderService”。
@RestController
public class OrderController {
@Autowired // 关键注解:请求容器将所需的OrderService注入到此
private OrderService orderService; // 面向接口编程,而非具体实现!
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.getOrderById(id);
}
}
service层:我们使用注解(如 @Component, @Service, @Repository)标记类,告诉Spring:“请创建并管理我这个类的实例(Bean)。”
@Service // 关键注解:声明这个类是一个Service,由Spring容器管理(成为一个Bean)
public class OrderServiceImpl implements OrderService {
@Autowired // 关键注解:请求容器将所需的OrderDao注入到此
private OrderDao orderDao; // 不再自己new!
@Override
public Order getOrderById(Long id) {
return orderDao.findById(id);
}
}
@Repository
public class OrderDaoImpl implements OrderDao {
// ... 数据访问逻辑
}
2.4 控制反转与依赖注入的实现分析
结合上面修改前和修改后的代码,大家应该能发现:
- 实现控制反转(IoC)的操作就是:
OrderController和OrderServiceImpl的创建控制权,从程序员手中(new关键字)“反转”到了Spring IOC容器手中。 - 实现依赖注入(DI)的操作就是:Spring容器负责创建好
OrderServiceImpl和OrderDaoImpl的实例(Bean)。当创建OrderController时,容器发现它需要一个OrderService,于是自动找到OrderServiceImpl这个Bean,并“注入”到OrderController的对应字段中。OrderServiceImpl对OrderDao的依赖也是如此注入的。
那么带来的好处也显而易见了:
-
彻底解耦:
OrderController只依赖于OrderService这个接口,完全不知道也不关心其具体实现是谁。这意味着,我们可以随时在配置中将注入的实现从OrderServiceImpl切换到NewOrderServiceImpl,而OrderController的代码一行都不用改! -
易于测试:在测试时,我们可以利用Spring的测试框架或Mock工具,轻松地为
OrderController注入一个模拟的OrderService,从而进行纯净的单元测试。
2.5 通过生活场景理解控制反转与依赖注入
通过上述代码示例,大家一定对控制反转与依赖注入有了初步的感受了。接下来,我将通过生活场景进一步说明,帮助大家理解其运作机制。
首先,我们先梳理一下这个过程中的各个角色:需要依赖注入的类(如controller类),对象的创建控制权交给IOC容器的类(如service类),IOC容器,bean对象。
现在大家想象一个生活场景。工厂需要招牌不同技能的工人,工人需要应聘一家工厂。于是工人将自己的资料交给中介公司,工厂向中介公司提出用工需求。
我们梳理一下这个场景中对应的关系:
1. 工人(技能提供者)
-
对应:需要将控制权交给IOC容器的类(如Service、Repository等)
-
行为:到中介公司(IoC容器)登记注册,成为Bean对象
-
特点:被动等待分配工作,不关心为谁服务
2. 工厂(服务使用者)
-
对应:需要依赖注入的类(如Controller、其他Service等)
-
行为:向中介公司提出用工需求(声明依赖)
-
特点:主动提出需求,但不亲自招聘
3. 中介公司(协调管理者)
-
对应:IOC容器(ApplicationContext)
-
行为:接收工人登记,响应工厂需求,组织协作
-
特点:掌握全局,负责调度
4. 工人资料/工人本身
-
对应:Bean对象
-
身份:在IOC容器中注册的具体实例
-
状态:随时准备被"派遣"到需要的地方
那么完整的工作的流程就是这样:
工人登记 → 中介管理 → 工厂申请 → 中介派遣 → 协作生产
1. 【工人登记】OrderService类加上@Service注解 → 成为Bean
(电工到中介公司登记:"我会电工技能")
2. 【中介管理】IoC容器创建并管理OrderService实例
(中介公司记录电工的联系方式和技能等级)
3. 【工厂申请】OrderController声明@Autowired OrderService
(建筑队向中介申请:"我需要一个电工")
4. 【中介派遣】IoC容器将OrderService Bean注入OrderController
(中介派电工到建筑队工作)
5. 【协作生产】OrderController使用OrderService完成业务
(建筑队和电工协作盖房子)
需要注意的是:
-
工人和工厂都是Bean:在Spring中,Controller、Service、Repository等被管理的类都是Bean
-
角色是相对的:一个Service可能同时是"工人"(被Controller使用)和"工厂"(使用Repository)
-
中介是核心:IoC容器作为中央协调者,解耦了工人和工厂的直接依赖关系
概念和运作机制都理解清楚后,我们接下来就详细了解控制反转和依赖注入在开发中具体的代码实现和注意点。
三、IOC详解
3.1 声明Bean的注解
要把某个对象交个IOC容器管理,需要在对应的类上面加上如下注解之一:
| 注解 | 说明 | 位置 |
|---|---|---|
| @Component | 声明bean的基础注解 | 不属于以下三类时,用此注解 |
| @Controller | @Component的衍生注解 | 标注在控制层类上 |
| @Service | @Component的衍生注解 | 标注在业务层类上 |
| @Repository | @Component的衍生注解 | 标注在数据访问层类上(由于与mybatis整合,用的少,MyBatis通常使用@Mapper接口替代) |
我们可以查看@Controller@Service@Repository的源码,三个注解都继承所有@Component功能。
// @Service 的源码定义
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component // 关键!@Service 本身被 @Component 注解标记
public @interface Service {
String value() default "";
}
// @Controller 的源码定义
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component // 关键!@Controller 本身被 @Component 注解标记
public @interface Controller {
String value() default "";
}
// @Repository 的源码定义
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component // 关键!@Repository 本身被 @Component 注解标记
public @interface Repository {
String value() default "";
}
3.2 为什么需要衍生注解而不仅仅是@Component?
虽然@Controller、@Service、@Repository在功能上与@Component完全等价,但它们的存在有着重要的设计意义。
// 不好的做法:语义不清晰
@Component
public class OrderController { ... }
@Component
public class OrderServiceImpl { ... }
@Component
public class OrderDaoImpl { ... }
// 好的做法:语义明确,一目了然
@Controller
public class OrderController { ... } // 明确表示这是控制器层(实际开发过程中我们会用到@RestController)
@Service
public class OrderServiceImpl { ... } // 明确表示这是业务逻辑层
@Repository
public class OrderDaoImpl { ... } // 明确表示这是数据访问层
好比职场中的职位标签:
-
所有人都可以统称为"员工"(
@Component) -
但具体分为"项目经理"(
@Controller)、"技术专家"(@Service)、"财务专员"(@Repository) -
职位标签让职责分工更加清晰
另外还有一些其他的原因,例如使用特定注解可以让代码自动体现三层架构,还能享受特定的框架特性,便于AOP切面编程。不过最重要的原因还是上述介绍的语义化表达,让架构层次清晰。
3.3 bean的命名
我们在声明bean的时候,可以通过注解的value属性指定bean的名字,如果没有指定,默认为类名首字母小写。
大家可以通过下面这个代码进行直观理解bean命名的两种方式。
@Service // 没有指定bean名称,使用默认规则
public class OrderServiceImpl implements OrderService {
// 这个bean在Spring容器中的名字将是:"orderServiceImpl"
// (类名首字母小写)
}
@Service("myOrderService") // 通过value属性指定bean名称
public class OrderServiceImpl implements OrderService {
// 这个bean在Spring容器中的名字将是:"myOrderService"
// (自定义名称)
}
3.4 Bean声明生效的关键
前面我们知道了四大注解(@Component, @Controller, @Service, @Repository),但有一个关键问题:仅仅在类上添加这些注解,Spring并不能自动发现并创建Bean,我们还需要配置@ComponentScan。
Spring容器启动时,会查看是否配置了@ComponentScan。如果有,就会按照指定路径去"扫描"所有类文件。然后发现带有@Component及其衍生注解的类,就创建对应的Bean实例将这些Bean纳入IoC容器管理。
该注解虽然没有显式配置,但是实际上已经包含在了启动类声明注解 。当我们创建一个springboot项目的时候,我们就可以看到启动类是这样的。
@SpringBootApplication // 这是一个复合注解!
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
没错就是@SpringBootApplication。在idea中点开这个注释,我们可以看到它的组成
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan // 看!它已经内置在这里了!
public @interface SpringBootApplication {
}
所以为什么是声明在启动类上呢?那是因为@SpringBootApplication 中,默认扫描的范围是启动类所在包及其子包。
通过下面这个项目结构的图例大家能够清晰的理解
com.example
└── myapp
├── Application.java // 启动类
├── controller
│ ├── UserController.java // 会被扫描到
│ └── OrderController.java // 会被扫描到
├── service
│ ├── UserService.java // 会被扫描到
│ └── OrderService.java // 会被扫描到
└── config
└── WebConfig.java // 会被扫描到
如果类不在扫描范围内:
com.example
├── myapp
│ ├── Application.java // 启动类
│ └── controller
│ └── UserController.java // 会被扫描到
└── external
└── ExternalService.java // 不会被扫描到!(不在子包中)
那如果大家需要扫描其他包,也可以这样配置:
@SpringBootApplication
@ComponentScan(basePackageClasses = {
Application.class, // 扫描启动类所在包
ExternalService.class // 扫描ExternalService所在包
})
public class Application {
// ...
}
//不推荐,不够安全
@SpringBootApplication
@ComponentScan(basePackages = {
"com.example.myapp", // 默认路径
"com.example.external", // 额外添加的扫描路径
"com.thirdparty.utils" // 第三方工具包
})
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
四、DI详解
依赖注入是控制反转思想的具体实现,使用@Autowired注解。Springboot框架主要通过以下三种方式来实现依赖注入。
4.1 构造器注入
通过类的构造方法来实现依赖注入,在对象创建时就完成所有必需依赖的注入。就像组建一个项目团队时,在团队成立的第一天就把所有核心成员(产品经理、开发工程师、测试工程师)一次性配齐。团队一旦成立,就具备了完整的工作能力。
@Service
public class OrderService {
private final PaymentService paymentService;
private final InventoryService inventoryService;
// 构造器注入:Spring容器会自动识别并注入所需的依赖
@Autowired
public OrderService(PaymentService paymentService, InventoryService inventoryService) {
this.paymentService = paymentService;
this.inventoryService = inventoryService;
}
}
- 优点:能清晰地看到类的依赖关系、提高了代码的安全性。
- 缺点:代码繁琐、如果构造参数过多,可能会导致构造函数臃肿。
- 注意:如果只有一个构造函数,@Autowired注解可以省略。
在spring官方网站是推荐的这个方式,但是在真实的企业开发当中很多的项目组都会选择我们接下里要介绍的基于属性的方式注入,因为属性注入代码更加简洁,方便快速开发。如果说项目组对于这个代码的规范性要求比较高,就会选择这个构造器注入。
4.2 属性注入
直接在字段上使用@Autowired注解,Spring通过反射机制完成注入。
@Service
public class OrderService {
@Autowired
private PaymentService paymentService;
@Autowired
private InventoryService inventoryService;
}
- 优点:代码简洁、方便快速开发。
- 缺点:隐藏了类之间的依赖关系、可能会破坏类的封装性。
4.3 Setter方法注入
通过setter方法来实现依赖注入,可以在对象创建之后再进行依赖注入。就像团队先成立,然后根据项目进展需要,陆续招聘或调配相关人员加入。团队在运行过程中可以动态调整成员构成。
@Service
public class OrderService {
private PaymentService paymentService;
private InventoryService inventoryService;
// Setter方法注入
@Autowired
public void setPaymentService(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Autowired
public void setInventoryService(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
}
- 优点:保持了类的封装性,依赖关系更清晰。
- 缺点:需要额外编写setter方法,增加了代码量。
4.4解决同一类型多个Bean的冲突问题
我们知道 @Autowired注解,默认是按照类型进行注入的。但是当Spring容器中存在多个相同类型的Bean时,使用@Autowired注解会遇到冲突问题。
假设我们有一个支付接口和两个实现类:
public interface PaymentService {
void pay();
}
@Service
public class AlipayService implements PaymentService {
public void pay() {
System.out.println("支付宝支付");
}
}
@Service
public class WechatPayService implements PaymentService {
public void pay() {
System.out.println("微信支付");
}
}
当我们在其他类中注入时就会报错:
@Service
public class OrderService {
@Autowired
private PaymentService paymentService; // 报错:找到2个Bean,不知道用哪个
}
错误信息:
NoUniqueBeanDefinitionException: No qualifying bean of type 'PaymentService' available: expected single matching bean but found 2: alipayService,wechatPayService
下面我们详细讲解三种解决方案:
4.4.1@Primary
@Primary注解用于指定当有多个相同类型的Bean时,优先使用被标记的Bean。通常当某个Bean是默认的首选实现时,则选择这个方式。
@Service
@Primary // 标记为主要的支付服务,当有多个PaymentService时优先使用这个
public class AlipayService implements PaymentService {
public void pay() {
System.out.println("支付宝支付(主要支付方式)");
}
}
@Service
public class WechatPayService implements PaymentService {
public void pay() {
System.out.println("微信支付");
}
}
注入时:
@Service
public class OrderService {
@Autowired
private PaymentService paymentService; // 自动使用AlipayService(因为有@Primary)
}
4.4.2 @Qualifier
@Qualifier注解通过Bean的名称来精确指定要注入哪个Bean。需要根据具体情况选择不同实现的场景。
@Service("alipay") // 给Bean指定一个名称
public class AlipayService implements PaymentService {
public void pay() {
System.out.println("支付宝支付");
}
}
@Service("wechat") // 给Bean指定一个名称
public class WechatPayService implements PaymentService {
public void pay() {
System.out.println("微信支付");
}
}
注入时指定名称:
@Service
public class OrderService {
@Autowired
@Qualifier("alipay") // 精确指定要注入的Bean名称
private PaymentService paymentService;
}
也可以直接在实现类上使用@Qualifier:
@Service
@Qualifier("alipay") //在类上指定
public class AlipayService implements PaymentService {
// ...
}
@Service
@Qualifier("wechat")
public class WechatPayService implements PaymentService {
// ...
}
4.4.3 @Resource
@Resource是Java标准注解(JSR-250),是javaE提供的注解并不是spring框架提供。@Autowired默认是按照类型注入,@Resource默认按名称进行注入。
需要注意三点规则:
-
如果指定了name属性,则按名称查找Bean
-
如果未指定name,则按字段名/方法名作为Bean名称查找
-
如果按名称找不到,则回退到按类型查找
@Service
public class OrderService {
@Resource(name = "alipayService") // 按名称注入
private PaymentService paymentService;
// 或者使用字段名匹配(推荐写法)
@Resource
private PaymentService alipayService; // 会自动查找名为alipayService的Bean
}
五、总结
控制反转与依赖注入是Spring框架的基石,它们通过将对象的创建和管理权从应用程序代码转移到IOC容器,有效降低了组件间的耦合度。在代码实现上面,文中介绍了多种方式。根据具体的业务需求选择合适的方案,或者组合使用以达到最佳的依赖管理效果。
最后感谢您的阅读,如有表述不准确或错误,欢迎批评指正!
322



被折叠的 条评论
为什么被折叠?



