尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring @Configuration 从原理到实战:CGLIB代理与避坑指南

Spring @Configuration 从原理到实战:CGLIB代理与避坑指南 我自己的开源项目被同事在代码评审里一顿追问之后我才意识到很多天天用Spring的人对Configuration的理解其实停留在“加个注解就能用”的层面。准确说是在Spring里待了多年、能熟练写Bean方法的人也未必说得清为什么Configuration里方法会被代理、为什么换成Component之后某些写法突然就不生效了。于是我决定把Configuration这个注解从头到尾拆一遍从原理到实战把那些容易踩的坑一次性讲透。这篇文章适合谁准备啃Spring源码的面试前突击Spring的或者写了好几年Spring Boot但没认真研究过配置原理的。我会尽量用最直白的语言把底层机制讲清楚同时给出一套可以落地的代码实践。1. Configuration的真实作用域远不止一个普通注解1.1 从最简单的用法讲起要说Configuration绕不开最经典的用法——定义配置类然后在里面通过Bean方法声明Bean。Configuration public class AppConfig { Bean public UserService userService() { return new UserService(userRepository()); } Bean public UserRepository userRepository() { return new UserRepository(); } }这段代码看起来人畜无害但里面藏着一个很多初级开发根本意识不到的细节userService()方法里调用了一次userRepository()而Spring在创建userRepository的Bean时也会调用一次userRepository()。如果这是一个普通方法那就会创建两个UserRepository实例。但实际运行结果却是Spring容器里只有一个UserRepository实例。原因是什么因为Configuration类会被Spring通过CGLIB动态代理。被代理之后userRepository()这个方法的每一次调用都会先检查容器里是否已经存在对应的Bean如果存在就直接返回容器里的实例不再执行方法体。这个机制保证了单例Bean的语义。1.2 Configuration与其他类似注解的区别说句扎心的Configuration、Component、Service、Repository、Controller这几个注解很多开发者以为它们差不多——无非就是怎么标识一个类是Spring管理的Bean。但Configuration实际上被Spring做了特殊对待。注解作用范围是否会被CGLIB代理核心用途Configuration类级别是定义配置Bean方法会被代理保证单例Component类级别否通用组件扫描Spring Boot自动扫描的目标Service类级别否业务层语义化注解Repository类级别否数据访问层语义化注解带特定异常转换Controller类级别否Web控制层语义化注解这里有个很常见的误区把Configuration替换成Component项目照样能跑很多情况下功能也正常。比如Component public class AppConfig { Bean public UserRepository userRepository() { return new UserRepository(); } }这段代码没问题AppConfig会被注册为一个普通BeanuserRepository()方法也能被识别。但区别在于Component标注的类不会被CGLIB增强如果两个Bean方法之间存在互相调用那就会真的执行两次方法体产生多个实例。所以如果你写的是配置类就应该用Configuration不要贪方便用Component。这不仅是一个规范问题本质上是一个“你的代码是否尊重Spring容器语义”的问题。1.3 什么时候用Configuration什么时候不该用Configuration最适合两类场景一类是纯粹的配置类比如数据库连接、消息队列、Redis等基础设施的Bean定义另一类是第三方的、无法用Component扫描到的类需要通过Bean方式注入容器。但如果你是在写自己的业务类比如Service、Controller、Repository那就别用Configuration。加了Configuration之后这个类会被代理每次方法调用都会走代理逻辑虽然没有性能问题但语义不对。更关键的是如果你的配置类里有非Bean的业务方法这些方法也会被CGLIB处理可能导致一些意想不到的行为比如方法调用走了代理但内部依赖还没初始化或者final方法抛异常。2. Full模式与Lite模式同一个注解两种截然不同的行为2.1 两种模式是怎么区分的Spring 5.2以后官方文档里明确提到了“Full Configuration”和“Lite Configuration”两个概念。这两个概念的划分依据很简单类上标注的是Configuration叫Full模式标注的是Component、ComponentScan、Import、ImportResource或者只在方法上标注了Bean都叫Lite模式。Full模式下Spring会对配置类做CGLIB代理确保Bean方法的单例语义。Lite模式下没有这种代理机制Bean方法就是普通方法调用几次就会执行几次方法体。这里还有一个容易被忽略的细节Configuration的代理默认是通过proxyBeanMethods属性控制的默认值是true。也就是说即使你用了Configuration也可以把proxyBeanMethods设为false来关闭代理。Configuration(proxyBeanMethods false) public class AppConfig { Bean public UserRepository userRepository() { return new UserRepository(); } }关闭代理之后userRepository()方法就是普通方法了每次调用都会创建新实例。这种模式更适合那种每个Bean方法之间没有相互依赖的配置类可以省掉代理带来的性能开销。2.2 代理带来的隐性问题方法必须可被覆写既然Full模式走的是CGLIB代理那配置类的方法和类就不能是final否则CGLIB无法生成子类。这个限制导致了一个很隐蔽的坑如果配置类被标注为final或者Bean方法是private/final的Spring容器启动时就会直接报错。BeanDefinitionParsingException: Configuration class xxx must not be final.另一个容易被忽略的问题是Bean方法不能是private的。很多人以为方法访问修饰符无关紧要但在Configuration类里Bean方法必须是public或者protected的。private方法会导致CGLIB无法覆写最终在启动阶段抛异常。2.3 Full模式与Lite模式到底该怎么选我的建议很简单标准的配置类尤其是Bean方法之间存在调用关系的优先用Configuration(proxyBeanMethods true)也就是默认模式。如果Bean方法之间没有调用关系且配置类只做Bean注册可以切到proxyBeanMethods false性能更好。如果是那种特别简单的、只有一两个Bean的配置类用Configuration依然推荐别为了省事去用Component。性能上Full模式的代理开销其实很小除非你的配置类有成百上千个Bean方法否则感知不到差异。真正需要关注的是语义正确性不是性能。3. 解析机制的幕后黑手ConfigurationClassPostProcessor如何工作3.1 一个后置处理器搞定所有配置类Configuration的解析核心功臣是ConfigurationClassPostProcessor它实现了BeanDefinitionRegistryPostProcessor接口也就是一个可以修改BeanDefinition的后置处理器。Spring容器在启动早期会先收集所有候选的配置类然后交给ConfigurationClassPostProcessor去解析。整个解析流程大致分这么几步扫描所有已注册的BeanDefinition找出标注了Configuration的类。对每个配置类解析类上的ComponentScan注解把指定包路径下的类扫描进来。解析Import注解处理导入的普通类、ImportSelector和ImportBeanDefinitionRegistrar。解析Bean方法为每个Bean方法生成对应的BeanDefinition。处理PropertySource、PropertySources注解加载属性文件。最后用CGLIB对Full模式的配置类进行代理增强。这个流程是Spring IoC容器初始化的核心一环。理解了它你就能明白为什么Configuration里的Bean方法必须是具体类里定义、不能是接口方法因为CGLIB代理是针对具体类生成的。3.2 同样的注解为什么位置不同结果不同这里分享一个我自己实际遇到过的案例。Configuration public class DatabaseConfig { Value(${db.url}) private String url; Value(${db.username}) private String username; Value(${db.password}) private String password; Bean public DataSource dataSource() { return DataSourceBuilder.create() .url(url) .username(username) .password(password) .build(); } }这段配置看起来很正常但如果db.url这些属性没有配置容器启动会直接失败。问题不在Configuration而在Value解析的时机。ConfigurationClassPostProcessor执行的时候Value还没有被处理属性值注入是在AutowiredAnnotationBeanPostProcessor阶段完成的。但Bean方法本身可以正常被注册真正报错是在dataSource()方法被执行时。这个案例想说明的是Configuration类里Value和Bean的依赖关系属于“方法执行阶段”的依赖而不是“解析阶段”的依赖。如果属性缺失报错信息会非常隐晦你要从整个启动过程中去找。3.3 配置类内部的Bean依赖方式在Configuration类里Bean方法之间可以互相调用来表达依赖关系。这在Full模式下的语义是正确的Spring会通过代理保证最终返回的都是容器里的单例Bean。Configuration public class ServiceConfig { Bean public UserService userService() { return new UserService(userRepository()); } Bean public UserRepository userRepository() { return new UserRepository(); } }但如果你把Configuration换成Component这段代码就会创建两个UserRepository的实例一个是被userService()方法调用的一个是Spring容器创建后管理着的。两个实例内存地址不同如果UserRepository内部有状态比如连接池那就会出现诡异的问题——你在Service里持有的连接池和容器管理的连接池不是同一个。建议是配置类内部的Bean依赖尽量用方法参数的方式表达而不是方法内部调用。比如这样Configuration public class ServiceConfig { Bean public UserService userService(UserRepository userRepository) { return new UserService(userRepository); } Bean public UserRepository userRepository() { return new UserRepository(); } }这种方式既清晰又没有代理依赖。即便配成Lite模式也不会出问题。4. Configuration与其他配置方式的组合协作4.1 ComponentScan的批判性问题Configuration类最常见的搭档就是ComponentScan。通过两者配合可以做到“包扫描 配置类集中注册Bean”的组合方案。Configuration ComponentScan(basePackages com.example) public class AppConfig { }这种写法下com.example包下所有标注了Component、Service、Repository、Controller的类都会被注册为Bean。而AppConfig本身作为配置类则负责装配那些无法通过注解扫描的Bean比如第三方库的客户端、数据源等。这里有一个容易忽略的点ComponentScan的basePackages如果没写默认会扫描配置类所在的包和子包。如果你把配置类放在一个无关的包下扫描范围会完全出乎意料。我见过一个项目配置类放在com.example.framework.config结果扫描了整个com.example下的所有包启动时莫名多了一堆不需要的Bean排查了半天才发现是包路径的问题。4.2 Import的三种类型Import是Configuration之外另一种很重要的配置导入方式它支持三种类型的参数普通类会被直接注册为Bean。这个是最简单的相当于把另一个配置类或普通类引入容器。Configuration Import(MongoConfig.class) public class AppConfig { }ImportSelector接口的实现类可以动态决定导入哪些类。这种方式特别适合做“根据某个配置决定是否引入某组Bean”的场景。public class MyImportSelector implements ImportSelector { Override public String[] selectImports(AnnotationMetadata importingClassMetadata) { return new String[]{com.example.config.MongoConfig}; } } Configuration Import(MyImportSelector.class) public class AppConfig { }ImportBeanDefinitionRegistrar的实现类可以自定义注册BeanDefinition的逻辑。这种方式最灵活可以在注册阶段做额外判断。三者对比可以整理成一张表导入类型使用场景灵活性普通类静态导入固定配置低ImportSelector根据条件动态选择要导入的配置中ImportBeanDefinitionRegistrar需要自定义BeanDefinition注册逻辑高4.3 配置类的继承与组合Configuration类支持继承。子配置类会继承父配置类里的Bean方法定义这在做多环境配置时非常好用。Configuration public abstract class BaseDataSourceConfig { Bean public DataSource dataSource() { return DataSourceBuilder.create().build(); } } Configuration public class DevDataSourceConfig extends BaseDataSourceConfig { }但是要注意如果父类和子类里定义了同名的Bean方法子类会覆盖父类的定义。这个覆盖逻辑和Java方法重写是一样的但Spring在解析时会以子类的Bean方法为准。还有一种组合方式是将多个配置类嵌套在一个主配置类里用内部类的方式组织。比如Configuration public class AppConfig { Configuration public static class WebConfig { Bean public WebMvcConfigurer webMvcConfigurer() { // ... } } Configuration public static class DataConfig { Bean public DataSource dataSource() { // ... } } }这种方式比较适合把多个关联的配置聚合在一个类文件里代码上更内聚但也会让主配置类变得臃肿仁者见仁。5. 条件化配置与属性绑定让配置类活起来5.1 Profile与Conditional一个纯静态的Configuration类在项目里是很少见的。大部分时候我们希望在特定环境、特定条件下才加载某些配置。Spring提供的最简单方案是Profile。Configuration Profile(dev) public class DevDataSourceConfig { Bean public DataSource dataSource() { return new HikariDataSource(); } }这段配置只在dev环境激活时生效。但如果条件更复杂比如“当某个类存在时生效”就得用Conditional。Configuration ConditionalOnClass(name com.mysql.cj.jdbc.Driver) public class MySqlDataSourceConfig { }ConditionalOnClass是Spring Boot的扩展注解底层就是ConditionalCondition接口的自动匹配。我自己在写通用Starter时经常用ConditionalOnClass和ConditionalOnMissingBean两个组合前者保证类路径上有对应依赖才加载配置后者保证用户已经手动定义了某个Bean时不重复注册。5.2 ConfigurationProperties与配置绑定Configuration除了定义Bean方法还可以配合ConfigurationProperties做配置属性的绑定。这个组合在Spring Boot里极其常用。Configuration ConfigurationProperties(prefix app.mongo) public class MongoProperties { private String uri; private String database; // getters/setters }这样配置类里的字段就会自动绑定application.yml中app.mongo前缀的配置项。但有一点要注意Configuration和ConfigurationProperties搭配时如果配置类里还有Bean方法这些Bean的注册顺序和属性绑定的顺序可能不一致。更推荐的做法是单独拆一个属性类再在配置类里用EnableConfigurationProperties引入。Configuration EnableConfigurationProperties(MongoProperties.class) public class MongoConfig { private final MongoProperties properties; public MongoConfig(MongoProperties properties) { this.properties properties; } Bean public MongoClient mongoClient() { return MongoClients.create(properties.getUri()); } }这种方式一改属性绑定和Bean创建的依赖关系就非常清晰了调试也方便。5.3 动态配置和刷新配置还有一个场景需要考虑配置需要动态刷新。Spring Cloud Config配合RefreshScope可以做到。但在纯Spring Boot项目里如果你用ConfigurationProperties绑定配置默认是启动时读一次就固定了不会自动刷新。想要刷新的话需要引入spring-boot-starter-actuator开启/actuator/refresh端点并给对应的Bean加RefreshScope。经验是能用Environment的属性占位符解决的问题就不要强行上刷新配置。刷新配置是有代价的它会销毁并重建Bean如果这个Bean是连接池、MQ客户端这类有状态对象启动时的连接建立开销会重复出现并且重建期间会有短暂的服务不可用。把配置分成“启动不变的基础配置”和“可动态调整的业务配置”两层比一揽子搞动态刷新要稳得多。6. 实战中值得警惕的失效场景与排查思路6.1 Configuration类被Final修饰导致启动失败这个坑我愿称之为“注解使用不当之最”。一旦配置类被标注为finalSpring启动时直接抛异常而且异常信息很明确——must not be final。原因前文已经说过CGLIB代理需要生成子类final类无法被继承。解决办法也简单去掉final修饰符。但有一种情况很隐蔽类没有被直接写成final但因为继承了某个父类方法方法被标记为final。那Bean方法同样会出问题因为CGLIB无法覆写final方法。遇到这种情况不要试图去“绕过”final而是应该重构代码结构别在配置类里搞复杂继承关系。6.2 配置类中调用Bean方法导致的多实例问题如果你确实用了Component的“伪配置类”并且在不同Bean方法里多次调用了同一个Bean方法那就会出现多实例问题。这种问题的表现形式特别迷惑人——功能看似正常但你会观察到性能异常、连接数过多、缓存不生效等随机性故障。我在一个项目里遇到过类似的问题RedisTemplate被创建了两次每次调用redisTemplate()方法都会new一个新的LettuceConnectionFactory导致连接池被反复创建最终连接数打满。排查过程很有意思——不是看代码能看出来的是上线后连接数持续上涨最后dump堆内存发现多个RedisTemplate实例顺藤摸瓜找到问题。这种问题的根源就是配置类用错了注解。回头想想很多“奇怪”的Spring故障最后都能追溯到配置写法的语义偏差上。6.3 一个完整的排查链路演示为了让你知道遇到类似问题时怎么排查我把上面那个Redis多实例问题的完整排查链路写下来你以后可以照着这个思路来。第一步确认配置类用的什么注解。先看代码里有没有Configuration被错误替换成Component的情况。用IDE的全局搜索搜Component标注的类里有没有Bean方法。第二步给Bean方法打上临时日志打印this.getClass().getName()。如果类是AppConfig$$EnhancerBySpringCGLIB$$...这样的名字说明是Full模式如果就是AppConfig本身说明没走CGLIB代理那就要怀疑是不是Lite模式了。第三步确认Bean的实例数量。在Bean方法里打印一行日志看启动过程里方法被调用了几次。如果被调用了多次基本可以断定是proxyBeanMethods被关闭或者配置类不是Configuration。第四步动态获取容器里的Bean对比实例地址。Autowired private ApplicationContext context; public void checkBean() { Object bean1 context.getBean(redisTemplate); Object bean2 context.getBean(redisTemplate); System.out.println(bean1 bean2); // true 则是同一个实例 }如果输出false说明容器里出现了两个不同的Bean实例问题基本坐实。6.4 配置类的调试技巧用日志追踪配置类是否被代理是排查配置类问题的核心手段之一。除了在Bean方法里打印class名称还可以在配置类里加一个PostConstruct方法打印this.getClass()。我个人习惯在调试的时候写一个最简单的配置类只保留一个Bean方法用它来做最小化复现。这样能把问题范围缩小到“配置写法”还是“业务逻辑”上。另外Spring Boot的debugtrue配置可以打印自动配置报告能帮你看到哪些配置类被加载、哪些被排除。排查ConditionalOnClass这类条件注解失效的问题时这个报告非常有用。7. 把Configuration用好的几个进阶习惯7.1 配置类职责单一一个配置类只负责一个领域这个准则是放之四海皆准的。数据库一个配置类、消息队列一个配置类、缓存一个配置类、Web层一个配置类。不要试图把几十个Bean方法塞进一个类里那样最后维护起来会非常痛苦。我在老项目里见过一个“上帝配置类”里面放了200多个Bean方法涵盖数据源、MQ、Redis、调度器、邮件、文件存储……改一个字段要全局搜索半天后来实在忍不了花了一个星期拆成了十几个专门的配置类。拆完之后很多问题不治而愈因为互相之间的隐式依赖被显式化拆开了。7.2 善用EnableConfigurationPropertiesEnableConfigurationProperties这个注解非常值得多说两句。它可以直接把属性类注册进容器而不需要给属性类加Component或Configuration。Configuration EnableConfigurationProperties(RedisProperties.class) public class RedisConfig { }这样RedisProperties会被自动注册为Bean并且完成属性绑定。好处是属性类不参与组件扫描不会被当成普通Bean到处乱扫。如果你有很多个属性类还可以用EnableConfigurationProperties({A.class, B.class, C.class})批量注册。7.3 显式优于隐式写配置类的时候尽量让依赖关系显式化。Bean方法之间的调用能改成方法参数注入就改成方法参数注入。配置类之间需要协作的时候通过构造器或者Autowired字段注入而不是让配置类A去调用配置类B里的Bean方法。这样写的直观好处是静态代码分析工具能更容易发现问题新增同事接手项目时也不用一行行去猜方法调用关系。Spring的隐式魔法已经够多了自己的代码里就不要再叠buff了。7.4 配置类与Spring AI新特性最近Spring AI挺火很多团队开始用Configuration配合Bean的方式定义AI相关的客户端和模型实例。比如定义一个ChatClientConfiguration public class AiConfig { Bean public ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem(你是一个专业的Java开发助手) .build(); } }这种写法和普通配置类没什么本质区别但要注意AI客户端通常是有状态的比如它内部可能会持有模型会话上下文。如果配置类用了proxyBeanMethods false并且其他地方通过方法调用方式获取这个Bean那就可能拿到多个不同的客户端实例导致会话状态错乱。所以建议AI相关的Bean定义还是走标准的Full模式。8. 几个绕不开的面试追问方向8.1 为什么Configuration类能被Autowired注入依赖Configuration类本身也是一个Bean它同样遵循Spring的依赖注入规则。在Configuration类里使用Autowired、构造器注入都是合法的而且Spring官方推荐在Configuration类中使用构造器注入因为这样在配置类内部用到依赖时保证依赖已经就绪。Configuration public class ServiceConfig { private final UserRepository userRepository; public ServiceConfig(UserRepository userRepository) { this.userRepository userRepository; } Bean public UserService userService() { return new UserService(userRepository); } }这种方式比Autowired字段注入更可靠尤其是配置类被CGLIB代理后字段注入的时机和顺序可能会有微妙问题。构造器注入没有这个问题。8.2 Configuration与ConfigurationProperties的组合关系很多面试官喜欢问两者是什么关系。简单回答Configuration定义BeanConfigurationProperties做属性绑定。两者可以各自独立使用也可以组合使用。组合使用时推荐用EnableConfigurationProperties方式解耦。8.3 为什么Bean和Configuration要配合使用Bean单独用也是可以的Spring Boot的自动配置就是通过Bean方法来定义各种默认Bean。但Bean和Configuration配合时才能获得Full模式的CGLIB代理语义从而保证单例、保证Bean方法之间的调用不会创建额外实例。单独把Bean写在Component类里得到的只是Lite模式某些场景下会埋坑。8.4 如何验证Configuration是否生效最直接的验证方法是启动Spring容器时看启动日志里面会打印配置类的解析信息。或者像我前面说的在Bean方法里打印this.getClass().getName()。如果名字里包含$$EnhancerBySpringCGLIB$$说明配置类确实被代理了。我之前排查过一个问题一个Spring Boot项目里自定义的配置类完全没生效Bean方法也没有被调用。最后发现是配置类所在的包根本不在主启动类的扫描路径下面。很多时候Configuration失效不是注解的问题是组件扫描压根就没扫到这个类。在大型项目中我会建议把配置类集中放在一个明确的包路径下例如com.example.config然后在主启动类上显式指定扫描路径或者用Import导入。这样比完全依赖包扫描更可控排查问题时也能快速定位。最后想说的Configuration这个注解表面上是用来声明配置类的但背后牵扯到组件扫描、Bean定义解析、CGLIB代理、条件化装配等Spring容器的核心机制。很多时候我们遇到莫名其妙的Bean重复创建、配置不生效、单例失效问题往深了追最后都落在“配置类到底以什么模式被解析”这一个点上。我之前也写过不少配置类踩过坑也帮别人排查过问题。回过头来最想分享的一点点体会是使用Configuration时不要贪图省事不要随意替换成Component不要为了微小的性能提升去关闭proxyBeanMethods除非你非常清楚自己在做什么。配置类看似简单但它决定的是整个容器的Bean装配方式这里出了问题排查成本会成倍上升。如果你正在啃Spring源码可以从ConfigurationClassPostProcessor入手把Configuration的解析流程完整跟一遍这比看十篇面试题总结都有效。读完源码再回来看这篇文章你会觉得很多“坑”其实是设计使然理解了设计意图你就能猜到哪些地方容易踩坑了。
返回列表