ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Bean注册三种方式:XML、注解与JavaConfig全解析

Spring Bean注册三种方式:XML、注解与JavaConfig全解析 面试的时候有个出现频率特别高的基础题Spring中如何把一个bean对象交给Spring容器管理。说实话这道题算得上“送分题”但能清清楚楚答到位的人不多很多人张口就是“加个Component”然后就没有然后了。更麻烦的是不少人把“bean的注册方式”和“依赖注入的方式”搅在一起面试官一问“还有呢”就卡住了。这篇文章我把三种方式从头到尾捋一遍XML配置、注解声明、JavaConfig的Bean外加每种方式背后的设计逻辑和实际工程里会遇到的坑。不管你是刚开始学Spring还是被循环依赖折磨过几次的老手读完应该都能把这条线理清楚。1. 为什么要把bean交给Spring容器管理1.1 控制反转解决的是什么问题先回到最原始的写法。假设你要写一个订单服务它依赖库存服务和支付服务。没有Spring的时候代码大概是这样的public class OrderService { private InventoryService inventoryService new InventoryService(); private PayService payService new PayService(); }看起来没啥问题但项目一旦大起来这种写法就是灾难的起点。库存服务内部可能又依赖数据库连接池、消息队列客户端、缓存客户端你得一层一层手动new任何一个依赖变了所有依赖它的地方都要跟着改。更难办的是写单元测试的时候你根本没法替换这些内部实现因为对象已经在你代码里写死了。Spring容器要解决的就是这个事它把对象的创建和装配过程从你的业务代码里抽走统一交给容器来做。你只需要告诉容器“我要一个订单服务它需要库存服务和支付服务”容器就会自动把这三者组装好大大降低耦合度让服务可以互相独立替换和测试。这个概念就是控制反转IoC的核心思路对象不再由自己控制依赖而是把控制权交给容器反向获取所需依赖所以叫反转。而所谓的依赖注入DI就是容器在创建bean时把依赖“塞”进对象里这正是IoC的一种具体实现方式。有人问那“把bean交给Spring容器管理”到底是什么意思说穿了就是告诉容器某个类需要由它来创建和组装而不是在应用代码里自己new。而“告诉”的方式就是本文要说的三种注册方式。1.2 bean在容器里的生命周期和默认行为一旦一个类被注册成bean容器会按照一套固定的流程来处理它实例化对象填充属性执行初始化回调放入缓存应用关闭时执行销毁回调。整个过程开发者不用操心容器帮你兜底。这里有几个默认行为值得记住。默认情况下容器中的bean都是单例也就是说同一个bean定义只会创建一次实例后续注入的都是同一个对象。大多数场景下这样做既节省内存又保证状态一致。另外bean的创建时机也很有意思。默认的单例bean在容器启动时就“迫不及待”地创建了不是等到被使用的时候才创建。这解释了很多新手遇到的怪现象项目一启动日志里全是bean的构造信息明明还没有任何接口请求进来。再补充一个常被问到的点容器的名字规则。如果是XML方式注册的bean我们可以给它指定id如果用注解或JavaConfig注册默认的bean名通常是类名的首字母小写形式比如UserService这个类默认就叫userService。bean实例化的底层策略大致有构造器实例化、静态工厂方法和实例工厂方法这三种普通开发时你接触最多的是构造器实例化剩下的两种在特殊扩展场景才会用到。2. 方式一XML配置声明bean——最老牌也最能看清原理2.1 最小可用的XML bean配置长什么样现在纯用XML的Spring项目确实不多了但老系统里存量很大而且理解了XML方式的底层逻辑后面理解注解方式就是一通百通。先看一段最经典的XML配置?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 iduserDao classcom.example.demo.dao.UserDao/ bean iduserService classcom.example.demo.service.UserService property nameuserDao refuserDao/ /bean /beans第一行的beans标签就是容器配置文件的根节点里面每一个bean标签就是一条注册声明。id是对象的唯一标识class是类全路径Spring在启动时通过反射加载这个类再按配置给它装配依赖。property标签是设置属性用的name对应UserService里的userDao字段名ref表示引用另一个bean。这里要注意Spring做的是先通过无参构造器创建对象然后调用setter方法把依赖注入进去。如果你的类没有无参构造器又用property方式传入依赖启动时会直接报错。还有一种注入写法叫constructor-arg走的是构造器参数注入bean iduserService classcom.example.demo.service.UserService constructor-arg refuserDao/ constructor-arg valueorderService/ /beanvalue用于注入基本类型和字符串ref用于注入bean引用这个区别要记住。除了注入XML里还能写初始化方法和销毁方法比如在bean标签上配置init-method和destroy-method指向类中的实例方法容器会在完成属性注入后调用init方法在容器关闭前调用destroy方法。用起来很简单但看源码的时候会发现Spring在背后做了一大堆判断和回调。应用的启动代码也很直观在Spring 5.x时代用ClassPathXmlApplicationContext加载配置ApplicationContext context new ClassPathXmlApplicationContext(applicationContext.xml); UserService userService context.getBean(userService, UserService.class);这里可以清晰地看到容器扮演的角色——你根本没有new过UserService它已经作为bean躺在容器缓存里了你只是把它取出来而已。2.2 XML方式适用的场景与注意点XML方式看起来麻烦但有它不可替代的价值。第一类是纯第三方类比如你引入了一个开源SDK它的类本身没有加任何Spring注解你又没办法改它的源码这时候用XML的bean标签注册最直接。第二类是历史遗留的老项目整套系统都在用XML管理依赖你上去就推注解迁移成本高风险大不划算。XML配置也有几个明显缺点新手要提前有心理准备。第一个是类型不安全配置里全是字符串id写错了、class类路径写错了只能在容器启动报错的时候才能发现。第二个是配置文件的体量会越来越大一个大型项目里动辄几千行XML想找一个bean定义就得翻半天。多环境配置方面XML有一个挺方便的能力——profile。比如开发环境和生产环境要用不同的数据源可以在XML里这样写beans profiledev bean iddataSource class.../ /beans beans profileprod bean iddataSource class.../ /beans启动时通过spring.profiles.active指定当前激活的环境。这个能力在注解时代也有对应方案但XML方式确实算最早被广泛使用的多环境处理手法。如果是在新项目里我个人建议不要主动选择XML来注册bean除非有明确的不可改源码的场景。它适合理解原理不适合拿来当主力开发方式。3. 方式二注解方式——如今最主流的选择3.1 组件标注三步走注解方式能成为现在Spring Boot项目的默认标配核心原因就是方便。它把注册信息写在类本身上编译器能帮你检查重构类名的时候注解不用单独改比XML那种“类定义和配置分离”的方式省心得多。注解方式的第一步是添加组件扫描的开关。在Spring Boot项目里你的启动类上通常有SpringBootApplication这个注解组合了ComponentScan会自动扫描启动类所在包及其子包下所有标注了注解的类。如果是传统的Spring项目需要在XML里加上context:component-scan base-packagecom.example.demo/这一步必须要有否则写了注解也等于白写容器根本看不到你的类。第二步就是给类打上“交给容器”的标记。Spring提供了三个从属关系的注解Component是通用组件所有类都可以用它Service用于业务层Repository用于数据访问层Controller用于Web控制层。后面三个注解在功能上都是Component的扩展但按照代码规范业务模块的类应该用语义最匹配的注解让代码读到注解就知道它在哪一层。第三步就是正常写类了Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao userDao; } }类上加Service容器扫描到这个类之后就会自动注册成beanbean默认名是类名首字母小写userService。这就算把bean交给容器了。3.2 依赖注入怎么配注册完之后类与类之间的依赖关系也要靠容器来维护。Spring里最常用的注入注解是Autowired。它可以加在三个位置字段上、setter方法上、构造器上。字段注入的写法最简洁Service public class UserService { Autowired private UserDao userDao; }但这种方式有一个问题字段是private的单元测试时想手动替换UserDao很麻烦而且依赖关系被隐藏了不够直观。构造器注入现在是社区推荐的写法好处是依赖以参数形式明确暴露出来类一旦创建所有依赖就已经就位不容易出现半初始化的状态。字段注入和setter注入还有一个隐患就是对象在构造完成后依赖才被塞进去如果构造器里就用到依赖就会拿到null。构造器注入天然规避了这个风险。所以Spring团队的官方文档也很明确地推荐构造器注入。当同一个类型在容器里有多个bean时Autowired会迷茫。比如你有两个实现类都实现了UserDao接口Component public class JdbcUserDao implements UserDao {} Component public class RedisUserDao implements UserDao {}这时候注入UserDao会报NoUniqueBeanDefinitionException。解决方式是在注入点用Qualifier指明bean名字Service public class UserService { private final UserDao userDao; public UserService(Qualifier(redisUserDao) UserDao userDao) { this.userDao userDao; } }也可以用JSR-250标准注解Resource来代替Autowired它的查找逻辑是先按字段名找同名bean找不到再按类型找。这个细节在面试里经常被拿来考察。3.3 注解方式容易踩的三个坑第一个坑是扫描包路径不对。组件扫描的base-package写得过大应用启动会变慢写得太小注解类扫不到直接报找不到bean。遇到过最典型的情况是把启动类放在com.example.demo包下业务代码放在com.example.business包下结果启动时各种NoSuchBeanDefinitionException排查了半天才发现是包没扫到。第二个坑是依赖注入到了非Spring管理的对象里。比如在工具类或者通过new创建的对象里用Autowired容器根本不会管你因为Spring管理的对象范围只限于容器创建的bean你自己new出来的对象如果用了Autowired字段就是null。这种情况应该配合ApplicationContext在容器里查找需要的bean或者把对象也注册成bean。第三个坑和配置有关。如果你在XML里配置了注解扫描又同时在类上加Component会出现重复的情况吗表面上不会报错因为Spring处理bean定义的合并逻辑比较复杂默认情况下后加载的定义会覆盖先加载的但如果你开启allowBeanDefinitionOverridingfalse就会触发冲突异常。老项目和Spring Boot基础配置混在一起时特别容易遇到。4. 方式三JavaConfig方式——显式声明和类型安全的平衡点4.1 一个典型的Configuration加Bean配置类第三种方式用纯Java代码完成bean注册没XML也不靠扫描而是专门写一个配置类在里面用方法声明bean。先看代码Configuration public class AppConfig { Bean public UserDao userDao() { return new UserDao(); } Bean public UserService userService() { UserService service new UserService(); service.setUserDao(userDao()); return service; } }一个Java类标上Configuration它就成了配置类容器扫描到或者显式加载它的时候会把类中所有带Bean的方法都找出来每个方法的返回值就是一个beanbean名称默认是方法名。这段代码传递了两个信息。第一Bean方法本质上就是一个对象工厂你想让容器管理什么对象就在方法里把它创建出来。好处是对象创建逻辑可以很复杂比如模拟一个带参数的对象、从配置文件中读值再拼接对象这些用注解在类级别上很难完整表达但在Bean方法里就是普通Java代码。第二方法里调用另一个Bean方法是有讲究的。注意userService()方法里调用了userDao()方法看起来像是new了两个UserDao但因为AppConfig被Configuration修饰后Spring会通过CGLIB代理增强这个类保证同一个配置类中多次调用同一个Bean方法返回的都是同一个bean实例。这就是很多源码解析里说的full模式代理机制。这也是为什么Configuration类不能被声明为finalfinal类无法被CGlib子类化代理会失效bean的单例性就会出问题。加载这个配置类的方式也很灵活。传统Spring里用AnnotationConfigApplicationContextApplicationContext context new AnnotationConfigApplicationContext(AppConfig.class); UserService userService context.getBean(userService, UserService.class);Spring Boot里只要这个配置类在启动类的扫描路径下它会被自动发现。4.2 JavaConfig到底比另外两种方式强在哪JavaConfig的核心优势是类型安全。XML方式里class属性和ref指向全靠字符串写错了编译期不会提醒JavaConfig方法返回值是真实类型IDE能立刻帮你检查重构类名时配置会跟着变这是极大的维护优势。相比注解方式JavaConfig又显得更“显式”看到Bean方法就明确知道容器里注册了什么不像组件扫描那样把类翻一遍。JavaConfig还有一个注解方式很难替代的场景注册第三方类。一个外部jar包里的类你不能给它加Component但你可以在这个配置类里这样写Bean public RestTemplate restTemplate() { return new RestTemplate(); }对于这类“不是自己的类”Bean是首选方案。很多Spring Boot的自动配置类内部就是大量使用Bean方法把默认组件注册进容器的比如DataSourceAutoConfiguration里的数据源定义。复杂对象也可以通过方法参数完成装配Bean public DataSource dataSource() { return DataSourceBuilder.create().build(); } Bean public JdbcTemplate jdbcTemplate(DataSource dataSource) { return new JdbcTemplate(dataSource); }jdbcTemplate()的参数DataSource会被Spring自动注入你不需要在方法里手动调用dataSource()容器会自动传递。这种方式比在方法内手动调用其他Bean方法更优雅适合依赖链比较长的场景。JavaConfig还有配套的条件注解比如ConditionalOnProperty和ConditionalOnClass让某个bean在满足特定条件时才注册。这已经是Spring Boot自动配置的地基了固定写法在大型项目里见得很多。4.3 三种方式混用怎么办实际项目里很少有一口咬定只用一种方式的情况更多时候是三种方式并存。老项目XML里有个bean新模块用注解还写了不少JavaConfig这就需要知道它们如何共处。XML配置和JavaConfig的桥接有一个专门的注解ImportResource。在配置类上声明它就能把XML引入Configuration ImportResource(classpath:legacy-context.xml) public class AppConfig { }JavaConfig之间也可以用Import引入其他配置类。这种方式适合把一个大的配置类按业务模块拆成多个小配置类在主配置类里统一汇总。在使用上需要明确的是不同方式注册进来的bean在容器里地位是平等的。它们的名字规则略有不同——XML用id注解用类名首字母小写JavaConfig用方法名——但只要是同一个名字最终在容器里合并的就是同一个bean定义。这里就埋了一个隐患同名bean到底谁覆盖谁我建议你先把这条规则记在心里到了第六节我详细演示。5. 三种方式对比与选型建议5.1 一张表看懂三种方式的差异对比维度XML方式注解方式JavaConfig方式注册位置独立XML文件类上的Component及衍生注解Configuration里的Bean方法bean名称来源显式id类名首字母小写方法名类型安全性差全字符串中等类本身是类型强返回值是真实类型第三方类注册支持不支持无法改源码支持最佳方案复杂对象创建写起来很笨拙只能在类内写构造逻辑在Bean方法里写任意逻辑配置可读性信息分离容易迷失集中但依赖隐式集中且显式条件化配置支持profile依赖其他注解配合支持Conditional系列当前主流程度低老项目为主高业务组件最常用高配置类和自动配置主选从表格里可以看到三种方式并不是淘汰更替的简单关系。XML是“能不用就不用但有存量”注解是“日常开发的主力”JavaConfig是“搞配置和集成时的武器”它们的适用场景有交集但侧重点完全不同。5.2 实际工程里怎么选才不纠结结合我这些年的经验选型逻辑可以简化成一句话能用JavaConfig表达的对象用JavaConfig自己的业务类优先用注解遗留XML不主动迁移只做桥接。具体展开说。新项目从零起步业务代码直接用Component和Service配合构造器注入风格统一、代码简洁。数据库连接池、消息队列、各种客户端SDK这类“外部件”统一放进一个配置类用Bean管理这样别人看到配置类就知道所有基础设施都在这里。老项目如果XML运行得好好的不用为了“跟上时代”去大规模改写迁移成本高且容易出回归问题用ImportResource桥接就够了。而当项目开始引入Spring Boot自动配置时你大量接触到的其实都是JavaConfig的写法比如各种EnableXXX注解背后本质上都是Import一个配置类。还有一种思维方式可以帮你判断你在写一个“服务”还是一个“工厂”写业务服务的时候关注的是这个类做什么用Service自然合适写配置的时候关注的是“创建什么对象、怎么创建”这正是Bean的语境。6. 常见问题排查与避坑实录6.1 同名bean冲突到底谁覆盖谁这是实际项目里遇到频率最高的容器异常之一。触发场景很典型你手写了一个Bean方法叫dataSource把一整套数据源配置好结果启动时发现容器里跑的却不是你的配置甚至报ConflictingBeanDefinitionException。Spring的默认覆盖规则在不同版本有差异。Spring Boot 2.1开始默认允许同名的bean定义覆盖但把allow-bean-definition-overriding设为true时会有日志警告Spring Boot 2.1之前默认是允许覆盖的。在新版Spring Boot里覆盖行为默认被禁止同名定义直接抛异常。规则归纳下来是后注册的bean定义会覆盖先注册的同名定义。启动时Spring会按照配置加载顺序解析bean定义后面的覆盖前面。所以如果你的注解类扫描和JavaConfig配置都注册了同一个名字结果取决于谁先谁后。排查思路很简单先在启动日志里找“Overriding bean definition”或“ConflictingBeanDefinitionException”相关关键字找到后把冲突的类名找出来确认是不是扫描路径重叠把同一个类注册了两遍或者是自己的配置类和某个自动配置类撞了名。实践中最稳的解法是换bean方法名避开自动配置的默认名或者调整扫描范围。这里建议不要贪图覆盖自动配置对象尤其是数据源这类核心组件容易埋下大坑。6.2 构造器循环依赖和setter循环依赖结局完全不同循环依赖指的是两个及以上的bean互相依赖形成闭环。具体点说就是A依赖BB依赖A。Service public class A { private final B b; public A(B b) { this.b b; } } Service public class B { private final A a; public B(A a) { this.a a; } }这种情况下容器启动时会发生什么构造器注入的方式下容器创建A时需要B创建B时需要A两个人互相等对方先出生最后直接抛出BeanCurrentlyInCreationException。这就是构造器循环依赖必挂的原因。而setter或字段注入的情况下容器可以先把A的半成品对象放到缓存里再创建BB里注入A的引用时发现缓存里有了B完成创建之后再回到A把B注入进去整个链路走完。Spring解决单例setter循环依赖靠的是容器内部的三级缓存结构一级缓存保存完整对象二级保存早期半成品三级保存对象工厂。A刚构造到一半放进第三级缓存B创建时从三级缓存拿引用返回给BB完成后A再接着收尾。这套机制是面试拔高题经常问的现在你至少应该知道它解决的是哪类循环依赖。但我要强调一句能解决不代表应该依赖。循环依赖本身就是设计上的瑕疵长期维护期会越缠越紧。遇到这种报错优先考虑重构把A依赖B的部分抽成方法回调或者中间加一层抽象彻底拆开循环。6.3 Bean方法写成static导致bean行为异常有次我在代码评审里看到有人写了这样一个配置方法Configuration public class AppConfig { Bean public static DataSource dataSource() { return DataSourceBuilder.create().build(); } }static关键字看起来无关紧要但对Configuration下的Bean方法来说它会让Spring的CGLIB代理完全失效。原因在于CGLIB是通过生成配置类的子类来拦截方法调用的而静态方法是类级别的方法子类无法覆盖它也就没法拦截。一旦方法里依赖另一个Bean方法的返回值比如Bean public DataSource dataSource() { return new DataSource(...); } Bean public JdbcTemplate jdbcTemplate() { return new JdbcTemplate(dataSource()); // 这里拿到的可能不是同一个dataSource }静态方法调用dataSource()时绕过代理会直接执行原始方法逻辑导致每个bean可能拿到独立的实例单例约束就破了。什么情况下static是合理的如果是自己写工具类不依赖其他bean而且明确想跳过代理可以用static。但在配置类中推荐一律用普通方法让Spring接管代理逻辑避免出现“时灵时不灵”的诡异现象。6.4 bean初始化顺序问题有些业务场景对bean的初始化顺序有要求比如A要先准备好缓存数据B启动时才能正确读取。Spring容器默认的bean初始化顺序是按依赖关系推导的你无法绝对控制但可以通过两种方式干预。一种是在Bean方法上使用DependsOnBean DependsOn(cacheService) public ReportService reportService() { return new ReportService(); }这个方法告诉容器先创建cacheService再创建reportService。另一种是让bean实现Ordered接口或者在类上使用Order注解同类场景下按order值从小到大排列。不过要注意Order只作用于同一类型或同一集合的多个bean它并不能严格定义所有bean的启动顺序预期不要放太高。还有一个常见现象是某些bean构造时会读配置属性但配置属性还没被加载完。这大概率不是顺序问题而是没有使用正确的方式获取配置值——应该用Value或者ConfigurationProperties绑定而不是在构造方法里直接静态读取。如果必须在启动时读配置建议使用ApplicationRunner或CommandLineRunner在容器完全启动后再执行。6.5 Configuration类加final后出现bean覆盖异常之前提过Spring会通过CGLIB生成Configuration类的子类来实现代理而CGLIB无法继承final类。有人为了避免配置类被继承顺手加了final结果发现启动报错或者bean被创建了多份一脸茫然。Configuration public final class AppConfig { // 不要这么写 Bean public UserDao userDao() { return new UserDao(); } }加了final之后Spring无法创建配置类的代理子类只能退回到lite模式的处理逻辑也就是不拦截Bean方法的内部调用导致多次调用Bean方法时返回不同实例。更直接的后果是一些需要拦截方法才能生效的特性全部失效。正确做法是不要用final修饰配置类或者退一步用Bean 普通类的组合明确接受lite模式不再依赖代理特性。想防止配置类被误继承使用package-private类或把配置类的细节收起来就好Spring对配置类没有非得public的要求。这个小细节不能说天天遇见但真出现时定位起很容易走弯路属于“配置声明”这个主题里值得记住的坑。7. 关于容器管理的几个小习惯经验上我建议你把“容器能自动做的事”和“需要你显式告诉容器做的事”分清楚。自动装配依赖是容器帮你做的事而声明bean本身是你必须做的事。构建复杂对象时优先写Bean方法注册业务类时用Autowired配合构造器注入遗留XML先桥接再逐步消化。还有一个实操细节值得保持项目里尽量统一bean的命名规则。XML时代人们习惯叫userService注解时代默认也是这个到了JavaConfig里也按这个习惯来。名字统一之后排查问题时的心理负担会小很多IDE的提示也更友好。这种基础功底不一定会天天展示但一旦遇到循环依赖、bean冲突、配置失效这类问题能不能快速定位很大程度上取决于你对容器管理机制的理解深度。三种方式看似只是写法差异背后的容器思想才真正值钱。
返回列表