ARTICLE DETAIL

资讯详情

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

JSR-250注解实践:@Resource与生命周期管理的避坑指南

JSR-250注解实践:@Resource与生命周期管理的避坑指南 JSR-250这个规范说实话在Java社区里存在感不算强。它不像JSR-338那样定义了一套完整的ORM体系也不像JSR-330那样把依赖注入的标准动作搞得人尽皆知。但你要是随手打开一个Java项目的Service类大概率能同时看到Resource、PostConstruct、PreDestroy这几个注解——它们全部出自这个被大多数人忽略的规范JSR-250Common Annotations for the Java Platform。这篇文章我打算以一份实践笔记的方式把JSR-250的核心注解、它们在不同容器里的实际表现、团队规范落地以及从javax到jakarta的迁移这件事一次性讲清楚。适合谁看后端开发、做框架选型的技术负责人、以及正被JDK 11/17升级或Spring Boot 3迁移折腾的朋友。读完你会明白这套注解既不神秘也不复杂但要是用错了坑绝对不小。1. JSR-250 到底管了哪些事1.1 一个典型的“杂货铺型”规范JSR-250的全名是Common Annotations for the Java Platform看到Common这个词你就知道它不是那种定义业务模型的规范而是把Java EE体系里反复出现、大家又都有点“各自为政”的注解统一起来。在J2EE那个年代应用服务器百花齐放有的容器叫Resource有的容器管注入叫EJB同一个语义在不同产品里注解都不一样。Sun牵头搞出JSR-250核心目的只有一个把这些常见的、跟具体业务无关的注解做成标准。标准定了以后EJB 3.0、Servlet 3.0、JAX-RS、JPA、CDI都会参考这套注解Spring虽然没有全盘采纳但也把其中一部分吸收进了自己的容器体系。1.2 规范里的完整注解清单很多人以为JSR-250只有Resource和相关注解实际上一份规范里装了不少东西。我整理了一下大概分四类分类注解用途资源注入Resource、Resources按名称或类型注入外部资源、数据源、服务生命周期PostConstruct、PreDestroy容器完成依赖注入后的初始化和销毁回调安全RolesAllowed、PermitAll、DenyAll、DeclareRoles、RunAs声明接口和方法的访问角色、绕过权限的策略通用Priority、Generated、ManagedBean组件优先级排序、代码生成标记、托管Bean声明表格里的这些注解实际项目里最常见的是前两类第三类在EJB和JAX-RS里很常见第四类的Priority在CDI和拦截器里经常出现。很多开发者在SpringBoot项目里用Spring Security做权限控制完全没接触过RolesAllowed但如果你用原生Jakarta REST或EJB开发这套安全注解就是标准配置。1.3 它和 JSR-330 到底什么关系这里必须多说一句因为很多人会把这两个规范搞混。JSR-330定义了Inject、Named、Singleton核心是“依赖注入标准动作”。JSR-250定义的Resource其实也是一种注入注解但它的语义比Inject更老也更贴近Java EE里“JNDI资源查找”的概念。我的判断是如果你写的是标准Jakarta EE应用Resource更符合容器语义因为它天然支持按名称查找资源如果你写的是纯Spring应用Inject和Autowired用起来更顺手但Resource一样可以用。真正要避免的是同一个项目里两种注入注解混得一塌糊涂这个下文展开。2. 被天天使用的 Resource注入逻辑与常见翻车现场2.1 Resource 的查找顺序和直觉不一样很多从Spring入门的朋友第一次看到Resource会下意识认为它和Autowired差不多按类型注入嘛。这是最大的误解。根据JSR-250规范Resource的查找逻辑是如果显式指定了name属性优先按名称查找没指定name就把字段名或者setter方法名当作资源名称去查找名称找不到才退化为按类型查找类型也匹配不上才抛异常。也就是说Resource的第一直觉是“按名字找资源”而不是“按类型找bean”。这个顺序和Autowired恰好相反后面排查错误注入问题时基本上全是这个差异引起的。Component public class OrderTransferService { Resource(name remoteOrderApi) private OrderApi remoteOrderApi; Resource private LocalOrderDao localOrderDao; }上面这段里remoteOrderApi通过name属性精确指定要容器里名为remoteOrderApi的beanlocalOrderDao没有指定名称就会先拿字段名localOrderDao去找找到就叫注入找不到再按LocalOrderDao这个类型找。2.2 和 Spring 的 Autowired 对比差异比想象的大我做过一个小实验同一个Service一半用Resource一半用AutowiredSpring启动日志里bean解析路径完全不一样。整理成表格感受更直观对比项ResourceAutowired规范来源JSR-250Spring自身查找方式名称优先其次类型类型优先再按名称必填性默认必须注入没有required属性默认必须可用requiredfalse匹配多个实现靠name属性区分靠Qualifier区分支持位置字段、setter方法字段、setter、构造器注意最后一行Resource不能用在构造器上。这也是现在很多新项目默认推构造器注入的原因——Autowired和Inject都能搞构造器注入Resource想搞也搞不了。2.3 Spring 项目里到底要不要用 Resource先给结论可以用但团队必须统一。我的习惯是新代码优先使用构造器注入参数用final修饰写起来干净测试也好mock遇到需要“按容器内的资源名称”注入的场景比如数据源、消息队列连接等明确名称的资源用Resource(name ...)反而比Autowired Qualifier更清晰禁止在同一个类里混用Autowired和Resource否则代码审查的时候别人根本没法一眼判断你到底想表达哪种语义。实际翻车场景我也见过不少有人在Controller里写Resource注入Service看着没问题后面Service改名叫xxxServiceV2字段名没改结果容器里凑巧有一个和字段名同名的bean注入了半天完全不是想要的那个排查到天亮。这类问题没有技术门槛纯粹是对注解语义理解不到位。3. 生命周期三件套PostConstruct/PreDestroy 的时序与误区3.1 执行时序到底谁先谁后PostConstruct和PreDestroy的语义很直白——一个在构造和依赖注入完成后执行一个在容器销毁Bean之前执行。但很多人不知道的是在Spring里它们的执行时机处在整个Bean初始化链路的哪个位置。我画过一张顺序表放在这里比说一百句话都清楚构造器属性/依赖注入populateBeanBeanNameAware、BeanFactoryAware等回调方法BeanPostProcessor.postProcessBeforeInitializationPostConstructInitializingBean.afterPropertiesSet自定义的init-methodBeanPostProcessor.postProcessAfterInitialization注意第5和第6步的顺序网上有些旧文章写反了实际是PostConstruct先于afterPropertiesSet执行。这个顺序决定了你可以把一些“属性注入之后再准备”的逻辑放进PostConstruct它一定比那些实现接口的初始化方法更早跑。3.2 最典型的使用场景统计公司里各种老项目的代码PostConstruct最常见的三类用途启动时加载配置或缓存比如把数据库里的配置项刷到内存Map启动时检测外部依赖比如检查数据库连接池能不能连通、远程服务有没有注册好启动时初始化线程池、定时任务等资源。Component public class CacheWarmer { private final MapString, ConfigItem configCache new ConcurrentHashMap(); PostConstruct public void loadConfig() { ListConfigItem items configRepository.findAll(); for (ConfigItem item : items) { configCache.put(item.getKey(), item); } logger.info(config loaded, size {}, configCache.size()); } }和它配对的是PreDestroy专门做优雅停机。我自己维护过一个消息拉取模块启动时用PostConstruct启动一个ScheduledExecutorService销毁前用PreDestroy把executor优雅关掉给它一个缓冲期处理完队列里的遗留任务否则直接杀掉线程消息会平白无故丢一批。Component public class MessagePoller { private ScheduledExecutorService executor; PostConstruct public void start() { executor Executors.newSingleThreadScheduledExecutor(); executor.scheduleAtFixedRate(this::poll, 0, 10, TimeUnit.SECONDS); } PreDestroy public void stop() { if (executor ! null) { executor.shutdownNow(); } } }3.3 三个高频误区误区一构造器里干完就行了。不行。Spring的依赖注入发生在构造器之后如果你在构造器里调用注入的Bean拿到的一定是null。这个坑在单元测试里特别隐蔽因为Mockito.mock出来的对象不走Spring生命周期构造器里用依赖看起来没问题一上集成环境就崩。误区二父类和子类都写PostConstruct只会执行一个。实际上按JSR-250的语义如果父类和子类各自定义了自己的PostConstruct方法容器会按继承层级先执行父类的再执行子类的。但如果是同一个类里写了两个PostConstruct方法那是编译都过不去的。误区三PreDestroy一定执行。未必。只有Spring容器管理的单例Bean在容器关闭时才会走到销毁回调。prototype作用域的BeanSpring不管它的销毁如果是自己new出来的对象就压根和生命周期机制没关系。4. 冷门成员也有大用Priority 与安全注解家族4.1 Priority一个数字决定谁先谁后Priority注解本身特别简单只有一个int类型的value值越小优先级越高。但它体现了JSR-250里很少被讲透的设计思想用一种去业务化的“大小比较”方式来控制组件排序。在CDI里当接口有多个实现时可以用Priority标记其中一个作为默认选择。在拦截器链里它可以决定拦截器顺序。在观察者模式里它决定事件通知顺序。比如下面这种写法在CDI环境中看到的机会就比较多Priority(100) Alternative public class MockOrderService implements OrderService { // 测试环境用 } Priority(200) public class RealOrderService implements OrderService { // 默认生产实现 }同一个接口两个实现数字小的优先于是测试环境下MockOrderService会被优先选中。这种机制比硬编码if判断要优雅得多也让“优先级”的概念在不同容器里保持一致。4.2 RolesAllowed 其实不止 EJB 能用javax.annotation.security包下躺着一整套安全注解RolesAllowed是出镜率最高的一个。它的作用很直观标注某个类或方法只能被指定角色访问。原生EJB和JAX-RS里用得非常多。Path(/orders) RolesAllowed({ADMIN, MANAGER}) public class OrderResource { GET public Response list() { // 只有管理员和经理可以访问 } }很多Spring开发者不知道的是Spring Security可以开启对JSR-250注解的支持。旧版本用EnableGlobalMethodSecurity(jsr250Enabled true)新版本Security 6之后是EnableMethodSecurity(jsr250Enabled true)。打开之后RolesAllowed、PermitAll、DenyAll这些标准注解就直接可以被Spring Security识别不需要再额外自定义注解。4.3 为什么这些安全注解经常被无视我的理解是大部分团队做接口权限都习惯用Spring Security的PreAuthorize写起来灵活支持SpEL表达式能细粒度控制。相比之下RolesAllowed只能写角色名功能上确实“简陋”。但标准化的好处是可移植性如果你的模块未来要部署到原生Jakarta EE容器或者期望不绑定Spring Security那JSR-250的安全注解就是最稳妥的中间层。有一点需要提醒JSR-250安全注解只有注解还不够必须在框架里开启处理逻辑。在Spring里不开启jsr250Enabled你写一百个RolesAllowed也没效果而且不会报错。这个“安静失效”的特性很坑我下面排查实录里会讲。5. 把 JSR-250 写进团队代码规范5.1 我们团队最终定下的注解使用约定带团队时间长了你会发现代码规范最难的不是写文档而是让每个成员理解“为什么这么规定”。针对JSR-250我在团队规范文档里只写了四条核心约定场景约定普通Service依赖注入统一构造器注入明确需要按名称注入的资源使用Resource(name ...)初始化逻辑PostConstruct 方法名统一用init/initialize资源清理逻辑PreDestroy 方法名统一用shutdown/close为什么普通注入不用Resource前面已经分析过它的名称优先查找语义容易在接口多实现时造成静默注错。为什么特殊场景要保留Resource因为数据源、消息连接这类真正意义上的“资源”按JNDI名称查找本来就是Java EE的标准用法语义完全对得上。5.2 新旧项目混用期怎么治理如果是老项目已经满屏Resource新代码再要求用构造器注入过渡期一定很痛苦。我们的治理办法分三步第一步全量扫描代码库里javax.annotation和jakarta.annotation的引用建立清单按模块分类 第二步在CI流水线里加一条规则禁止新增的类和接口import javax.annotation包下的注解新代码只能走构造器注入 第三步每次迭代顺手清理老模块改完一个模块就提一个独立PRPR描述里标注[annotation]标签方便回滚定位。这里多提一句git提交规范团队里经常有人一口气改一百个文件然后提交信息写“refactor”后面排查问题根本没法追溯。按模块拆分提交每个提交都说明改的是哪个注解、什么原因看起来多花两分钟后面省的是几小时。5.3 规范和联调、测试的关系做测试联调时PostConstruct的初始化顺序经常被人忽略。比如集成测试里启动整个Spring容器某个Bean在PostConstruct里连了外部测试环境的Redis而这个Redis临时挂了测试环境启动就直接失败半天排查不出来。我们后来在测试规范里加了一条所有PostConstruct初始化逻辑里对外部中间件的访问必须做可用性校验和降级处理不能因为缓存服务不可用就直接拖垮整个容器启动。6. javax.annotation 到 jakarta.annotation一次绕不开的大迁移6.1 为什么好好的包名要换Java EE从Oracle捐给Eclipse基金会之后改名成Jakarta EE命名空间也跟着从javax换成jakarta。这事的直接后果是你熟悉的javax.annotation.Resource、javax.annotation.PostConstruct全都不再是“未来”。加上JDK 9开始搞模块化JDK 11直接把这个包从运行时里挪出去了老项目如果没显式引入依赖升完JDK之后第一件事就是ClassNotFoundException。我见过太多团队升级JDK 17时被“javax.annotation不存在”这样的编译错误卡住调整半天才意识到不是代码问题是命名空间整个变了。6.2 依赖坐标的对应关系环境包名Maven依赖典型版本JDK 8 / Spring Boot 2.xjavax.annotationjavax.annotation:javax.annotation-api1.3.2JDK 11 / Spring Boot 3.xjakarta.annotationjakarta.annotation:jakarta.annotation-api2.1.1需要强调的是这张表不只是Spring Boot相关而是所有Java EE / Jakarta EE应用共同的变化。不管你是EJB、Servlet还是JAX-RS项目只要从旧规范往新规范迁移包名替换这一步躲不开。6.3 迁移实操步骤与容易踩的细节第一步全局替换源码里的import也就是把所有javax.annotation替换成jakarta.annotation。这一步可以用IDE的全局搜索替换速度很快但必须小心注释和字符串里的内容最好替换完编译一遍。grep -rn javax.annotation src/main/java第二步去Maven依赖树里把旧的javax.annotation-api揪出来和老版本一起移除。有时它会跟着某个第三方库传递进来光改自己的pom.xml不够要用mvn dependency:tree确认来源。mvn dependency:tree -Dincludesjavax.annotation第三步重点检查那些“只改了包名没改版本号”的第三方库。有些中间件发布了新版本但类名和jar坐标让人眼花缭乱容易混淆。稳妥做法是编译之后写一段冒烟测试调用涉及注解的初始化Bean确认PostConstruct正常触发。有个细节很多人忽略如果只是把包名从javax改成jakarta但项目里某个自有模块还在用javax.annotation-api 1.3.2两个命名空间同时存在运行时Bean的生命周期回调就会分裂。一个类可能因为容器按注解全限定名匹配导致PostConstruct认不出来。迁移要做到“全项目统一命名空间”这个原则比改代码本身还重要。7. 排查实录四个我亲手踩过的 JSR-250 相关的坑7.1 案例一JDK 11 下 ClassNotFoundException现象是项目从JDK 8升到JDK 11后启动直接抛出ClassNotFoundException指向javax.annotation.PostConstruct。原因很典型老项目一直没显式引入javax.annotation-api依赖的是JDK 8运行时自带的那份实现升到JDK 11之后这个包被移除了。解决方案是二选一要么继续留在javax命名空间显式在pom里加上对应的旧依赖要么顺势把代码迁到jakarta.annotation走全新的命名空间。我个人的建议是如果项目还没有迫在眉睫的历史包袱就直接借这个机会迁到jakarta。早迁早干净晚迁只会越来越贵。7.2 案例二Resource 注入到了错误的实现类这个案例很有意思。接口OrderService有两个实现一个叫mainOrderService一个叫orderService。开发者的本意是把mainOrderService注入过来字段名也写了一样的名字private OrderService mainOrderService。看起来没问题但代码里没有写name属性Resource会拿字段名mainOrderService去容器里找找到就注入一切正常。问题出在后来有人做重构把字段名改成了orderService本意是“我要主实现”却忘了容器里真有一个叫orderService的bean于是Spring按名称找到了那个不是主实现的实现注入完全没报错线上逻辑却全错了。这类问题最要命的地方在于所有测试都过了因为按类型匹配时两个实现都满足只有运行起来才能看到怪异行为。排查思路也分享一下先看注入点一行的name属性没有name就看字段名再到Spring容器里查这个名称对应的Bean是谁。找到根因后修复很简单显式写Resource(name mainOrderService)或者干脆改成构造器注入把选择权交给spring和Qualifier。7.3 案例三PostConstruct 静默失效预热数据没加载有次同事跑来跟我说缓存里数据是空的启动日志也没有加载记录。我让他先看类上有没有Component一行果然没有。那是一个定时任务类是某个配置类里头手动new出来的对象。Spring容器不管理它PostConstruct当然不会触发。这种问题Unity测试里也容易踩用Mockito的InjectMocks创建一个待测类以为标了PostConstruct方法会自动执行实际上Mockito根本不处理生命周期注解。如果你需要这种效果还得显式调用一下对应方法或者在Spring的测试上下文里跑集成测试。另一个root cause是方法被修饰成了final。Spring对非final类默认可以使用CGLIB代理而CGLIB生成子类时无法重写final方法PostConstruct回调就“静默丢了”。修法也简单去掉final或者把这类初始化方法挪到一个不会被代理的内部组件上。7.4 案例四RolesAllowed 在 Spring MVC 里完全没生效这个案例最坑因为它不报错效果就是“注解写了个寂寞”。开发环境测试时大家都以为权限生效了实际上谁都能访问。排查思路是先确认Spring Security版本再排查是否开启了jsr250Enabled。旧项目升级到Spring Security 6之后很多人不知道EnableGlobalMethodSecurity已经改名成EnableMethodSecurity参数从jsr250Enabled迁移过来也一样。加上这行配置以后RolesAllowed才真正开始工作。基于这个案例我后来在代码规范里加了一条硬性约定引入任何注解式安全框架必须写一个集成测试来验证“无权限时确实会拦截请求”。权限这种东西不出问题则已一出就是安全事件测试怎么多都不为过。排查这几个坑时我自己最大的体会是JSR-250的注解都不复杂但每一处翻车都指向同一个本质——标准注解的语义在不同容器里有细微差异有的人拿它当Spring注解用有的人拿它当Java自带能力用理解一旦错位问题就会在毫无征兆的地方爆发。与其在面试里背注解用途不如把这些“为什么”和“踩坑瞬间”沉淀到工程规范里让后来的人直接避开。
返回列表