Spring Bean三种注册方式:XML、注解与JavaConfig全解析
刚接触Spring的时候我一度以为“把一个对象交给Spring容器管理”只有一种写法就是给类加Controller、Service之类的注解。后来做老项目维护看到一整套XML配置才意识到这条路远不止一条。再后来去读Spring源码发现不管是XML、注解还是ConfigurationBean它们最终的终点都一样生成BeanDefinition注册进容器。这篇文章就把这三条路挨个捋一遍从怎么写到为什么行从避坑到面试追问尽量一次说透。如果你是刚学Spring的读者这篇能直接当操作手册抄如果你已经写了两三年Spring Boot我敢说后半段的循环依赖、Full/Lite模式、编程式注册仍然值得你花十分钟重读一遍。1. 先弄明白什么是“交给Spring容器管理”1.1 容器到底替我们做了哪些事要理解三种写法先得理解“容器”这个概念。没有Spring的时候我们创建一个对象就是new UserService()。这个对象创建出来之后谁来维护它的依赖谁来决定它是单例还是每次新建谁来在项目关闭前执行清理逻辑全靠开发者自己在代码里操心。Spring容器做的事情简单说就是“接管对象的生命周期”。它拿到一份配置然后负责创建对象、给对象注入依赖、管理对象的存活范围、执行初始化和销毁方法。这套机制叫作控制反转创建和装配对象的控制权从程序员手里反转到了容器手里。你平时听的依赖注入就是容器在创建bean的同时把A依赖的B对象“塞”进A的属性或构造参数里。传统做法是A自己去getB控制反转之后A连B怎么来的都不用管容器会负责。为了把事情说清楚可以做个类比以前你自己开小饭馆买菜、洗菜、切菜、配菜全自己干用了容器之后你只需要把“菜谱”告诉中央厨房到点直接取做好的菜。这里的“菜谱”就是你要交给容器的bean定义。1.2 三种方式背后的同一个终点BeanDefinition很多人学三种方式时容易把它们当成三套完全不同的东西。实际上它们只是“菜谱”的三种记录形式最后的归宿都一样。Spring内部对每个bean都维护一个BeanDefinition对象。这个对象里记录着bean的类名classbean的名字id或name作用域singleton还是prototype构造参数、属性值初始化方法和销毁方法是否懒加载等元信息你可以把BeanDefinition理解成一张“配方表”BeanFactory是厨师真正炒菜靠它。而ApplicationContext是完整版的餐厅它在BeanFactory之上增加了事件发布、国际化、资源加载等能力我们平时说的“Spring容器”绝大多数场景下指的都是ApplicationContext。三种方式对应的“配方表”来源分别是方式解析入口谁来写配方XMLXmlBeanDefinitionReader解析bean标签注解扫描ClassPathBeanDefinitionScanner扫描Component等注解JavaConfigConfigurationClassPostProcessor解析Bean方法无论哪个入口最终都会生成GenericBeanDefinition然后注册进DefaultListableBeanFactory的beanDefinitionMap里。后面容器启动时统一遍历这些配方通过反射创建实例。所以你把三种方式当成“同一条流水线的三个上料口”整个Spring的源码就好读很多。2. 方式一XML配置最正统但最容易被忽略的老路子2.1 最基础的XML声明与几种写法XML方式是Spring 1.0时代就有的写法SSHSpring Struts Hibernate年代的项目全是这么干的。现在写新项目的人很少用了但维护老系统、读历史代码、看很多框架的历史版本时你一定会碰到。最简单的声明beans xmlnshttp://www.springframework.org/schema/beans xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd bean iduserDao classcom.example.dao.UserDao/ bean iduserService classcom.example.service.UserService property nameuserDao refuserDao/ /bean /beans这里的id是bean在容器里的名字class必须是全限定类名。property的意思是通过setter方法注入Spring会去找UserService里的setUserDao(UserDao)方法去赋值。注意它找的是setter不是直接操作私有字段所以name属性要跟JavaBean规范里的属性名对应。如果你的类没有无参构造器或者想通过构造器传入参数可以这么写bean iduserService classcom.example.service.UserService constructor-arg index0 refuserDao/ constructor-arg index1 value8080/ /bean这表示调用UserService(UserDao, int)这个构造器index0对应第一个参数index1对应第二个参数。值类型用value引用其他bean用ref。还有一种写法是通过静态工厂方法创建beanbean idcalendar classjava.util.Calendar factory-methodgetInstance/Calendar的构造器是私有的没法直接new但Spring会调用它的静态方法getInstance()来获得实例。这种写法在对接一些老工具类时很有用。加载XML的入口也很复古ApplicationContext context new ClassPathXmlApplicationContext(applicationContext.xml); UserService userService context.getBean(userService, UserService.class);如果你的XML是放在文件系统里还可以用FileSystemXmlApplicationContext。2.2 XML是怎么变成BeanDefinition的XML方式之所以让我觉得值得学不光是历史原因而是它把Spring“配置到定义再到实例”的过程展示得最直白。ClassPathXmlApplicationContext启动时内部会创建XmlBeanDefinitionReader。这个Reader拿到XML文档后逐个读取bean标签每个标签解析出一个GenericBeanDefinition然后把它们注册进DefaultListableBeanFactory里的beanDefinitionMap。这个过程发生在AbstractApplicationContext.refresh()方法里的obtainFreshBeanFactory()阶段。重点在于这个阶段只读配置、生成配方不会立刻创建bean对象。真正的创建发生在后面的finishBeanFactoryInitialization()阶段容器才会调用getBean()去实例化所有非懒加载的单例bean。这也是为什么很多网上文章说“Spring启动时加载顺序是先解析BeanDefinition再实例化Bean”。你如果去打断点能看到beanDefinitionMap里已经躺满了一个个配方但堆里的对象还没几个。2.3 写XML时容易踩的坑XML的坑我踩过不少挑几个典型的说。第一个坑写了有参构造器却忘了无参构造器。Spring默认调用无参构造器反射创建bean如果类里定义了有参构造器而没写无参构造器容器启动时直接给你抛BeanInstantiationException。第二个坑property name写成了字段名。property nameuserDao对应的是setUserDao方法不是类里的userDao字段。如果你的setter叫setDao(UserDao dao)那name就得写dao。第三个坑scopeprototype时容器不会帮你执行destroy方法。Spring对单例bean负责到底项目关闭时会调用destroy-method但对prototype类型的bean容器只管发对象发完就不管了销毁逻辑得自己处理。第四个坑XML声明单例bean时启动就会创建。如果你某个bean的class写错了或者依赖的ref不存在很多时候不是等到getBean()才报错而是容器启动阶段就直接崩。排查这种启动失败优先看堆栈里是哪个bean导致的。第五个坑XML文件编码。老项目里经常出现中文乱码然后排查半天发现是XML文件保存成了GBK而Spring按UTF-8解析。这个问题现在不多见了但遇到乱码可以第一时间检查这个。3. 方式二注解组件扫描日常开发绝对主力3.1 Component家族注解与扫描原理现在写Spring Boot项目绝大多数bean都是靠注解扫描进容器的。你只需要给类加Component、Service、Repository、Controller其中任意一个再把类放到启动类所在的包路径下面容器就会自动把它管理起来。这四个注解在功能上没有任何区别Service、Repository、Controller底层都包含了Component它们的存在只是为了让代码的语义更清晰Service层用Service数据访问层用RepositoryWeb层用Controller。Spring在扫描时认的是Component以及能被元注解追溯到Component的注解所以这四个都能被扫到。扫描的核心组件叫ClassPathBeanDefinitionScanner。它做的事情不是把你的类加载到JVM里然后用反射判断而是直接用ASM字节码工具读取class文件里的元数据看看类上面有没有对应注解。这么做的好处是启动阶段不加载类性能更好代价是你如果只在方法上加了Bean类上没有Component的话它是不会扫描这个类的。启动的时候AnnotationConfigApplicationContext或者在Spring Boot里SpringBootApplication自带一个ComponentScan默认扫描启动类所在的包以及所有子包。所以很多新手遇到的“为什么bean扫不到”十有八九是启动类的包路径和业务类不在同一个层级下。3.2 为什么你的bean总是扫描不到我整理了一份排查清单基本覆盖了日常能遇到的“扫不到”问题现象常见原因解决办法启动类不在根包SpringBootApplication默认扫当前包及子包把启动类放到包的根位置类上没加注解只写了类没有加Component系列注解补上对应注解加了Bean但类没被扫描方法上有Bean但类不是Configuration且没有Component确保配置类能被扫描或使用Import使用IDE开发时扫不到新增的类没编译target里没有class文件执行mvn clean compile或重启IDE类是非静态内部类非静态内部类需要外部类实例Spring无法直接创建改成静态内部类或独立Top-Level类类是接口或抽象类Spring默认不实例化接口和抽象类提供具体实现类还有一个比较隐蔽的问题MapperScan、ComponentScan这些注解混用的时候同一个包可能会被扫描两遍。如果两遍都扫到了同一个类就可能出现bean名字冲突或者重复注册的问题。3.3 注解与XML混用要注意的地方老项目里经常能见到这种组合核心配置还是XML但又加了context:component-scan base-packagecom.example/然后在业务类上写Service、Autowired。这个组合本身没问题Spring允许两种方式共存于同一个容器。但混用时要注意两个点第一XML里的bean id和注解扫描出来的bean名可能冲突。Component默认的bean名是类名首字母小写比如UserServiceImpl变成userServiceImpl。如果XML里也定义了同名的bean在Spring Boot 2.1之后会直接报覆盖异常而不是静默覆盖。第二扫描范围要控制精准。context:component-scan会把整个包都过一遍如果包里有些类是专门给特殊场景准备的比如RMI调度的适配器、测试桩很容易被误扫进生产容器。建议在XML里用resource-pattern或exclude-filter把范围卡死。我个人强烈建议既然已经用了Spring Boot新项目就不要再引入XML。实在要保留用ImportResource把XML指定的那部分bean汇入容器就够了不要整个项目两套体系并列跑。4. 方式三Configuration BeanJavaConfig的现代玩法4.1 一个完整的Configuration示例现在的主流写法里Configuration Bean是专门用来解决“需要复杂初始化逻辑的bean”的。尤其是对接第三方类比如数据源、Redis连接工厂、消息队列客户端这些类的构造过程要么很复杂要么需要根据配置项动态生成这时直接用Component根本写不了只能在Bean方法里自己new。看一个实际例子Configuration public class DataSourceConfig { Bean public DataSource dataSource() { HikariDataSource ds new HikariDataSource(); ds.setJdbcUrl(jdbc:mysql://localhost:3306/test); ds.setUsername(root); ds.setPassword(123456); ds.setMaximumPoolSize(20); return ds; } Bean public JdbcTemplate jdbcTemplate(DataSource dataSource) { return new JdbcTemplate(dataSource); } }这个类里没有任何Component但它放在了启动类的包路径下会被扫描到并作为配置类处理。dataSource()方法上标了BeanSpring就会把方法的返回值当成一个bean注册进容器默认bean名就是方法名dataSource。jdbcTemplate方法的参数DataSource dataSource是一个很典型的依赖注入写法。Spring看到这个参数后会自动从容器里找出DataSource类型的bean传进来不需要你自己调用getBean()。这种“方法参数自动注入”的方式在Bean里比Autowired字段注入更直观推荐优先用。Bean注解还支持initMethod和destroyMethod用来指定初始化回调和销毁回调Bean(initMethod init, destroyMethod close) public SomeClient someClient() { return new SomeClient(); }这里有个容易忽略的细节Spring Boot 2.x之后Bean的destroyMethod默认值是INFER_METHOD意思是Spring会自动推断名为close或shutdown的方法作为销毁方法。如果你引用的第三方类恰好有个close()方法但你不希望容器关闭时调用就要显式写成destroyMethod否则那种不明显的资源释放问题能折腾你好久。4.2 Full模式和Lite模式为什么同一个Bean方法调用多次还是同一个对象这是面试里真正能拉开差距的知识点也是很多人写复杂配置类时最容易翻车的地方。先看代码Configuration public class AppConfig { Bean public A a() { return new A(); } Bean public B b() { return new B(a()); // 这里调用了a()方法 } }直觉上b()方法里调用a()每次都会执行一次new A()那岂不是容器里有两个A实例但实际上如果AppConfig是加了Configuration的类容器会生成一个CGLIB代理的子类在调用b()之前代理会先拦截对b()的调用进入容器逻辑在b()里调用a()时代理同样会拦截然后从容器里拿已经存在的A实例而不是真正执行new A()。所以最终结果是容器里只有一个A实例所有引用都指向它。这就是Spring文档里说的“Full模式”Configuration类默认是Full模式会被CGLIB代理Bean方法内部互相调用保证单例语义。如果配置类不加Configuration只加Component或者在配置类里的Bean方法上用普通方法调用另一个Bean方法就走Lite模式没有代理拦截方法调用一次就new一次容器里可能有多个实例。为什么Lite模式存在因为CGLIB代理不是没有代价的。它启动更慢、内存开销更高而且对非static的Bean方法内部调用有限制。如果配置类里的Bean方法彼此之间不互相调用你可以在Configuration(proxyBeanMethods false)关掉代理告诉Spring“我这个配置类不需要方法拦截”启动速度能快一些。Spring Boot的自动配置类大量使用了proxyBeanMethods false就是这个原因。你在别人的项目里如果看到Bean方法之间用new互相创建或者配置类上写的是Component而不是Configuration就要警惕Lite模式下实例被重复创建的问题。4.3 编程式注册从Import到FactoryBean再到BeanDefinitionRegistry除了上面三种“声明式”的方式还有一类偏“编程式”的注册手段。严格说它不是第四种独立方式更多是Configuration Bean的延伸但面试时经常被拎出来单问。先说Import。它能直接导入一个配置类Configuration Import({DbConfig.class, CacheConfig.class}) public class AppConfig { }Import还能配合ImportSelector和ImportBeanDefinitionRegistrar做到“根据条件一次性注册一批bean”。Spring Boot的自动配置本质就是若干个Configuration类通过Import 条件注解批量加载出来的。理解了Import你就理解了Spring Boot“为什么引入一个starter依赖就能自动得到一堆bean”。再说FactoryBean。这个接口名字很直白工厂Bean。它是一个专门生产bean的beanSpring会先创建FactoryBean实例再调用它的getObject()方法得到真正想要的对象。调用方式上有个经典细节getBean(xxx)拿到的是工厂生产的产品getBean(xxx)拿到的才是工厂本身。MyBatis的MapperFactoryBean、各种动态代理的创建都用了这个套路。如果你想完全自己控制注册过程还可以实现BeanDefinitionRegistryPostProcessor在容器启动早期直接调用registerBeanDefinition()手动注册Component public class MyBeanRegistrar implements BeanDefinitionRegistryPostProcessor { Override public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) { GenericBeanDefinition bd new GenericBeanDefinition(); bd.setBeanClassName(com.example.SomeService); bd.setScope(BeanDefinition.SCOPE_SINGLETON); registry.registerBeanDefinition(someService, bd); } }这种写法在框架开发里很常见普通业务代码基本用不到但知道它的存在会让你看Spring源码时豁然开朗。5. 三种方式的核心差异与选型建议5.1 一张表看透三种方式从前面的分析能看出来三种方式不是互相替代的“新旧关系”而是各有擅长领域。我整理了一份对照表对比维度XML配置注解扫描Configuration Bean声明位置独立的XML文件类上的注解Java方法上的注解适合对象老项目、需要外部可调的bean自己写的业务类第三方类、复杂初始化逻辑第三方类支持一般需要factory-method等差无法直接加注解好可以在方法里new条件化配置弱弱强可配合Spring Boot条件注解可维护性配置集中但容易臃肿分散在各业务类直观集中且灵活常用入口ClassPathXmlApplicationContextAnnotationConfigApplicationContext / SpringBootApplication同样基于注解扫描这个表能帮你回答一个高频面试题“在Spring里创建一个bean有哪些方式”标准答案其实就是XML声明、注解扫描、Configuration Bean可以再补一句“本质上都是生成BeanDefinition注册进容器”。5.2 不同场景怎么选具体到实际项目我的选择逻辑是这样的新项目、业务代码全部用注解扫描。自己的Service、Controller、Repository直接用Service、RestController这些组件注解一行配置都不用写语义也清晰。第三方类、配置类用Configuration Bean。比如数据源、KafkaTemplate、RedisTemplate这些类的初始化过程不应该散落在业务代码里放进配置类统一管理最干净。老项目维护XML必须能看懂。不是说让你继续写XML而是遇到老代码时能读得懂、改得动。如果老项目里既有XML又有注解动之前先理清重复定义的风险。中间件/Starter开发Import 条件注解 编程式注册是主力。你写一个自己的starter给别人用不可能要求使用方去加ComponentScan而是要用自动配置机制把bean按需注册进去。单元测试里的临时容器可以直接new AnnotationConfigApplicationContext(MyConfig.class)或者new AnnotationConfigApplicationContext()后手动注册一个bean这种方式轻量快捷。这些方式在同一个容器里可以共存。Spring Boot项目表面上全是注解但底层可能有ImportResource引入的XML bean又有自动配置类注册的bean还有你自己Configuration类里的Bean。它们最后都在同一个beanDefinitionMap里排队实例化。6. 面试和实战中最容易翻车的几个问题6.1 bean重名与覆盖规则三种方式都往同一个容器注册bean那名字撞了怎么办先明确一点Spring容器的beanDefinitionMap是一个Map同一个key只能有一个值。后注册的BeanDefinition理论上会覆盖先注册的。但在Spring Boot 2.1之后默认行为改成了禁止覆盖。如果两个不同配置源注册了同名bean启动时直接报错Invalid bean definition with name userService defined in ...这时候正确的处理方式不是去application.yml里打开spring.main.allow-bean-definition-overridingtrue而是找到冲突的源头改掉重复的bean名或者消除重复扫描。把覆盖开关打开只是掩盖问题后患无穷。6.2 循环依赖与三级缓存循环依赖可以说是Spring面试里最经典的问题它和bean注册方式有直接关系。假设有A和B两个beanA依赖BB也依赖AComponent public class A { Autowired private B b; } Component public class B { Autowired private A a; }如果是属性注入Spring能解决。它的流程是这样的创建A调用构造器得到一个A的半成品对象还没有注入属性。把这个半成品A封装成一个ObjectFactory放进三级缓存singletonFactories。给A注入属性发现需要B于是去创建B。创建B实例化B发现需要A。从三级缓存里拿到A的早期引用把A暴露给B的注入点。B完成注入和初始化放入一级缓存singletonObjects。A继续完成注入最后也放入一级缓存。三个缓存分别叫singletonObjects一级缓存完整对象、earlySingletonObjects二级缓存早期暴露对象、singletonFactories三级缓存对象工厂。很多人会问为什么有二级缓存了还要三级缓存因为早期暴露的A对象不一定是最终放进容器的那个。如果A需要AOP代理那么三级缓存里的ObjectFactory会在被调用时才去执行getEarlyBeanReference()生成代理对象这样B拿到的就是代理后的A而不是代理前的裸对象。如果直接存二级缓存代理逻辑就没办法按需触发了。为什么构造器注入无法解决循环依赖因为构造器是在对象创建的第一步执行的你的A还没有生成完连三级缓存都来不及放B根本拿不到半成品A。构造器注入的循环依赖只能在设计层面拆掉。还有一个容易踩的版本坑Spring Boot 2.6起默认allow-circular-referencesfalse也就是说即使属性注入也会在启动时直接报错提示你不要写循环依赖。这时候正确的做法是重新设计依赖关系而不是去改配置允许它。6.3 Transactional/Async自调用失效这个问题和bean的注册方式关系不大但它出现的频率太高了值得放在这里一起说。容器向外暴露的bean对象实际上可能是代理对象。以Transactional为例Spring会用AOP生成一个代理类包裹你的Service代理类里会在调用目标方法前后开启和提交事务。但如果你在同一个类内部写这样的代码Service public class OrderService { Transactional public void doOrder() { // 真正的事务逻辑 } public void pay() { this.doOrder(); // 这里走的是this不走代理 } }pay()里的this.doOrder()调用的是当前对象的原始方法根本没有经过代理事务自然不生效。解决办法有三个把doOrder()拆到另一个Service类里让pay()注入那个Service再调用在类里注入自己Autowired private OrderService self;然后self.doOrder()开启EnableAspectJAutoProxy(exposeProxy true)用((OrderService) AopContext.currentProxy()).doOrder()。三个方案里我通常优先选第一个因为它最简单也最符合单一职责。Async同理自调用照样不生效。6.4 启动流程里的后处理器顺序BeanFactoryPostProcessor vs BeanPostProcessor这两个名字长得像作用却差得很远。BeanFactoryPostProcessor处理的是BeanDefinition。它在bean实例化之前执行此时容器里只有一堆“配方”还没有实际对象。比如ConfigurationClassPostProcessor就是个BeanDefinitionRegistryPostProcessor专门解析Configuration、Bean、Import这类注解。如果它执行得不够早你的Bean方法根本来不及变成BeanDefinition。BeanPostProcessor处理的是已经创建出来的bean实例。它在bean实例化之后、初始化前后执行可以对bean进行“加工”比如注入属性、生成代理。AOP相关的AnnotationAwareAspectJAutoProxyCreator就是典型的BeanPostProcessor。在AbstractApplicationContext.refresh()流程里顺序是调用BeanFactoryPostProcessor处理BeanDefinition注册BeanPostProcessor为后续实例化做准备finishBeanFactoryInitialization()里才真正创建单例bean此时BeanPostProcessor才开始发挥作用。把这个顺序记牢你就能看懂很多“为什么这个注解不生效”的问题。比如配置类的扫描逻辑必须跑在业务bean实例化之前不然业务bean都创建完了Bean方法才被解析那配置就失去了意义。我个人在实际操作中的体会是三种注册方式看着是选择题其实是一道原理题。刚入门时只记注解也能干活但一旦你把“XML、注解扫描、Configuration Bean”理解成同一条流水线的三个上料口再去读源码、排查启动问题视角完全不一样。最后分享一个排障小技巧。遇到“bean找不到”或者“属性没注入”这类问题先别急着一行行看配置在启动类里临时写一个ApplicationRunner把context.getBeanDefinitionNames()全部打出来或者直接debug看一眼DefaultListableBeanFactory里的beanDefinitionMap。哪个bean没注册、哪个bean被覆盖、哪个名字和你预期不一样一目了然。这个方法我用了不下一百次基本每次都能快速定位比盲猜配置高效得多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →