聊到“Spring不使用XML的注解开发”,很多Java开发者的第一反应是:把<bean>标签换成@Bean注解,把<property>换成@Autowired,就完事了。这个理解不能说错,但实际操作过的人都知道,从XML切换到纯注解开发,真正要适应的不是怎么写注解,而是容器发现和管理Bean的整套逻辑变了。我早期接手过一个老项目,就是带着这种浅层理解去迁代码,结果一路踩坑,有些问题查资料都要折腾大半天。这篇文章会把Spring纯注解开发的完整路径梳理一遍,从核心机制、搭建步骤,到依赖注入的实战细节,再到从XML迁移过程中最容易炸的五个问题,全部展开讲清楚。打算做注解迁移、或者刚从XML教程过渡到注解教程的朋友,可以直接照着操作。
1. 从XML到注解:Spring开发方式变迁的关键节点
1.1 XML配置的痛点与注解出现的必然性
当年Spring项目长什么样?一个applicationContext.xml里堆着几十个bean定义,每个Service、每个Dao都要注册。加一个UserDao,要改三处:先写一个<bean>,再给UserService补<property>标签,如果Dao自己还有依赖,还得再带一个ref。类一多,配置文件的长度和排查问题的时间直接成正比。
那段时期我印象最深的坑是改类名。把UserService改成MemberService,XML里所有地方都要同步,漏掉任何一个,启动时才会看到NoClassDefFoundError或者NoSuchBeanDefinitionException。后来的IDE虽然能重构类名并联动XML,但当一个文件里有一百个bean定义的时候,一屏根本看不完,光找位置就够受的。
注解出现的必然性,本质上是把“类A是业务组件、类B需要依赖类C”这些信息,从外部配置文件搬回到离代码最近的地方。这个趋势和Java社区从配置走向注解的大方向一致:Spring 2.5引入了@Autowired和@Component,Spring 3.0带来了@Configuration和标准化的注解方式,Spring Boot更是把“约定大于配置”推到了极致。我见过一些团队到现在还在坚持全XML,理由也不完全没道理——配置集中,新人翻一遍XML能理清大致结构。但就我自己维护的项目而言,注解带来的可读性和重构效率远远超过那些损失,尤其是代码量上来以后。
1.2 组件扫描:注解开发真正的地基
先问一个关键问题:类上写了@Component,Spring到底是怎么知道的?
答:靠组件扫描器。Spring容器启动时,会扫描指定包路径下的类,读取class文件里的注解元数据,把标记了@Component及其派生注解的类注册成BeanDefinition,然后再在合适的时机实例化、注入依赖、执行初始化回调。
对应到XML时代,这就是那行<context:component-scan base-package="com.example"/>。换到纯注解方式,这行配置变成了@Configuration类上的@ComponentScan("com.example")。
很多刚转注解的人会犯一个错:类上写了@Component,配置类也写了@Configuration,但偏偏没写@ComponentScan,或者扫描的包路径覆盖不到目标类。结果启动时日志风平浪静,一调用getBean就抛NoSuchBeanDefinitionException。这个错在XML时代也有,但XML里base-package一般写得很清晰,全局搜一下context:component-scan就能看到;注解方式下扫描范围分散在各个配置类里,反而更容易漏。
然后是@Configuration的CGLIB增强问题。Spring里带@Configuration的类会被CGLIB做代理增强,目的是让@Bean方法在单例Bean首次被调用后,后续再调用直接返回同一个实例。这也是官方文档强调@Configuration和普通类的区别:如果把@Bean方法放在普通类里,或者自己new一个配置类去调方法,@Bean的缓存语义就不会生效。不少新手在单元测试里写AppConfig config = new AppConfig(); config.userService();,跑完发现每次都是新对象,其实就是代理机制没走上的缘故。
1.3 入口容器换血:AnnotationConfigApplicationContext
XML时代启动Spring容器,用的是ClassPathXmlApplicationContext,传入的是配置文件路径。切换到注解开发,入口对应的是AnnotationConfigApplicationContext。
它有两种常见用法:
- 传配置类:
new AnnotationConfigApplicationContext(AppConfig.class) - 传包路径:
new AnnotationConfigApplicationContext("com.example")
这两者有本质区别:传配置类时,Spring会从配置类上声明的@Bean和@ComponentScan开始加载;传包路径时,Spring直接扫描该包下的组件,不会主动去找配置类。所以如果你的入口传的是包路径,配置类里写的@Bean方法是不生效的——除非配置类本身也在扫描包下面、被当作普通组件扫到。
我实际项目里见过不止一次混用这两个构造器的案例,结果表现为:某个Bean在测试环境有、生产环境没有,排查半天发现就是入口写法不同。建议统一成传配置类,把包扫描范围写死在@Configuration的@ComponentScan里,入口永远只写new AnnotationConfigApplicationContext(AppConfig.class)这一种,团队成员之间也好对齐。
2. 纯注解搭建Spring工程的完整步骤
2.1 最小依赖和项目骨架
纯注解Spring不是说不依赖Spring框架,而是说不依赖applicationContext.xml这个文件。如果用Maven管理项目,核心依赖其实只有一个:
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>6.1.6</version> </dependency>这个依赖会带出spring-core、spring-beans、spring-aop、spring-expression等一堆基础模块。要跑Web应用再加spring-webmvc,要做数据访问再加spring-jdbc或Spring Data JPA,这些后面可以按需补充。
一个纯注解的最小项目,目录大概长这样:
src/main/java └── com.example ├── config │ └── AppConfig.java ├── service │ └── UserService.java └── Main.javaMain里的启动代码:
import org.springframework.context.annotation.AnnotationConfigApplicationContext; public class Main { public static void main(String[] args) { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class); UserService userService = ctx.getBean(UserService.class); userService.hello(); ctx.close(); } }这个骨架是后续所有示例的基础。先跑通它,再谈各种高级注解,排查问题时也能有个干净的环境。
2.2 配置类怎么写:@Configuration、@ComponentScan、@Bean
AppConfig的经典写法:
package com.example.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; @Configuration @ComponentScan("com.example") public class AppConfig { @Bean public String appName() { return "demo-app"; } }三个注解各管一件事:@Configuration标记这是一个配置类,相当于以前XML配置中的根节点;@ComponentScan("com.example")告诉Spring去com.example包下扫描组件;@Bean方法显式声明一个Bean。
关于@Bean有几个容易忽略的细节:
- 方法名就是Bean的名字。上面的
appName,容器里Bean名就叫appName,类型是String。如果方法名叫app,想指定名字可以用@Bean("app")。 @Bean方法的参数会自动从容器中按类型注入。比如方法签名写成dataSource(DataSourceProperties props),Spring会在调用时把容器里对应的Bean传进来。@Bean最适合用来注册自己无法加注解的第三方类,比如连接池、Redis客户端、各种SDK Client。这个点后面第5节还会细说。
2.3 @ComponentScan的过滤规则
@ComponentScan不是只能写包路径,它还能做精细的装配控制。常用属性如下:
| 属性 | 作用 |
|---|---|
value/basePackages | 指定扫描包路径,可传多个字符串 |
basePackageClasses | 指定某个类的包作为扫描起点,重构时更安全 |
nameGenerator | 自定义Bean名称生成器 |
includeFilters | 包含过滤器,指定哪些类型要额外扫描 |
excludeFilters | 排除过滤器,指定哪些类型不扫描 |
useDefaultFilters | 默认true,即扫描@Component派生注解;设false后只扫描includeFilters指定的类型 |
示例:在扫描时排除某个临时组件。
@Configuration @ComponentScan( basePackages = "com.example", excludeFilters = @ComponentScan.Filter( type = FilterType.ASSIGNABLE_TYPE, classes = TemporaryBean.class ) ) public class AppConfig { }includeFilters的典型场景是自定义框架里定义了一个自己的注解,希望扫描时只处理带这个注解的类。这时要配合useDefaultFilters = false,否则默认的@Component扫描还是会全部扫进来。这个功能在做插件式架构时非常好用,但日常业务项目里用得不多,知道有这回事就行。
3. 常用注解的语义边界:哪些是注册、哪些是装配、哪些是生命周期
3.1 注册类注解:@Component及其派生注解
@Service、@Repository、@Controller这三个注解,底层都加了@Component。所以从扫描逻辑看,它们都是Component的特化,Spring只要遇到@Component的派生注解就会注册Bean。
四个注解的分工:
@Component:通用组件,没有明确的层次语义@Service:业务层@Repository:数据访问层,通常结合持久层框架做异常转换@Controller:Web控制层,配合@RequestMapping做请求映射@RestController:组合了@Controller和@ResponseBody
很多团队实际使用中并不严格区分,Service类写@Component照样能跑。但我的建议是还是按分层语义来,因为Spring的AOP、事务管理往往按这些注解决定是否增强,语义边界用错了,后面的切面配置会写得很别扭。比如事务切面直接匹配@Service,结果你的业务类写的是@Component,切到哪里就完全对不上了。
3.2 装配类注解:@Autowired、@Qualifier、@Resource
@Autowired是核心。默认按类型注入,遇到同类型有多个Bean时,必须配合@Qualifier("beanName")按名称指定。
一段最常见的字段注入写法:
@Service public class OrderService { @Autowired private OrderDao orderDao; }字段注入代码量最少,但有个明显问题:OrderService没有显式构造函数,单元测试时不方便替换OrderDao的实现。更推荐构造器注入:
@Service public class OrderService { private final OrderDao orderDao; public OrderService(OrderDao orderDao) { this.orderDao = orderDao; } }类上只有一个构造器时,Spring会自动使用这个构造器完成注入,不需要额外写@Autowired。这样orderDao是final的,创建后不可变,测试时直接new OrderService(mockOrderDao)就行。
@Resource是Java标准里的注入注解,按名称优先注入。在Spring项目里也能用,但它只能用在字段和setter上,不能用在构造器上。注意Spring 6时代对应的是jakarta.annotation.Resource。用@Resource时如果找不到对应名称的Bean,会回退到按类型注入,这个行为和@Autowired的默认策略有细微差异,容易造成一种“怎么换个环境就注入错了”的错觉。
3.3 生命周期与作用域:@Scope、@PostConstruct、@PreDestroy
@Scope("prototype")表示每次getBean都返回新实例,默认的singleton则是全局只有一份。日常业务里绝大多数Bean都是单例,需要注意的是单例Bean不能保存可变状态;如果需要每次独立计算的对象,才考虑prototype。
@PostConstruct和@PreDestroy不是Spring独有的注解,而是JSR-250里的标准。Bean创建并完成依赖注入后,会执行@PostConstruct标注的方法;容器销毁时,执行@PreDestroy标注的方法。它们可以替代XML里的init-method和destroy-method。
有一点很关键:这两个注解只有在Bean交给Spring容器管理时才生效。如果你自己在代码里new了一个@Component类,那不会有任何生命周期回调。这个逻辑和@Autowired一样——没有容器参与,注解就只是注释。
4. 纯注解方式下的Bean依赖注入实战解析
4.1 构造器注入与字段注入的取舍
构造器注入的优点前面已经提到:不可变、依赖完整、易于测试。Spring官方文档也是明确推荐构造器注入。但现实项目里字段注入非常常见,甚至很多网上的教程也这么写,因为省事。
我的态度是:新代码用构造器注入,老项目里看到字段注入不用急着推翻重来,但凡是改动到的类,顺手改成构造器注入。一天改一两个,老代码慢慢就变干净了,冲动地一次性全局替换反而容易冒出意外问题。
再补充一个构造器注入的细节:如果类上有多个构造器,Spring不会自动帮你选,需要在一个构造器上显式加@Autowired,否则启动直接报错。这个情况常见于老代码里保留了一个无参构造器。与其加注解,不如删掉多余构造器,让代码意图更清晰。
4.2 多实现类的装配策略:@Primary、@Qualifier、按名称
当接口有多个实现类时,@Autowired按类型注入必然失败,启动时直接报:
No qualifying bean of type '...MessageSender' available: expected single matching bean but found 2典型的场景是这样:
public interface MessageSender { void send(String msg); } @Component public class SmsSender implements MessageSender { public void send(String msg) { System.out.println("sms: " + msg); } } @Component public class EmailSender implements MessageSender { public void send(String msg) { System.out.println("email: " + msg); } }三种解决方式对比:
| 方式 | 做法 | 适用场景 |
|---|---|---|
@Primary | 在某个实现类上标注,让它成为默认注入对象 | 有一个主实现,其余是备选 |
@Qualifier("emailSender") | 在注入点指定Bean名字 | 不同调用处需要不同实现 |
| 参数名匹配 | 构造器参数名与Bean名一致 | 不推荐,依赖编译器保留参数名,太隐晦 |
我个人的实际经验:如果多个实现共存只是为了代码结构,那缩窄到只有一个会更省心。比如只需要邮件发送,就把SmsSender排除在扫描之外,或者用条件装配按配置动态决定注册哪个实现。两个实现确实都要对外服务时再考虑@Qualifier,否则所有注入点都要带限定名,代码到处都在重复声明。
4.3 循环依赖:注解方式更容易暴露的问题
循环依赖指的是A注入B、B又注入A这种情况。
字段注入时,Spring在旧版本默认情况下能通过三级缓存解决这个环。简单理解:先创建A的早期引用放入缓存,再在注入B的过程中创建B,B注入A时拿到早期引用,等两边都建好再完成属性填充和初始化。这套机制在Spring 5及以前的纯Spring容器里默认开启,但Spring Framework 6.1开始,默认改成了不允许循环依赖,Spring Boot 2.6之后也默认通过探针检测并直接报错。
换成构造器注入后,循环依赖会暴露得更快:创建A时需要构造器参数B,但B也还没法创建,因为B的构造器也要A。于是容器直接抛BeanCurrentlyInCreationException,根本不给三级缓存机会。
很多人遇到这个异常的第一反应是改成字段注入绕过去。我劝你别这么干。循环依赖往往意味着设计上存在环,应该有意让它暴露出来,然后拆开:把A和B共同依赖的部分下沉到一个新组件C,或者把其中一个依赖改成延迟获取。延迟获取可以用ObjectProvider:
@Service public class A { private final ObjectProvider<B> bProvider; public A(ObjectProvider<B> bProvider) { this.bProvider = bProvider; } // 真正要用到B时,再获取 public void run() { B b = bProvider.getIfAvailable(); } }靠三级缓存续命,只是把问题往后拖延,升级Spring版本或者改成强制构造器注入时,同样的坑还得再踩一次。
5. 从XML迁移到注解时的5个高频坑
5.1 扫描不到Bean:NoSuchBeanDefinitionException
现象:启动不报错,一调用getBean就抛NoSuchBeanDefinitionException。这就是典型的“注解写了但没被扫描到”。
排查链路按下面顺序来:
- 检查
@Configuration类是否真的传给了AnnotationConfigApplicationContext。 - 检查
@ComponentScan的basePackages是否覆盖了目标类所在包。 - 确认目标类上的注解确实是
@Component或它的派生注解,而不是误写成了@Configuration——这个注解不会让类变成组件。 - 如果项目里创建了多个
ApplicationContext,确认是不是往错误的容器里取Bean。 - 确认编译输出目录里有这个类,排除IDE缓存或target目录未刷新的情况。
我遇到的一个真实案例:开发写了一个@Service类放在com.example.biz包下,配置类写在com.example.config里,@ComponentScan写的是com.example.config。看起来离得不远,但扫描范围是精确到包的,config包下面根本扫不到biz包。这种错误在XML时代反而不容易出现,因为base-package通常写的是顶级包。所以纯注解开发,@ComponentScan尽量写顶层包,比如com.example,而不是某个具体子包。
5.2 容器选型错误:还在用ClassPathXmlApplicationContext
迁移老项目时最常见的一类问题:代码全部改成了注解,但main方法还在用new ClassPathXmlApplicationContext("applicationContext.xml")。如果XML文件已经被删掉,启动直接FileNotFoundException;如果XML还在但没加<context:component-scan>,注解类依然不会被扫描,表现就是“启动成功但Bean全没有”。
这种混合状态最坑。过渡期的建议是:要么全XML,要么全注解。实在需要共存,XML里必须补上<context:component-scan base-package="..."/>,程序入口也统一改成AnnotationConfigApplicationContext。哪怕混合使用,入口和扫描机制也要先理清楚,否则排查成本很高。
5.3 @Value取不到值:占位符没有解析机制
纯注解开发里用@Value("${db.url}")注入外部配置,但有时候会发现注入进去的是没解析的字符串${db.url}本身。原因是:${}占位符要靠PropertySourcesPlaceholderConfigurer去解析,而纯Spring环境里这个Bean不是自动注册的。XML时代Spring会自动配置一个<context:property-placeholder/>,注解时代就需要手动声明。
常规写法是配合@PropertySource:
@Configuration @PropertySource("classpath:db.properties") public class DbConfig { @Bean public static PropertySourcesPlaceholderConfigurer propertyConfigurer() { return new PropertySourcesPlaceholderConfigurer(); } @Value("${db.url}") private String dbUrl; }注意这个方法必须是static,因为在容器生命周期的早期就要用它处理占位符,如果方法不是静态的,解析时机就会靠后,@Value还是拿不到值。
如果是Spring Boot项目,自动配置已经处理好了这些,所以很多Boot用户没踩过这个坑。但一旦用纯Spring做底层框架或中间件,这个问题就会出现。另一个容易踩的点是多个properties文件时,后声明的@PropertySource会覆盖前面的同名key,配置命名的优先级经常因此被搞混。
5.4 @Transactional失效:代理机制与自调用
这个坑在注解开发里太经典了。看例子:
@Service public class OrderService { @Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { orderDao.save(order); updateStock(order.getProductId()); } @Transactional(rollbackFor = Exception.class) public void updateStock(Long productId) { stockDao.decrease(productId); } }createOrder方法内部用this.updateStock()调另一个@Transactional方法。看起来事务嵌套了,实际updateStock上的事务完全没生效。原因在于Spring事务基于AOP动态代理:外部调用createOrder会经过代理对象,事务切面在此开启;但方法体里的this指向原始对象,调updateStock压根没经过代理,事务切面根本没有机会介入。
怎么判断有没有遇到这个问题?看数据库日志:虽然两条update语句都执行了,但在中间制造一个异常后,数据库不会一起回滚,这就是事务失效的典型特征。
解决办法有两类:
- 拆类:把
updateStock放到另一个Bean里,在OrderService注入那个Bean的代理再调用。 - 使用编程式事务:用
TransactionTemplate把事务边界显式写出来,不依赖代理。
自调用问题不仅出现在事务上,凡是基于AOP的注解(如缓存、异步@Async、自定义切面)都有同样的毛病。理解“代理”这个根因后,很多注解“不生效”的问题都能自己推出来。
5.5 第三方类无法扫描:@Import与@Bean兜底
这个问题放到最后,但它在实际项目中出现的频率非常高。比如引入了一个开源SDK,里面的Client类需要放进Spring容器,但它既没有@Component,你也改不了它的源码,扫描永远扫不到它。此时必须用@Bean显式注册:
@Configuration public class SdkConfig { @Bean public SomeSdkClient someSdkClient() { return new SomeSdkClient("key", "secret"); } }如果第三方SDK提供了一整套可配置的装配逻辑,@Import更好用。@Import最常见的能力是把一个普通类变成Bean,但它更强大的用途是配合ImportSelector和ImportBeanDefinitionRegistrar做条件装配,很多框架的starter底层都是这么干的。
我在项目中的习惯是把所有第三方SDK的装配集中到一个SdkConfig配置类里,模块内部的初始化细节不外泄,其他模块想用就直接注入客户端,整体集成体验比在XML里到处写类名清爽得多。
6. 纯注解开发之后,哪些地方我还是会用外部配置
6.1 数据源与连接池参数
尽管纯注解开发很顺手,但有些场景我坚持把参数放到外部配置文件里,比如数据源。不同环境(本地、测试、生产)的连接串不一样,直接在@Bean里硬编码,换环境就得改代码重新打包,这非常不划算。
一般做法是db.properties配合@PropertySource和@Value,或者干脆用@ConfigurationProperties绑定到一个配置类上。这个思路和XML时代的<context:property-placeholder>是一样的,只是载体从XML换成了Java配置类。我自己的原则是:凡是“像配置”的值跟“像代码”的逻辑分开。连接池、超时时间、重试次数这类属于配置,放外部文件;Bean的组装逻辑、条件分支属于代码,放配置类。坚持这个原则,换环境、调参数都很快。
6.2 多环境切换与条件装配
纯注解下做多环境切换,最常用的就是@Profile和@Conditional。@Profile("dev")标注的Bean只在dev环境生效,@Conditional可以自定义装配条件。相比XML时代手写多个properties文件,这套方案灵活得多。
但要提醒的是:条件装配用多了,启动上下文会变得很难预测。你很难一眼看出某个环境最终装配了哪些Bean,出问题时要逐个condition排查。所以我的建议是:条件注解用来做“生产可用但默认关闭”的可选能力,不要把所有配置都变成条件,否则以后再接手这个项目的人会骂你。
6.3 我的默认组合:注解为主、外部配置为辅
最后聊点个人实际体会。经历了全XML、XML+注解混合、纯注解三个阶段,现在的默认做法是:项目主体用纯注解开发,基础设施类的配置(数据源、消息队列、缓存、SDK客户端)独立成配置类,连接参数通过外部配置文件注入。这样既拿到了注解带来的可读性和重构效率,也保留了配置的外部化能力。
我也见过比较激进的做法:所有配置全部注解硬编码,连数据库地址都写在@Value的默认值里。这种项目换个环境简直想骂人。所以不要让“纯注解”变成一种执念。注解和XML从来不是非此即彼的关系,判断标准只有一个:改哪个更省事、出问题哪个更容易定位。对我来说,代码层面的依赖关系放注解里,环境层面的参数放外部配置里,就是最舒服的边界。