ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringUtil工具类:基于ApplicationContextAware的静态方法Bean获取指南

SpringUtil工具类:基于ApplicationContextAware的静态方法Bean获取指南 在Spring项目里待久了大概率会遇到这么一个需求写一个工具类想在静态方法里调用Service层的方法习惯性敲出Autowired编译能过一运行直接抛NullPointerException。我当年第一次遇到这个报错时还以为是装配的Service名写错了排查了半天才发现问题出在注入方式上——Spring的依赖注入根本没法处理static字段和容器外对象。后来接触到一个叫SpringUtil的工具类就是用Spring提供的ApplicationContextAware接口在容器启动时把ApplicationContextSpring容器对象保存到一个静态字段里从此项目里任何地方都能通过SpringUtil.getBean()拿到容器里任意Bean。这篇文章就把我实际使用SpringUtil的过程、实现原理、以及踩过的几个坑完整记录下来适合正在被非Spring管理的类拿不到Bean困扰的开发者参考。1. 从一次NullPointerException说起为什么会有SpringUtil这种工具1.1 复现场景工具类里注入Service结果拿到null先来看一段典型的错误代码。我在一个老项目里要做用户信息的批量处理想着封装一个静态工具类代码大概长这样public class UserUtil { Autowired private static UserService userService; public static String getUsername(Long id) { return userService.getUsername(id); } }结果一运行userService就是null。为什么因为Spring的依赖注入是基于容器管理的对象实例进行的Spring在创建Bean时会处理实例字段、Setter方法、构造器参数上的Autowired但static字段根本不参与这个过程——Spring根本不会去给类的静态成员做属性填充。等类加载器把UserUtil加载进来、静态方法被调用时userService一直是初始值null。如果把Autowired放到某个实例字段上Component public class UserUtil { Autowired private UserService userService; }这样确实能注入成功前提是这个UserUtil本身得是Spring容器里的一个Bean而且使用方也必须从容器里拿它。麻烦就麻烦在很多工具类天生就是静态的或者某些对象根本不在Spring容器的管辖范围内比如Quartz创建的Job实例、Netty的Handler、自己new出来的策略对象。这些场景下常规依赖注入直接失效。1.2 依赖注入管不到的地方我梳理了一下实际项目中会遇到这么几类拿不到Bean的情况静态工具类中的静态方法。工具方法本来就是public static直接调用的类上没有Component方法里却需要某个Service。非Spring管理的回调对象。比如Quartz的Job实现类由Quartz框架实例化Spring不知道它的存在。定时任务、消息监听器中被反射创建的对象。部分框架会绕过Spring自己new对象这些对象里想用Autowired是空谈。某些框架的扩展点、拦截器、过滤器虽然可能在Spring容器里但生命周期比较特殊不方便用常规注入。在这些场景里唯一可靠的办法就是拿到Spring容器对象本身通过容器去获取Bean。而Spring官方也想到了这一点专门提供了ApplicationContextAware这类回调接口让我们能在容器启动时把ApplicationContext“扣”出来。1.3 SpringUtil解决的核心问题SpringUtil干的事情其实很朴素写一个类实现ApplicationContextAware接口在setApplicationContext方法里把容器保存到static变量中。之后任何代码只要能引用到SpringUtil类就能通过这个静态变量拿到ApplicationContext再调用getBean()系列方法获取想要的Bean。这样做的好处非常直接不受Bean生命周期限制容器启动完成之后任何时刻都能调用。不加Autowired不依赖Spring实例化该对象静态工具类、非托管对象都能用。一个静态字段全局共享项目里所有模块拿到的是同一个容器实例。当然代价也有本质上绕过了依赖注入代码里出现了服务定位器的味道用多了会破坏Spring的IoC风格。但它在特定场景下确实是最实用、最简单、最不容易出错的方案。我用它救过不止一次场这个后面细说。2. 最小实现基于ApplicationContextAware的SpringUtil2.1 核心代码与关键点注释SpringUtil的完整实现并不复杂下面是我在实际项目中使用的版本核心部分带注释package com.example.common.util; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.stereotype.Component; Component public class SpringUtil implements ApplicationContextAware { private static ApplicationContext applicationContext; /** * Spring容器在初始化时会自动调用这个方法把容器本身传进来。 */ Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { SpringUtil.applicationContext applicationContext; } public static ApplicationContext getApplicationContext() { return applicationContext; } /** * 按类型获取Bean比如 SpringUtil.getBean(UserService.class) */ public static T T getBean(ClassT requiredType) { return applicationContext.getBean(requiredType); } /** * 按名称和类型获取Bean适用于同类型有多个实例的情况。 */ public static T T getBean(String name, ClassT requiredType) { return applicationContext.getBean(name, requiredType); } /** * 按名称获取Bean返回Object需要调用方自己转型。 */ public static Object getBean(String name) { return applicationContext.getBean(name); } /** * 获取某个类型的所有Bean返回Mapkey是beanName。 */ public static T java.util.MapString, T getBeansOfType(ClassT type) { return applicationContext.getBeansOfType(type); } /** * 获取环境配置项等价于Value(${xxx})但可以动态获取。 */ public static String getProperty(String key) { return applicationContext.getEnvironment().getProperty(key); } }两个容易忽略的细节第一个是Component不能少。SpringUtil必须被Spring扫描到并实例化成BeansetApplicationContext才会被回调。第二个是getBean里的泛型方法applicationContext.getBean(requiredType)会有类型推断但Spring 5.1以上还支持ResolvableType形式的获取真需要时再扩展普通项目用上面的就够了。2.2 Aware接口回调的时机Bean生命周期里的一环很多初学者不理解setApplicationContext到底是谁在什么时候调用的实际上这是Spring容器Bean生命周期中的一个标准环节。一个单例Bean从创建到就绪大致要经历实例化构造器→ 属性填充Autowired、Value等→ 初始化PostConstruct、InitializingBean→ 使用 → 销毁。其中属性填充和初始化之间Spring会去检查这个Bean是否实现了各种Aware接口。如果实现了BeanNameAware就传入Bean的名字实现了BeanFactoryAware就传入BeanFactory实现了ApplicationContextAware就传入ApplicationContext。这个回调是由ApplicationContextAwareProcessor这个BeanPostProcessor来执行的它在postProcessBeforeInitialization阶段调用。也就是说在Bean初始化之前SpringUtil就已经拿到了容器对象。整个容器刷新完成后只要SpringUtil被扫描到这个静态字段必然是有效的。所以setApplicationContext里不需要加任何空判断因为能走到这一步说明容器一定已经存在。但调用方使用SpringUtil.getBean()时需要保证容器已经刷新完成否则可能拿到null。2.3 为什么用static保存context不会乱有同学会问applicationContext是static字段而SpringUtil本身是个单例Bean为什么能把实例方法里的参数传给静态字段这里的关键在于static字段属于类本身不属于任何实例。Spring创建SpringUtil对象时调用setApplicationContext本质上只是借助这个实例方法把容器对象存到类级别的变量中。之后即使SpringUtil的那个单例Bean被销毁甚至这个类的实例被GC回收静态字段依然存在。项目中任何地方引用SpringUtil.applicationContext拿到的都是同一份容器引用。从另一个角度看这也说明SpringUtil本身有没有被实例化并不影响静态字段的使用——只要容器启动时执行过一次setApplicationContext就行。这也是为什么在单元测试里我们可以手动调用SpringUtil.setApplicationContext(...)来模拟这个初始化动作。3. getBean背后的容器原理从BeanFactory到三级缓存3.1 ApplicationContext到底是个什么对象ApplicationContext是Spring IoC容器的核心接口它继承自ListableBeanFactory而ListableBeanFactory又继承自BeanFactory。BeanFactory定义了getBean()、containsBean()等基础方法ApplicationContext在此基础上又扩展了事件发布、资源加载、国际化等能力。SpringUtil要做的就是把整个容器的入口保存下来。换句话说拿到了ApplicationContext就相当于拿到了Spring所有Bean的管理入口。这个入口不仅包含我们自己注册的Service、Repository、Controller也包括Spring Boot自动配置里的各种Bean——比如DataSource、RedisTemplate、ObjectMapper等。实际运行时Spring Boot环境下的applicationContext通常是一个AnnotationConfigServletWebServerApplicationContext实例它内部持有DefaultListableBeanFactory这个DefaultListableBeanFactory才是真正存储Bean定义和单例实例的地方。3.2 getBean时Spring做了什么单例池与三级缓存为什么要单独讲这个因为理解getBean()的执行过程才能知道SpringUtil.getBean和普通的注入有什么差别也知道什么情况下会踩坑。Spring的DefaultSingletonBeanRegistry中有一个关键字段/** Cache of singleton objects: bean name to bean instance. */ private final MapString, Object singletonObjects new ConcurrentHashMap(256);对于默认的单例BeanSpring创建完成后就会放到这个singletonObjects一级缓存里。调用getBean(userService)时大部分情况是直接从这张Map里查出来返回。那三级缓存是怎么回事在单例Bean的创建过程中A依赖B、B依赖A这种情况会导致循环依赖。Spring的解法是在Bean完成实例化构造器执行完但还未完成属性填充时把它的ObjectFactory放到第三级缓存singletonFactories里当A发现自己依赖B、B又回头依赖A时B可以从第三级缓存拿到A提前暴露的实例此时A还没完成初始化属于半成品Bean放入二级缓存earlySingletonObjects最后A完成所有初始化被放入一级缓存singletonObjects二级三级缓存删除。所以SpringUtil.getBean拿到什么样的Bean取决于调用时机。如果容器已经完整启动所有单例Bean都已创建完拿到的一定是最终成品包括AOP代理后的对象。如果在Bean创建过程中调用可能拿到的是提前暴露的半成品这就要特别小心后面我会专门讲这个坑。3.3 手写简易容器对照理解SpringUtil的本质为了更直观地理解我写过一个极简容器public class SimpleContainer { private final MapString, Object singletonObjects new ConcurrentHashMap(); public T T getBean(ClassT type) { return type.cast(singletonObjects.get(type.getName())); } public void registerBean(String name, Object bean) { singletonObjects.put(name, bean); } }使用方式先把Service的实例放进Map再通过getBean取出来。Spring的容器本质上就是一个更复杂的这样的Map只不过多了BeanDefinition解析、反射创建、依赖注入、AOP代理生成等一大套机制。SpringUtil.getBean()做的事就是从这张大Map里根据类型或名称取出对应的值。想明白这一点就不会对SpringUtil有任何神秘感了。它就是给这个Map提供了一个公开的、全局的访问入口。3.4 为什么getBeansOfType能拿到一堆BeangetBeansOfType(ClassT type)返回的是MapString, Tkey是每个Bean在容器中的名字。底层实现会遍历BeanFactory里的所有BeanDefinition把类型匹配的筛选出来再去创建或获取对应实例。在策略模式场景中这个很好用。比如我定义了一个MessageHandler接口有短信、邮件、站内信三个实现类那么在某个路由类里可以一次性拿到所有实现MapString, MessageHandler handlerMap SpringUtil.getBeansOfType(MessageHandler.class);然后通过Map的key或遍历来处理消息。getBean(smsHandler, MessageHandler.class)则适合明确知道名字的场景比如按配置项动态选择实现类。用getBeansOfType需要注意它会触发所有匹配Bean的实例化。如果某个实现类初始化开销很大或者依赖了暂时不可用的资源贸然调用可能导致意想不到的问题。4. 生产环境里常见的坑与排查链路4.1 setApplicationContext没被调用context为null这是最经典的问题。现象是SpringUtil编译运行都不报错但getBean()调用时抛出NullPointerException因为applicationContext是null。排查链路一般分三路先看SpringUtil所在的包有没有被扫描到。如果启动类用了ComponentScan限定包路径而SpringUtil在别的包下面容器根本不会创建它setApplicationContext自然不会被调用。解决办法要么把包纳入扫描范围要么单独Import(SpringUtil.class)或者用Bean方法注册。再看项目是否存在多个容器。老一点的项目可能是Spring和SpringMVC父子容器结构SpringUtil被Web子容器扫描到但业务Service注册在父容器中这样SpringUtil持有的context里根本没有Service。排查方式是打印SpringUtil.getApplicationContext().getClass()看看具体是哪个容器再检查它有没有getBean(userService)。最后一种可能在单元测试里没启动Spring上下文就直接调用了SpringUtil。这个场景我单独在4.6里细说。4.2 静态代码块执行时容器还没启动有同事写过一段这样的代码public class AppConfig { static { String projectName SpringUtil.getProperty(app.name); } }运行就崩原因不是代码写错而是静态代码块在类加载时执行时点远早于Spring容器的启动。类加载可能发生在Spring启动前的各种环节——比如另一个静态工具类先被触发了或者某个配置类初始化时引用了AppConfig。此时SpringUtil持有的context还是null。正确做法是不要在静态代码块里通过SpringUtil拿配置或Bean。如果需要提前读取配置可以用EnvironmentPostProcessor或者把配置封装成ConfigurationProperties对象在Bean初始化阶段取用。如果实在要在静态代码块里初始化某些东西也要做null判断并延迟加载。这一点很多教程不会提但实际项目里反复出现我单独拿出来说。4.3 prototype类型的Bean被缓存了SpringUtil.getBean每次调用都会从容器获取但不同类型的Bean行为不同。对于Scope(singleton)的Bean每次拿到的都是同一个实例。对于Scope(prototype)的Bean每次getBean都会创建一个新的实例——这才是prototype语义应该有的样子。问题出在有些人拿到prototype Bean后喜欢存到static变量里复用。比如private static TaskExecutor taskExecutor SpringUtil.getBean(taskExecutor, TaskExecutor.class);如果这个TaskExecutor是prototype的被存到static字段后它就不再具备每次调用都是新实例的特性了翻车是迟早的事。prototype Bean的正确用法是每次使用时都调用SpringUtil.getBean()获取新实例像上面的写法等于硬生生把一个多例Bean变成了全局单例。这也是我在代码规范里反复提醒的一条SpringUtil只负责从容器拿Bean拿回来之后怎么管理生命周期还是得看Bean作用域。4.4 拿到的是原始对象还是AOP代理对象Spring的AOP代理是在Bean初始化完成后、通过BeanPostProcessor生成的。一个标注了Transactional的Service单例池里保存的是经过代理的对象。SpringUtil.getBean拿到的就是这个代理对象所以调用方法时事务、缓存、切面逻辑都会生效这是好事。但如果一个人用SpringUtil拿到了Bean接着在某些地方手动调用了getTargetClass()之类的反射逻辑以为拿到的是原始类就可能出现代理类和原始类不一致的困惑。例如UserService userService SpringUtil.getBean(UserService.class); System.out.println(userService.getClass().getName()); // 输出的是 $ProxyXXX 或 UserServiceImpl$$EnhancerBySpringCGLIB反而能验证AOP生效了。如果你确实需要原始类对象那要借助AopProxyUtils.getSingletonTarget(proxy)工具类嗯这种需求少之又少。我遇到过需要把Bean注册到另一个框架的场景才不得不去解包代理对象。4.5 多容器场景Spring Cloud环境下拿到错误的上下文在Spring Cloud微服务环境中应用可能存在多个ApplicationContext。比如Nacos配置的bootstrap上下文和应用主上下文同时存在SpringUtil持有的通常是主上下文。如果直接从SpringUtil里获取配置项可能拿不到bootstrap.properties里的属性。遇到这类问题先确认配置是放在主配置还是bootstrap配置中必要时单独持有bootstrap上下文或者把要用的配置提前放到主上下文里。这不算SpringUtil本身的坑但用的时候要知道容器可能不止一个。4.6 单元测试如何预置SpringUtil单测里调用SpringUtil也会踩坑。比如某个工具方法的内部逻辑调用了SpringUtil.getBean而单测没有启动完整Spring容器那context就是null。我有两种常用处理方式方式一提供一个公开的静态set方法方便测试预置比如在原工具类里加public static void setApplicationContext(ApplicationContext applicationContext) { SpringUtil.applicationContext applicationContext; }然后在测试的BeforeEach里手动设置一个Mock或简易容器的ApplicationContext。方式二直接用Spring Boot的测试框架加载完整上下文SpringBootTest class UserUtilTest { Test void testGetUsername() { String username SpringUtil.getBean(UserService.class).getUsername(1L); } }但如果只测一个工具类就拉起整个Spring Boot应用启动慢、耦合大一般还是优先采用方式一。我推荐在SpringUtil中始终保留这个公开静态setter它只会带来便利不会引入额外问题。5. 还有哪些拿容器对象的方式以及我的选择5.1 方案对比注入ApplicationContext、ApplicationObjectSupport、ObjectProvider除了SpringUtil项目里拿容器对象还有几种常见途径我做了一张表对比方式适用场景优点缺点Autowired ApplicationContext在Spring管理的Bean内最规范、最直接容器外对象用不了ApplicationObjectSupport内部框架类中Spring内置支持要继承抽象类耦合高ObjectProviderT延迟获取、可选依赖可延迟解析、支持多个Bean仍需要注入容器外不可用SpringUtil静态方法工具类、容器外代码随处可用引用简单绕过注入易被滥用Autowired ApplicationContext是我日常推荐方式。只要这个类本身是Spring管理的Bean直接在构造器或字段上注入ApplicationContext是最清晰、最不容易出问题的。SpringUtil只适合那些“确实没法被Spring管理”的地方。ApplicationObjectSupport是Spring提供的抽象类内部实现了ApplicationContextAware但需要子类继承它。用到它通常是写框架级代码应用代码直接用反而增加继承耦合不推荐。ObjectProvider解决的是“延迟获取Bean”和“容器里可能有多个Bean”的问题。它最适合构造函数注入时的可选依赖比如public class OrderService { private final RedisService redisService; public OrderService(ObjectProviderRedisService provider) { this.redisService provider.getIfAvailable(); } }它在“能不能拿到Bean”“拿哪一个Bean”上提供了更多弹性但同样依赖Spring的注入机制。5.2 为什么我仍然推荐SpringUtil上面列了这么多方式为什么我还是把SpringUtil放在工具类建设的核心位置因为项目真实痛点往往出现在“不是Spring管理的代码”里比如老项目中有大量静态方法调Service的需求改造成Bean注入动辄调整调用链。某些外部框架接管了对象创建Spring注解失效。临时写一个调试脚本、一个工具函数不想为了一个Bean引入复杂依赖。在这些场景里SpringUtil是最低成本的解耦方案。只要容器启动时执行过一次后面所有非托管代码都能顺手拿到任意Bean。我在几个项目里把它配成公共模块里的默认工具类使用成本几乎为零。5.3 最佳实践别把SpringUtil当作常态注入手段但我也要强调SpringUtil绝不能成为常态依赖注入的替代品。项目里如果从头到尾都是SpringUtil.getBean(XXX.class)那IoC容器就退化成了一个“全局Map”代码的可测试性、可维护性都会直线下降。我给自己定的几条规范写在这里供参考能被Spring管理的类一律用构造器注入或Autowired既保证依赖清晰也方便单测时传Mock。只有工具类、静态方法、外部框架回调对象才使用SpringUtil。调用SpringUtil尽量用接口或父类返回避免直接依赖具体实现例如SpringUtil.getBean(MessageHandler.class)而不是SpringUtil.getBean(SmsMessageHandler.class)。对可能为null的返回结果做好空判断尤其是那些由外部框架控制的调用时机。避免在静态代码块中提前使用SpringUtil避免把prototype Bean存入static字段。只要守住这几点SpringUtil就是一把很趁手的工具而不是一个容易被滥用的捷径。在这几个项目里用下来我的实际体会是SpringUtil这类工具的价值不在于技巧多高明而在于它提供了Spring在依赖注入之外的一种兜底能力。每当遇到那些“不在容器内却要使用容器内对象”的尴尬插入点我都会优先想到它但用完也会回头审视一下——这个地方是不是本来设计成Spring管理的更合理。如果你也在维护一个工具类满天飞的旧项目不妨先按上面的方式把这个口子打开再慢慢把业务代码迁回正规注入这是最平滑的路径。
返回列表