尧图精选

Spring注册Bean的三种方式:XML、注解与@Bean配置类

🕒 发布时间:2026/10/1 5:02:12 📁 来源:尧图网络
1. 三种方式背后的设计演进为什么XML、注解和配置类并存见到“把一个bean对象交给Spring容器管理”这个问题很多人第一反应是直接把类上贴个Service或者Component不就完事了吗确实在Spring Boot大行其道的今天注解扫描是最常见的姿势。但真到了面试聊底层、或者接手一个从Spring 3.x时代走过来的老项目时你会发现“让容器管理bean”这件事远不止加注解这么简单。先说一个核心认知Spring容器本身并不关心bean是怎么被发现的它只认BeanDefinitionBean定义。所谓的“三种方式”本质上是三种不同的途径最终目的都是把某个类的BeanDefinition注册到容器里让容器能够实例化、装配、管理这个bean的生命周期。理解了这个之后再看XML、注解、Bean配置类这三种写法你就不会觉得它们是割裂的三套东西了。Spring最早的时代是纯XML驱动所有bean都在applicationContext.xml里声明。后来Spring 2.5引入了注解驱动Component、Autowired开始普及。再后来Spring 3.0推出了JavaConfig也就是Configuration Bean的编程式配置方式这套东西在今天已经被Spring Boot完全承接变成了事实上的标准。为什么会有这种演进核心驱动力有两个。第一类型安全。XML里写的是字符串类名拼错了要等到容器启动报ClassNotFoundException才能发现而Bean方法直接返回具体的类编译器就能帮你把关。第二可维护性。一个大型项目几百个bean全写在XML里文件膨胀到几千行查找、排错都是灾难。注解和配置类把bean定义挪到了Java代码里跟着业务类走内聚性明显更好。但这三种方式并没有真正互相替代。XML在企业老项目和某些特殊基础设施场景里仍然存在注解适合标记“自己写的业务组件”而Bean则是“把第三方库的类对象交给Spring管理”的最佳方案。下面逐一拆。2. 方式一XML声明式配置把bean写进配置文件2.1 最基本的bean标签注册在Spring 3.0之前定义一个bean的标准做法是在XML配置文件中使用bean标签。假设你有一个DataSource类想让它被Spring容器管理最原始的写法就是?xml version1.0 encodingUTF-8? beans xmlnshttp://www.springframework.org/schema/beans xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd bean iddataSource classcom.example.config.DataSource property nameurl valuejdbc:mysql://localhost:3306/demo/ property nameusername valueroot/ property namepassword value123456/ /bean /beans这段XML做的事情翻译成白话就是告诉Spring容器“帮我创建一个id为dataSource的实例它的类是com.example.config.DataSource创建完之后把url、username、password这三个属性给我set进去”。容器拿到这段定义后会把它解析成一个BeanDefinition对象存到自己的注册表里。等初始化的时候再根据这个BeanDefinition反射创建真实的对象。这里有个关键细节bean标签里的class是不可以省略的除非是抽象父bean或子bean但id可以省略。id省略后Spring会自动生成一个名字格式是com.example.config.DataSource#0这种全限定类名加序号可读性很差。所以几乎任何实际项目里都会显式声明id因为后续的注入都是靠id来引用的。2.2 XML方式的依赖注入构造注入与setter注入XML里除了注册bean本身还要解决bean之间的依赖关系。比如你的DataSource要被ServiceA用那就需要在ServiceA的bean定义里声明这个引用。构造注入的写法bean iduserService classcom.example.service.UserService constructor-arg index0 refdataSource/ /beansetter注入的写法bean iduserService classcom.example.service.UserService property namedataSource refdataSource/ /bean注意这两种写法的区别constructor-arg走的是构造函数property走的是setter方法。用哪一种取决于你的类设计。我见过不少同事在XML里同时使用这两种注入方式结果导致同一个bean的部分依赖通过构造函数传、部分通过setter塞排查问题的时候非常混乱。如果项目还在用XML配置建议统一用一种方式要么全构造注入要么全setter注入。另外还有个坑容易被忽略property name里的name对应的是setter方法名推导出来的属性名。比如你有一个setDataSource(DataSource ds)方法那name就是dataSource如果你在类里写的是setDs(DataSource ds)那name必须写ds否则启动时直接报Invalid property dataSource。2.3 XML方式的实际价值与使用注意现在做新项目基本不会主动用XML了但有两种情况你绕不开。第一种是接手老项目Spring Dubbo、Spring 老版本MyBatis的整合很多历史项目还是XML驱动不会读XML配置等于没法干活。第二种是和第三方框架做整合时某些场景下XML的声明式配置比Java代码更清晰比如MyBatis的mapper扫描、Dubbo的dubbo:reference虽然Spring Boot把这些都starter化了但底层实现里还是能看到XML配置的影子。用XML方式时必须注意两点。第一如果你用ClassPathXmlApplicationContext加载XML需要把配置文件放在classpath下然后这样写ApplicationContext context new ClassPathXmlApplicationContext(applicationContext.xml); UserService userService context.getBean(userService, UserService.class);第二XML里尽量不要写死值尤其像数据库连接、第三方接口地址这类环境相关的配置应该用占位符bean iddataSource classcom.example.config.DataSource property nameurl value${jdbc.url}/ /bean然后配合context:property-placeholder locationclasspath:jdbc.properties/加载属性文件。这样环境切换的时候只需要改配置文件不用动bean的定义。3. 方式二注解扫描让容器自动发现bean3.1 四个组件注解分别怎么用Spring 2.5开始引入注解驱动到了Spring Boot时代注解已经成了绝对的主导方式。把一个类交给容器管理就是在类上面加一个组件注解。常见的四个分别是Component通用组件任何类都可以贴。Service业务层组件语义上标记Service。Repository数据访问层组件语义上标记DAO。Controller控制层组件Spring MVC的控制器。注意一个关键点这四个注解的功能本质上完全一样区别只是语义不同。源代码里Controller、Service、Repository都被Component元注解标注了所以对容器来说它们都是“被Component标注的类”。为什么要细分纯粹是为了代码可读性和后续AOP切面能做更精准的切点匹配。比如你写一个AOP切面只想作用于Service注解的类如果没有语义区分你就只能靠包路径匹配很别扭。用法很简单Service public class UserService { public String getUserName(Long userId) { return user_ userId; } }但光贴注解还不够你还得让Spring知道“去哪里扫描这些注解”。在Spring Boot里启动类上的SpringBootApplication本身就包含了ComponentScan默认扫描启动类所在包及其子包。所以只要你的类放在启动类的子包下面直接就被扫到了。3.2 ComponentScan的扫描规则与过滤如果你不是Spring Boot项目而是传统的Spring MVC项目就需要自己在XML或者配置类里声明扫描路径Configuration ComponentScan(basePackages com.example) public class AppConfig { }用XML的话就是context:component-scan base-packagecom.example/。这些都是基础操作真正容易出问题的是ComponentScan的过滤规则。假设你引入了一个第三方jar包里面有一个类碰巧被Component标注了但它不应该被你的容器管理这时候就可以用excludeFilters把它过滤掉Configuration ComponentScan( basePackages com.example, excludeFilters ComponentScan.Filter( type FilterType.ASSIGNABLE_TYPE, value SomeUnwantedClass.class ) )FilterType有几种比较常用的是ASSIGNABLE_TYPE按类/接口类型排除、ANNOTATION按注解排除、REGEX按类名正则排除、ASPECTJ按AspectJ表达式排除。我实际项目里用ANNOTATION最多比如排除所有标了Deprecated的类ComponentScan.Filter(type FilterType.ANNOTATION, classes Deprecated.class)3.3 注解方式的关键限制注解方式最大的限制是你只能给“自己写的类”加注解。如果你想让一个第三方的类外部工具类、配置类被Spring管理那些类的源码不在你手里没法直接贴注解。比如我要引入一个RedisTemplate、一个RestTemplate、一个线程池ThreadPoolTaskExecutor这些都不是我定义的类没法在它们身上贴Component怎么办这就是第三种方式Bean登场的理由。另外还有一个容易被新手忽略的问题注解扫描并不等于“所有的类都被自动管理”。只有被扫描到且满足条件的类才会被注册。如果某个类没有被Component标注、也没有通过Bean声明那它就是普通的Java对象Spring根本不知道它存在。还有一个细节Component标注的类是单例bean默认在容器启动的时候就实例化了前提是它不是懒加载。如果你的类构造方法里有重逻辑启动时间会被拖慢。这个时候可以考虑在类上加Lazy注解让它在第一次被使用的时候再实例化。不过这个用法要谨慎延迟初始化的bean如果AOP切面依赖它会导致切面在初始化阶段拿不到对象引发一些奇奇怪怪的问题。4. 方式三BeanConfiguration最灵活的编程式注册4.1 在配置类里写Bean方法第三种方式的核心是Bean注解。它写在方法上表示“这个方法返回的对象交给Spring容器管理”。Configuration public class WebConfig { Bean public RestTemplate restTemplate() { RestTemplate restTemplate new RestTemplate(); restTemplate.setConnectTimeout(5000); return restTemplate; } }这段代码的意思是容器初始化的时候调用restTemplate()方法把返回的RestTemplate对象放进容器里。之后你在任何地方用Autowired注入RestTemplate拿到的就是这个对象。注意一个很多人一开始不理解的细节Bean方法的方法名默认就是bean的名字。上面这个例子注册的bean名是restTemplate不是RestTemplate。如果你希望指定别的名字可以写Bean(httpClient)那bean名就成了httpClient。这个在多个Bean方法返回同类型对象时尤其重要。这个方式为什么最强因为它本质上是在Java代码里“自己写工厂方法”。你有任何定制逻辑都可以往方法里塞。比如需要根据配置文件的某个开关来决定创建哪个实现类Bean public CacheManager cacheManager(Value(${cache.type}) String cacheType) { if (caffeine.equals(cacheType)) { return new CaffeineCacheManager(); } return new ConcurrentMapCacheManager(); }XML和注解方式都做不到这种“运行时逻辑判断”。4.2 Bean方法之间如何互相依赖配置类里经常有多个Bean方法而且它们之间会有依赖关系。比如我们项目里有个ThreadPoolExecutor配置它依赖一个ThreadPoolTaskExecutor内部的线程工厂参数Configuration public class ThreadPoolConfig { Bean public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setQueueCapacity(100); executor.setThreadNamePrefix(demo-); executor.initialize(); return executor; } Bean public AsyncService asyncService(ThreadPoolTaskExecutor taskExecutor) { return new AsyncService(taskExecutor); } }这里asyncService方法接收一个ThreadPoolTaskExecutor参数Spring看到这个参数后会自动从容器里查找已注册的同类型bean把它传进去。所以Bean方法之间可以通过参数引用来解决依赖不需要手动调用另一个Bean方法。但这里藏着一个非常经典的大坑如果在Bean方法里直接手动调用另一个Bean方法会发生什么Bean public AsyncService asyncService() { return new AsyncService(taskExecutor()); // 手动调用 }在Configuration类的默认代理模式下taskExecutor()返回的还是同一个容器管理的bean实例因为CGLIB代理会拦截调用并走容器查找所以单独看好像没问题。但如果你把Configuration改成Component标注这个类就会失去这个代理保证taskExecutor()会直接new一个全新的实例返回导致asyncService和你容器里那个taskExecutor根本不是同一个对象。这个问题排查起来非常隐蔽因为单看运行结果可能每种功能都是正常的但线程池资源就白白创建了一套。我的习惯是Bean方法之间一律通过方法参数注入绝不手动调用。一是安全二是不依赖代理机制的隐式约定代码意图更清晰。4.3 Bean方法返回接口还是实现类这又是一个实操中的选择题。假设你有一个MessageSender接口两个实现类public interface MessageSender { void send(String message); } Component public class SmsMessageSender implements MessageSender { Override public void send(String message) { System.out.println(sms: message); } } Component public class EmailMessageSender implements MessageSender { Override public void send(String message) { System.out.println(email: message); } }这两个实现类是注解扫描注册的接口绑定本身没问题。但如果你要在Bean方法里new一个实现类返回切记返回类型要写成接口或者实际实现类取决于你的调用方怎么用。如果返回类型写MessageSender容器里注册了一个bean类型是接口调用方用Autowired MessageSender sender注入能拿到这个bean但如果调用方写Autowired SmsMessageSender smsSender那就会NoSuchBeanDefinitionException因为容器里的这个bean类型是MessageSender不是SmsMessageSender。Spring容器查找bean是按“类型兼容”来判断的一个类型为MessageSender的bean可以被注入到MessageSender类型的字段里但不能被注入到SmsMessageSender类型的字段里。5. 三种方式的取舍什么时候用哪一种5.1 一张表看清三种方式的异同很多人在面试里被问到“Spring创建bean有哪几种方式”回答XML、注解、Bean就完了。但真的在项目里做技术选型的时候光背出三种方式的名字远远不够。我整理了一张针对实际开发场景的对比表维度XML声明组件注解Bean配置类适用类任意类含第三方仅限自己写的类任意类含第三方类型安全无运行时才暴露类名错误有编译器参与有编译器参与可读性差配置和代码分离好注解在类旁边好配置集中且逻辑清晰动态创建逻辑几乎不支持不支持支持方法体里可以写任意逻辑依赖注入constructor-arg/propertyAutowired字段/构造器Bean方法参数维护成本高文件膨胀后出问题难查低Spring Boot默认低集中管理方便评审典型场景老项目、第三方框架整合业务组件、Controller、Service第三方Bean、条件创建、复杂初始化逻辑这张表基本可以决定你日常写代码时的方案选择。我给团队定的规矩很直白所有自己写的业务组件一律用注解扫描所有涉及第三方库的Bean、或者需要根据配置/条件来创建对象的一律用Bean配置类除非必要否则新代码不写XML。5.2 现实项目的混合策略实际项目里这三种方式通常不是互斥的而是混用的。比如一个Spring Boot项目里Controller和Service基本全是Component系列注解然后配置类里放了一堆RestTemplate、RedisTemplate、KafkaTemplate、线程池这些基础设施bean。某个老模块还在用XML引入那么配置类里加一个ImportResource(classpath:legacy.xml)就能把老XML里的bean也加载进容器和注解扫描、Bean定义共存。这种混合架构是现实世界中非常常见的状态。你要明白它们之间并不冲突因为最终的归宿都是BeanDefinitionRegistry。容器初始化时解析XML得到的BeanDefinition、扫描注解得到的BeanDefinition、Bean方法得到的BeanDefinition全都会送到同一个注册表里后续的处理流程完全一致。所以你在一个项目里同时用三种方式容器不会闹矛盾矛盾只会出现在你注入的时候引用错了bean名字。5.3 BeanDefinitionRegistryPostProcessor动态注册的第四种思路虽然标题说的是三种方式但我在实际项目中遇到过一种场景就是需要在容器启动早期动态注册bean比如根据数据库里的开关配置来决定注册哪一个实现类。这种场景下我给你分享一个不算“第四种方式”但具备补充作用的技法实现BeanDefinitionRegistryPostProcessor接口在postProcessBeanDefinitionRegistry方法里手动注册BeanDefinition。Component public class DynamicBeanRegistrar implements BeanDefinitionRegistryPostProcessor { Override public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) { GenericBeanDefinition bd new GenericBeanDefinition(); bd.setBeanClassName(com.example.dynamic.DynamicServiceImpl); bd.setScope(BeanDefinition.SCOPE_SINGLETON); registry.registerBeanDefinition(dynamicService, bd); } Override public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) { // 这个方法在BeanDefinition注册完成、bean实例化之前回调 } }这种方式发行的时机特别早在Spring的invokeBeanFactoryPostProcessors()阶段就会执行比普通bean的实例化要早得多。如果你设计的框架需要扫描某些自定义注解后自动注册一批bean那这种动态注册方式比ComponentScan更可控。当然这种方式也引入了一定的理解和调试成本不是万不得已不建议常规使用。6. 踩坑实录手动注册bean时的常见问题6.1 同名bean覆盖的坑三种方式混用时最容易踩的坑是bean名字冲突。比如XML里你定义了一个id为userService的bean代码里又有个Service标注的UserService默认的bean名就是userService。容器启动时后面注册的会覆盖前面注册的Spring Boot默认允许覆盖但会打日志具体哪个生效取决于加载顺序这种不确定性最坑人。强调一下同名覆盖的日志长这样——BeanDefinitionOverrideException的警告不是报错Bean name userService is already registered。如果你项目中大量出现这个警告建议尽早排查。用Bean时如果方法名撞了同样会覆盖唯一的办法是显式指定不同的bean名。我给团队定的规范是配置类里的Bean方法名统一跟类名有区分度不要跟业务类注解名冲突。比如配置里的线程池bean叫taskExecutor业务Service叫taskService一眼就能分开。6.2 Bean方法内部调用自己导致的代理失效前面说过了在Configuration类中手动调用另一个Bean方法时如果是默认配置模式CGLIB代理会保证返回容器里的同一实例。但这里有个前提你用的必须是Configuration而不是Component。Spring有一个非常容易被忽略的规则Configuration类是Full模式的代理类而Component是Lite模式的非代理类。Lite模式下类内部的Bean方法之间互相调用时会直接new新的实例不走容器。这导致的问题就是拿到两个不同的实例对于有状态的对象比如连接池、缓存管理器来说基本就是沉默地出错。我的建议很简单配置类一律用Configuration标注不要图省事变通成Component并且Bean方法之间通过参数注入依赖彻底绕开手动调用。6.3 静态Bean方法和实例Bean方法还有个细节Bean方法能不能是static的可以Spring从4.x开始支持静态Bean方法但语义和实例方法有区别。static Bean方法在配置类的实例化之前就可以注册bean定义适用于一些不需要配置类实例状态的场景。如果一个Bean方法既不依赖配置类里的其他方法也不依赖实例字段写成static反而能避免容器过早实例化配置类也别有一番好处Configuration public class PureConfig { Bean public static BeanA beanA() { return new BeanA(); } Bean public BeanB beanB(BeanA beanA) { return new BeanB(beanA); } }这里BeanA是静态创建的BeanB是普通实例方法创建的它依赖BeanA就直接通过参数传入。这种组合在构建那些彼此之间没有循环依赖的基础设施bean时很好用。6.4 构造方法上的循环依赖问题三种方式里注解扫描最容易遇到循环依赖。A依赖BB依赖A然后容器启动疯狂报BeanCurrentlyInCreationException。Spring的解决方案是三级缓存机制但这个机制只解决了“单例bean的setter注入/字段注入”的循环依赖问题对构造器注入的循环依赖是无解的。什么叫构造器注入就是用RequiredArgsConstructor或者构造方法参数注入比如Service public class A { private final B b; public A(B b) { this.b b; } }这种写法下如果B又依赖A启动直接报错Requested bean is currently in creation。解决办法要么把其中一个改成字段注入/Lazy要么重新设计类结构消除循环。这也引出一个我在面试中学员经常问的问题Spring为什么用三级缓存解决循环依赖两级不行吗答案是两级可以解决“成品bean”的循环依赖但解决不了“代理对象”的循环依赖。三级缓存加的那层ObjectFactory是为了在bean还没完全创建好的时候就能提前暴露一个工厂让依赖方先拿到一个引用占位等真正需要的时候再通过工厂获取或创建代理。这个设计很精妙但也意味着——只要你的bean还处于创建过程中另一个bean提前引用了它那这个引用就不是最终形态的bean而是早期引用里面的依赖可能还没装配完。6.5 静态工厂方法注册bean的一个补充视角和Bean类似的还有factory-method这个属性在XML里它被用来指定一个静态工厂方法创建beanbean idcalendar classjava.util.Calendar factory-methodgetInstance/它和Bean的区别是Bean定义在配置类里方法体里可以写多个步骤factory-method是直接调用指定类的静态方法。现在开发中已经很少用了但知道它的存在有助于你理解Spring“实例化bean并非只有new这一条路”这句话的含义。实际上创建bean实例有构造器实例化、静态工厂方法实例化、实例工厂方法实例化三种底层机制你日常用的三种注册方式最终都会落到这些实例化策略上。7. 结尾实操心得结合我自己的经验给你一个比较实用的“怎么选”的参考标准。如果你只是写业务代码90%的bean用Component系列注解就够了省心、直观、符合Spring Boot的预设。如果你在搞框架整合比如接一个第三方SDK要把它提供的Client类做成容器里的单例复用那就用Bean在配置类里把它new出来顺便做参数初始化。如果你不幸在维护Spring老项目XML这块知识就别指望绕过起码得会读、会改、会排查。三种方式之间有一条隐藏的主线——它们最终都变成BeanDefinition进入注册表后面容器处理bean生命周期的时候才不管你是从哪儿来的。我把这条主线作为所有源码分析的起点凡是对Spring容器行为有疑问的时候先问自己一个问题——这个bean的BeanDefinition到底在哪一步、通过哪个类注册进去的它的属性scope和lazyInit是什么值。把这个问题理清了很多“为什么这里注入为空”、“为什么同一个类有两个实例”的疑问都能自己找到答案。最后再分享一个调试技巧。遇到注入不进去或者拿到null的情况别急着在代码里加日志先在启动的时候给Spring加一个调试开关-Dspring.debugtrue或者在你的配置类上临时加个--debug参数。启动后会打印大量bean注册的详细日志能直接看到你的bean有没有被扫描到、是哪个配置类注册的、依赖解析顺序是什么。这个治标的方法在很多疑难杂症里都特别好使。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →