ARTICLE DETAIL

资讯详情

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

Spring ApplicationContext 四大实用功能:环境、事件、国际化与资源加载

Spring ApplicationContext 四大实用功能:环境、事件、国际化与资源加载 先问一个问题你在项目里用过ApplicationContext还是只见过Autowired和Component如果答案是后者那你可能错过了一个非常顺手的工具类。ApplicationContext在 Spring 里的地位可以说是“中央枢纽”很多人理解它就是一个“大号 BeanFactory”——能创建 Bean、能管理 Bean 生命周期。但实际上它远不止如此。翻翻它的接口继承关系就会发现它同时继承了EnvironmentCapable、MessageSource、ApplicationEventPublisher、ResourcePatternResolver这些看起来很“散装”的接口。这背后其实是 Spring 的一个设计思路把应用运行所需的基础能力全部集中到一个门面上。这篇博文就围绕ApplicationContext的四个小功能展开环境信息访问、事件发布、国际化消息、资源加载。这四块平时被提及的频率不高但每一个都在源码和项目中被大量使用。我会把原理、实操、踩坑一起讲清楚适合正在学 Spring 原理的读者也适合那些“会用注解但说不清 ApplicationContext 到底干了什么”的开发者。1. ApplicationContext 到底是什么接口图景与设计思路1.1 接口继承关系令人头大但拆开就不难第一次看ApplicationContext的继承结构很多人都会被吓到。它上面挂着好几位“长辈”public interface ApplicationContext extends EnvironmentCapable, ListableBeanFactory, HierarchicalBeanFactory, MessageSource, ApplicationEventPublisher, ResourcePatternResolver { // ... }这六个接口分两类来看就清晰了Bean 管理相关ListableBeanFactory、HierarchicalBeanFactory。这是ApplicationContext作为容器的基础职责负责 Bean 的获取、列举、层级查找。这部分大多数人比较熟。应用运行相关EnvironmentCapable、MessageSource、ApplicationEventPublisher、ResourcePatternResolver。这四个就是我们这次要聊的“小功能”分别对应环境属性、国际化、事件发布、资源加载。也就是说ApplicationContext不只是一个 Bean 容器它把“程序跑起来之后你需要的大部分基础设施”都包进来了。你可以不通过ApplicationContext单独去 new 一个PropertySourceResolver也不需要单独 new 一个MessageSource——容器本身就是这些东西的实现。1.2 为什么 Spring 要把这些功能塞进同一个接口从使用者的角度这带来最大的好处就是你只要拿到一个ApplicationContext就等于同时拿到了配置访问能力、事件中心、消息源、资源定位器。这比到处注入各种不同的组件要省心太多。从设计者的角度这其实是门面模式Facade的典型应用。Spring 想让开发者面对一个统一入口而不是散落的一堆工具类。你在ApplicationContext接口里看到的方法并不多但每一个方法背后都对应一个完整的能力链路getEnvironment()→ 返回ConfigurableEnvironment背后是PropertySources的层级搜索publishEvent(ApplicationEvent)→ 事件经过ApplicationEventMulticaster分发到监听器getMessage(String, Object[], Locale)→ 委托给容器内的MessageSourceBeangetResource(String)→ 返回Resource对象内部通过ResourceLoader实现理解了这个整体设计后面逐个拆解四个功能时你就不会觉得它们是孤立的而是refresh()方法在启动过程中一步一步组装出来的子模块。2. 功能一Environment 环境信息访问2.1 getEnvironment() 到底是什么Environment是 Spring 3.1 引入的属性访问抽象它的核心职责是解决一个问题运行时配置从哪里来、优先级怎么样。我们平时最常用的是Value(${xxx})注解和ConfigurationProperties它们底层其实都是在读取Environment里的属性。ApplicationContext.getEnvironment()返回的是ConfigurableEnvironment接口这个接口往下还有StandardEnvironmentWeb 环境对应StandardServletEnvironment。它内部维护了一个MutablePropertySources对象这个对象是一个有序的PropertySource列表。想理解它可以把每个PropertySource想象成一张Map优先级从高到低PropertySource 名称内容示例1systemPropertiesSystem.getProperties()即-D参数设置的 JVM 属性2systemEnvironmentSystem.getenv()即操作系统环境变量3application.yml / application.properties传统配置文件4自定义 PropertySource比如 Apollo、Nacos 配置中心注入的源查找属性时Environment是从前往后遍历第一个找到的就返回。所以同样一个 key-D参数里的值会覆盖配置文件里的值。这个顺序在生产环境里非常有用比如紧急修改日志级别、端口号时可以直接用 JVM 参数覆盖配置而不需要重新打包。2.2 一次动态读取配置的实操很多人在运行时需要读配置时习惯用Value注入到一个字段。但Value有两个问题值在 Bean 实例化时才能注入如果你是动态构造的临时对象注入不了。Value读的是固定 key想“根据运行时参数决定读哪个配置”会很别扭。这时候直接用ApplicationContext.getEnvironment()就非常顺手Service public class DynamicConfigService { private final ApplicationContext context; public DynamicConfigService(ApplicationContext context) { this.context context; } public String getConfigByEnv(String key, String defaultValue) { Environment env context.getEnvironment(); String value env.getProperty(key); return value ! null ? value : defaultValue; } }还可以拿到requiredProperties的集合或者判断某个配置是否存在Environment env context.getEnvironment(); // 判断某个 profile 是否激活 boolean isProd env.acceptsProfiles(Profiles.of(prod)); // 获取配置集合 String[] activeProfiles env.getActiveProfiles();这里有个容易被忽略的小技巧getProperty方法支持类型转换比如env.getProperty(server.port, Integer.class)它会调用 Spring 的类型转换服务ConversionService把字符串转成目标类型不需要自己Integer.parseInt。2.3 注意属性源的优先级这个功能用起来简单但坑也不少。我踩过的第一个坑就是自定义属性源的顺序没放对导致配置中心的值覆盖不了本地配置。Spring 允许你往Environment里塞自定义的PropertySourceConfigurableEnvironment env (ConfigurableEnvironment) context.getEnvironment(); MapString, Object sourceMap Map.of(my.source.version, 1.0); env.getPropertySources().addFirst(new MapPropertySource(mySource, sourceMap));addFirst表示加到最前面也就是优先级最高。如果你用的是addLast那你的源排在系统属性后面很可能被同名配置覆盖掉。配置中心集成里大量的 bug 就出在这个顺序选择上。另一个常见问题是Environment是启动时快照不是实时变化。它读取的PropertySource大多数是一次性加载的配置中心推送更新时需要依赖配置中心 SDK 自己刷新PropertySource不是改了数据库配置Environment就自动变了。理解了这一点你在排查“为什么配置改了没生效”时就能少走弯路。3. 功能二ApplicationEventPublisher 事件发布3.1 事件机制的几个关键对象Spring 的事件机制由三部分构成事件ApplicationEvent、发布器ApplicationEventPublisher、监听器ApplicationListener或EventListener。ApplicationContext直接继承并且实现了ApplicationEventPublisher所以你在任何一个 Bean 里注入ApplicationContext就能直接调用context.publishEvent(event)当然更规范的做法是注入ApplicationEventPublisher接口让代码不依赖完整的ApplicationContextComponent public class OrderService { private final ApplicationEventPublisher publisher; public OrderService(ApplicationEventPublisher publisher) { this.publisher publisher; } public void createOrder(Order order) { // 业务逻辑 publisher.publishEvent(new OrderCreatedEvent(order)); } }事件对象在这里要继承ApplicationEvent或者实现ApplicationEvent标记接口。Spring 4.2 之后事件可以不继承任何类只要是一个普通 POJOpublishEvent会自动包装成PayloadApplicationEvent。3.2 从发布到监听同步流程走一遍默认情况下事件是同步发布的。这个流程可以拆成四步调用publishEvent。ApplicationContext内部找到applicationEventMulticasterBean。多播器根据事件类型匹配监听器集合。逐个调用监听器的onApplicationEvent方法。核心代码如下简化自AbstractApplicationContext// AbstractApplicationContext.java protected void publishEvent(Object event, ResolvableType eventType) { // ... getApplicationEventMulticaster().multicastEvent(applicationEvent, eventType); // ... }而SimpleApplicationEventMulticaster的默认实现是直接同步调用// SimpleApplicationEventMulticaster.java public void multicastEvent(ApplicationEvent event, ResolvableType eventType) { ResolvableType type (eventType ! null ? eventType : resolveDefaultEventType(event)); for (ApplicationListener? listener : getApplicationListeners(event, type)) { invokeListener(listener, event); } }监听器这边推荐直接使用EventListener注解Component public class OrderEventListener { EventListener public void onOrderCreated(OrderCreatedEvent event) { // 发短信、记录日志、更新统计 System.out.println(收到订单创建事件 event.getOrderId()); } }如果你需要在一个监听器听多个事件或者按条件过滤EventListener(condition #event.orderId.startsWith(RM)) public void onRushOrder(OrderCreatedEvent event) { // 只处理秒杀单 }3.3 让人忽视的坑同步事件阻塞上面说了默认是同步的这意味着监听器的执行时长会直接卡住发布事件的线程。如果你的监听器里有重操作——调用外部接口、写文件、发邮件——那么业务线程会一直阻塞到所有监听器执行完。我遇到过最典型的一次事故订单创建接口平均响应时间从 50ms 涨到 800ms排查后发现问题出在一个同步事件监听器里调用了第三方短信服务对方接口超时 5 秒于是每个下单请求都被拖住了。处理方案有三种给EventListener方法加Async让监听器异步执行。注意前提是项目已经开启了EnableAsync。使用TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT)把事件推迟到事务提交之后避免事务还没结束就执行监听逻辑操作数据库也顺带避免了大事务问题。换用消息队列如 RabbitMQ、Kafka承担跨系统的通知Spring 事件只负责进程内解耦。另外要说清楚Spring 事件默认只在本进程内生效它不是跨服务的消息总线。做微服务架构时如果试图用ApplicationEventPublisher实现两个服务之间的通信那是行不通的——两个独立 JVM 有各自的 ApplicationContext事件不会跨 JVM 传播。4. 功能三MessageSource 国际化消息4.1 系统里已经给你备好了 MessageSource很多项目里MessageSource是个“听说过但没用过”的接口。它的作用简单来说就是根据 key 和 Locale找到对应的文案。传统项目中典型的用法是把提示信息从代码里抽出来放到messages.properties、messages_zh_CN.properties、messages_en_US.properties里然后运行时根据用户的语言偏好自动选择文案。关键点在于ApplicationContext自己就实现了MessageSource接口。也就是说任何一个ApplicationContext实现类在refresh()的过程中都会初始化一个MessageSource。如果你在配置里注册了名为messageSource的 BeanSpring 会使用你的自定义实现如果没有注册它会兜底创建一个DelegatingMessageSource。// AbstractApplicationContext.java protected void initMessageSource() { ConfigurableListableBeanFactory beanFactory getBeanFactory(); if (beanFactory.containsBean(MESSAGE_SOURCE_BEAN_NAME)) { this.messageSource beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, MessageSource.class); // ... } else { // 使用兜底的 DelegatingMessageSource this.messageSource new DelegatingMessageSource(); } }这解释了为什么你在ApplicationContext里可以直接调用getMessage——它早就准备好了一个消息源实例。4.2 实操配置 properties 文件要启用国际化文案先配置一个MessageSourceBean。最常用的是ReloadableResourceBundleMessageSource因为它支持classpath:前缀并且可以配置缓存时间和编码Configuration public class MessageConfig { Bean public MessageSource messageSource() { ReloadableResourceBundleMessageSource messageSource new ReloadableResourceBundleMessageSource(); messageSource.setBasenames( classpath:i18n/messages, classpath:i18n/validation ); messageSource.setDefaultEncoding(UTF-8); messageSource.setCacheSeconds(60); return messageSource; } }对应的 properties 文件放在src/main/resources/i18n/下messages.properties默认文案messages_zh_CN.properties中文文案messages_en_US.properties英文文案内容格式是标准的keyvalueorder.createdOrder {0} has been created successfully. order.failedOrder creation failed, reason: {0}然后在业务代码里调用ApplicationContext.getMessageString message context.getMessage(order.created, new Object[]{orderId}, Locale.ENGLISH);{0}、{1}占位符会被Object[]里的参数依次替换。这个能力来自java.text.MessageFormatSpring 只是套了一层 key 查找而已。如果你用的是 Spring MVC还可以通过MessageSource自动处理表单校验错误消息比如字段注解里的message {order.notnull}框架会自动把占位符翻译成用户语言。4.3 编码问题是最常见的坑这个坑几乎人人都踩过properties 文件里的中文变成乱码。根源在于Properties类默认使用 ISO-8859-1 编码读取文件中文在 ISO-8859-1 下无法正确表示。解决办法在我上面给的配置里已经做了——setDefaultEncoding(UTF-8)但注意属性名不是setEncoding是setDefaultEncoding。还有一种方式是使用ResourceBundleMessageSource不过它对编码的控制能力弱一些我建议统一用ReloadableResourceBundleMessageSource。另外还有一个小细节把 properties 文件放在 JAR 包里时ReloadableResourceBundleMessageSource是能读到的它的classpath:前缀针对类路径下的资源做了专门处理和普通的FileSystemResource不同。如果遇到“开发环境能读、打成 JAR 后报错”的情况多半是基础路径写错了检查一下setBasenames的前缀是不是classpath:生产打包后资源在 classpath 里不要用相对路径。5. 功能四ResourceLoader 与 ResourcePatternResolver 资源加载5.1 前缀即协议classpath、file、url 一网打尽ApplicationContext实现的第四个“小功能”是资源加载。它继承的是ResourcePatternResolver而ResourcePatternResolver又继承自ResourceLoader。所以你可以直接调用Resource resource context.getResource(classpath:application.yml);getResource(String)方法会根据你传入的前缀决定创建哪种Resource实现前缀Resource 实现典型场景classpath:ClassPathResource读取类路径下的文件file:FileSystemResource读取服务器文件系统路径https://UrlResource读取远程资源无前缀取决于 ApplicationContext 类型底层实现不同结果不可控最后一行值得单独讲一下不带前缀的路径行为取决于 ApplicationContext 的实现类。ClassPathXmlApplicationContext会当作classpath:处理FileSystemXmlApplicationContext会当作file:处理。为了让代码行为可预期写getResource时强烈建议显式加前缀。5.2 classpath*: 与通配符匹配的坑ResourcePatternResolver比ResourceLoader多出来的能力是Ant 风格的路径通配符Resource[] resources context.getResources(classpath*:META-INF/spring.factories);注意这里的classpath*:能和classpath:区分开classpath:只会搜索第一个匹配的类路径位置如果同名文件在多个 JAR 里都有只返回一个。classpath*:搜索所有类路径位置包括所有 JAR 包返回全部匹配。当你想扫描所有依赖 JAR 中的某个固定路径资源时必须用classpath*:。Spring Boot 加载spring.factories和spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports时底层就是靠这个能力来实现的。通配符也支持在路径中使用Resource[] resources context.getResources(classpath*:config/*.properties); Resource[] modules context.getResources(classpath*:modules/**/module-info.txt);这里**表示多级目录*表示单级目录或者文件名片段。匹配逻辑主要看在路径里有没有*或?Spring 会调用PathMatchingResourcePatternResolver来处理。这个功能的典型应用场景是自己写框架或者封装 starter 时约定业务方在META-INF/下放一个特定文件框架启动时用getResources(classpath*:META-INF/my-starter/*.properties)把所有约定文件扫进来。5.3 一个场景扫描 META-INF 配置举个亲自做过的例子。我之前给团队封装过一个内部组件需要把所有模块里定义的“数据迁移脚本”清单找出来。当时用的就是ResourcePatternResolverComponent public class MigrationScriptScanner { private final ResourcePatternResolver resolver; public MigrationScriptScanner(ApplicationContext context) { this.resolver context; } public ListString scanScriptPaths() throws IOException { Resource[] resources resolver.getResources(classpath*:db/migration/*.sql); ListString paths new ArrayList(); for (Resource resource : resources) { paths.add(resource.getFilename()); } return paths; } }这里把ApplicationContext直接赋给ResourcePatternResolver类型的变量就是前面说的“门面模式”的体现。运行时会发现多个 JAR 包里的db/migration/目录都能被扫到因为用了classpath*:。如果改成classpath:那就只会找到第一个 JAR 里的脚本——这往往不是你想要的结果。6. 常见问题与排查技巧实录6.1 问题速查表把这四块功能里最常见的坑整理成一张表方便以后排查时直接对照问题现象可能原因解决方向读取配置拿到的值不是最新推送值混淆了Environment快照和配置中心的实时推送确认配置中心 SDK 是否主动刷新PropertySource自定义PropertySource永远覆盖不了默认配置源添加顺序不对放在了addLast改用addFirst或者精确控制插入位置订单接口变慢日志显示监听器执行耗时长同步事件触发重操作监听器加Async或改用TransactionalEventListener事件监听器执行后事务回滚数据处理异常监听器在事务提交前执行使用AFTER_COMMIT阶段properties 中文乱码文件编码和解析编码不一致设置setDefaultEncoding(UTF-8)打成 JAR 包后getResource找不到文件基础路径写的是相对路径前缀加classpath:多个 JAR 同路径文件只扫描到第一个用了classpath:而不是classpath*:改成classpath*:6.2 调试技巧从 refresh() 看这四个功能如何被初始化如果你真想彻底弄懂ApplicationContext我建议你直接打开AbstractApplicationContext.refresh()方法从头读一遍。这个方法被 Spring 官方称为“模板方法”它的调用顺序就对应了我们前面拆解的几个功能// AbstractApplicationContext.refresh() 关键步骤 prepareRefresh(); // 1. 准备 Environment记录启动时间 obtainFreshBeanFactory(); // 2. 创建 BeanFactory加载 Bean 定义 prepareBeanFactory(); // 3. 往容器里塞工具如 Environment、ResourceLoader initMessageSource(); // 4. 初始化 MessageSource initApplicationEventMulticaster(); // 5. 初始化事件多播器 onRefresh(); // 6. 子类扩展点Web 容器在这里初始化主题 registerListeners(); // 7. 注册监听器到多播器 finishBeanFactoryInitialization(); // 8. 实例化所有非懒加载单例 Bean finishRefresh(); // 9. 发布 ContextRefreshedEvent对照这个顺序你就理解了为什么Environment在第一步就准备好了——因为后面实例化 Bean、解析Value都需要读配置为什么MessageSource在第六步之前初始化——因为 Bean 实例化阶段需要注入MessageSource相关的功能。实际调试时你可以在这个方法里打断点配合 Debug 看beanFactory.getBean的过程。那一层层包装的ApplicationContext实现类看起来复杂但核心逻辑都浓缩在这十几个方法里读两遍基本就能在脑子里画出一条完整的启动链路。结尾我个人在项目里用得最多的是Environment和事件发布这两个一个解决运行态动态取配置的问题一个解决业务解耦后的通知问题。它们给我的直接感受是Spring 的优雅更多体现在“接口组合”上而不是单个类的复杂度。ApplicationContext之所以能成为中央枢纽恰恰因为它在BeanFactory之上挂了这么多能力而你在使用时几乎不需要关心内部有多复杂。最后分享一个小技巧写代码的时候别一上来就注入ApplicationContext整个对象尽量面向接口注入——需要配置就注入Environment需要发事件就注入ApplicationEventPublisher需要读消息就注入MessageSource需要扫资源就注入ResourcePatternResolver。这样你的组件依赖面更窄、更容易测试也更容易被其他人读懂意图。这四个小功能背后是对“窄接口、广能力”的一种诠释用好了代码自然干净很多。
返回列表