Java EE 企业应用 04:CDI 如何创建、选择与销毁对象
一个请求要用哪一份对象
采购系统的用例服务要取得价格目录与存储端口。创建一个 ProcurementUseCases 并传入两个实现,看起来已经解决装配问题;但在 Web 应用中,还要回答目录由谁选、服务活多久、每个请求是否拿到同一份可变数据,以及结束时谁关闭它持有的资源。仅从构造函数的参数类型,回答不了这些问题。尤其不能把“类上有注解”当成“容器已经创建过对象”的证据。
累计工程的真实入口位于 GitLab 仓库路径 examples/javaee-enterprise/application/src/main/java/blog/javaee/application/ProcurementUseCases.java。目前该类使用 @ApplicationScoped、@Inject 构造函数与 @Transactional;FixedPriceCatalog 是唯一的 PriceCatalog 实现。另一个真实装配点是 examples/javaee-enterprise/adapters-jdbc/src/main/java/blog/javaee/jdbc/JdbcRequestStore.java,它用 @ApplicationScoped 实现 RequestStore,并以 @Resource 注入数据源。Web 模块的 examples/javaee-enterprise/webapp/src/main/java/blog/javaee/web/HealthResource.java 提供静态健康入口;examples/javaee-enterprise/webapp/src/main/java/blog/javaee/web/DatabaseCheckResource.java 另有一个已经记录原始响应的 DataSource 只读探针。二者都不调用采购用例,也没有观察 CDI 身份和销毁。下面涉及第二套目录实现、qualifier 和观察接口的代码都是待实施的实验设计,不可按现有工程的行为解读。
本章采用 Jakarta EE 11 Platform、CDI 4.1(Contexts and Dependency Injection,上下文与依赖注入)与 Java 21 教学环境;Java 21 不是 EE 11 的最低要求,平台最低为 Java SE 17。WAR 中的 WEB-INF/beans.xml 是 bean-discovery-mode="annotated"、版本 4.0;这里只能由此解释发现范围,不能据此推断某个请求已触发 bean 创建。application 模块的 EE API 依赖为 provided,服务器实现不打进 WAR。真正的解析及回调需要在与冻结版本匹配的容器中观察。
CDI 选择的是带限定符的类型
在当前工程里,注入点请求的是 PriceCatalog,可用候选为 FixedPriceCatalog,没有第二个可选目录。CDI 对注入点不仅比较 Java 类型,也比较 qualifier(限定符);没有写限定符时按 @Default 处理,所有 bean 还拥有 @Any。若为了不同采购渠道再加入一个同样带 @Default 的实现,PriceCatalog 的注入就会出现歧义。解析发生在部署校验阶段,不应期待请求时由容器随机挑一个,更不能把启动失败说成“自动降级到固定目录”。相反,过滤掉所有候选后是无法满足的依赖,和“多于一个候选”要分别记录。
计划中的修复方案是给两个实现分别加 @FixedCatalog、@PartnerCatalog,并在构造参数上明确标 @FixedCatalog。限定符定义本身要有 @Qualifier、运行时保留以及适用的元素目标;具体 Java 源文件尚不存在。选择哪个价格源应由可信的用例/配置决定,不能直接把客户端传来的租户字符串当成 CDI 候选名。若希望按运行期条件切换,可以显式注入 @Any Instance<PriceCatalog> 再进行有约束的选择;这不是把原来有歧义的 @Default 注入“自动修好”。带成员的 qualifier 还要处理成员匹配,不能只看注解类型名字。
限定符还有一个容易误判的细节:给新候选显式加上专用限定符以后,该候选不再自动成为默认注入点的 @Default 候选。因此在“两个候选都显式标专用限定符”的设计里,原来的构造参数若不改,也可能从歧义变成无法满足,不能只给实现类补注解就宣称修好了。修复应当同时审阅实现与消费点的限定符,并在部署校验中分别测试“缺少候选”和“候选过多”。@Named 提供名字并不自动为业务建立可靠的运行时路由;字符串恰巧与客户端输入相同,反而容易把配置选择误写成权限选择。真正允许哪一套价格生效,仍由服务端的业务条件约束。
另一个机制是 producer(生产者方法):把外部构造规则封装成一个受 CDI 管理的生产者,声明其生产类型、限定符和作用域。它适合需要适配非 bean 类的对象,不适合无缘无故为已有的单一 FixedPriceCatalog 再造一层工厂。一个示意方案是为请求内使用的审计缓冲器提供 @Produces @RequestScoped 生产方法,再用同一类型、同一限定符的 disposer void dispose(@Disposes AuditBuffer buffer) 释放它;AuditBuffer、生产方法与销毁方法均是拟议实现,不在仓库。若生产者返回外部连接,清理回调不能被当作“事务提交”或“连接池归还已经验证”的替代品;用例必须在恰当边界释放使用权。生产者与 disposer 的匹配规则、以及 @Dependent 产物的所属关系,要按 CDI 4.1 分别检查,不能一律套用请求结束即调用 disposer 的口号。
上下文决定身份,不决定线程安全
@ApplicationScoped 是正常作用域(normal scope),注入点通常拿到客户端代理;对代理对象调用方法时才解析当前上下文中的实例。比较注入引用的 ==,测的是代理,不等同于比较真正的上下文实例。应用上下文中的受管实例可被不同请求共同使用,因此真实的 ProcurementUseCases 只保留注入依赖,不在字段上记录“当前 tenantId”“本次采购金额”之类会被并发请求覆盖的状态。FixedPriceCatalog 当前使用静态不可修改的价格映射,适合作为示例,但这不证明所有 @ApplicationScoped bean 天然线程安全。业务租户标识仍须沿方法参数和可信身份上下文传递;后续安全章节才能建立身份来源。
@RequestScoped 的实例在有效请求上下文内复用,请求之间隔离;并非每调用一次方法就创建一次实例。要观察身份,应由受管对象提供稳定的实例标识,例如在 @PostConstruct 之后由实例字段持有随机标识,而不是每次读接口都重新生成。为对照,注入一个 @ApplicationScoped 探针和一个 @RequestScoped 探针,在同一个请求中各调用两次、在两个分开的请求中分别记录两次。预期同一请求的请求探针标识相同,不同请求的请求探针标识不同;应用探针在同一个应用上下文中相同。应用重启或重新部署后应重新观察,不能要求跨部署保持标识。不要仅凭连续两次请求中的对象哈希值证明生命周期,哈希值可能碰撞,也可能出自代理。
@Dependent 则是伪作用域:依赖对象的创建与所属对象、注入点和生产者调用相关,销毁时机也随所属关系变化。@PreDestroy 可以记录作用域对象被容器销毁的时刻;它不是响应已经发送、数据库已提交或 JVM 必定会在非正常终止前执行清理的凭据。销毁实验要分开记录请求结束、应用正常停止,以及强制终止三种条件,不对 kill -9 后能收到回调作断言。请求上下文只在规范规定的活动期间有效;手工新建线程不是继承请求上下文的可靠方法,更不该用 ThreadLocal 存储采购身份后期待异步任务自动清理。
特别要分开观察“被注入的正常作用域对象”与“生产方法返回的对象”。前者有上下文和代理语义,后者由生产者声明的作用域决定生命周期;把生产方法放进 application-scoped 类中,不等于它产生的每个对象自动变成 application-scoped。如果产物维持默认 @Dependent,其销毁与持有它的受管对象相连;若明确声明请求作用域,才能以请求结束来设计这组观测。disposer 也按类型与限定符匹配生产者,不能只写一个同名方法就期待容器调用。记录销毁回调时必须给事件带上产物标识,避免看到一条日志就误认所有请求都已清理。
CDI 的构造注入还约束对象怎样被创建。受管实例由容器解析构造函数、注入依赖并调用生命周期回调;直接执行 new ProcurementUseCases(...) 得到的是普通 Java 对象,即使类型上写了 @ApplicationScoped 和 @Transactional,也不自动变为受管实例。现有类还有一个供代理/框架使用的受保护无参构造器,其中依赖赋值为 null;不能据此把它当作可直接构造的工作用例。需要业务状态时使用方法局部变量和显式参数,不要依赖容器为每个请求自动复制 application-scoped 服务。
实验合同:身份、歧义、并发与销毁
现在可复用的构建入口是 examples/javaee-enterprise/pom.xml;容器配置在 examples/javaee-enterprise/deploy/server.xml。scenarios/00-health.sh 只检验健康接口,不是本章的场景脚本。以下是待添加 /_diag/cdi/identity 诊断接口与探针之后的命令合同,诊断接口须只在隔离实验环境启用,不暴露给正式流量。运行时保存 WAR SHA、服务器版本、诊断端点实现、日志窗口、退出码与两组并发请求的原始响应。具体判据和空白记录见 CDI 身份及失败路径实验合同。
1 | |
构建命令针对真实模块;curl 路径尚无实现,不能按当前仓库执行得到预期的身份报告。实现后应返回两份分别代表同请求两次读取的标识,并对两个请求比较。要做并发测试,至少以屏障保证请求交叠,再让探针在 application-scoped 字段上保存临时租户值,观察串值的反例;该字段只作为受控故障注入,绝不能进入采购主路径。重复 curl 不能证明实际并发交错。正常预期是无共享可变请求状态时彼此不串值;故障预期是刻意放入共享可变状态后可观察串值,且恢复安全实现后再无串值。若调度没有建立交错,结论应记为“未触发”,不是“线程安全已证明”。
歧义实验需要在隔离工作副本临时增加第二个标记 @ApplicationScoped 且没有专用 qualifier 的 PriceCatalog 实现,使其在当前 annotated 发现模式下也成为 @Default 候选,然后重新构建并尝试部署。仅执行 Maven verify 未必运行 CDI 容器校验,所以失败判据是容器拒绝部署、日志指向 PriceCatalog 解析歧义;不能要求某一种服务器输出固定的错误字符串。删去临时改动并加明确 qualifier 后,应能部署且选择预期目录;已选目录的价格必须由服务端返回或在受管调用里核对,不能只看 HTTP 200。销毁探针要记录 @PreDestroy 和 disposer 的调用次数,并按实例 ID 对齐创建与销毁事件。强制杀进程只记录可能缺失的清理事件。所有上述运行项目当前为 NOT_RUN,未附有本章实验的原始响应、部署日志与调用轨迹。
建立失败实验时还有两个隔离条件。首先记录真正部署的 WAR,而不是只记工作目录的源码:若容器仍在运行旧 WAR,看到“无歧义”不能证明新改动没有生效问题。其次在原配置上只改一个变量,记录从启动到失败的时间窗;不要同时换服务器、beans.xml 发现模式和两套实现,否则无法判断是哪项变动改变了候选集合。恢复时重新打包并部署不含故障实现的 WAR,再核对实现列表和价格来源。若容器仍持有旧上下文,须先停止并清理实验实例,不能把缓存的上次响应算进本轮验证。身份、销毁与并发三类观察都要有各自的请求 ID,避免不同请求的日志凑成一条虚假的生命周期。
两道有解的练习
练习一: 某读者在同一个 HTTP 请求里两次注入并调用请求探针,打印的两个代理引用地址一样;随后两次独立请求的代理地址也一样。能否断言请求 bean 被错误地复用了?再说明用什么数据推翻这一判断。
解: 不能。正常作用域的注入引用可能是稳定代理;它的地址不标识当前上下文对象。在探针受管实例创建时保存一次 instanceId,让同一请求调用两次并返回两个 ID,再比较两次独立请求的 ID:同请求相同、跨请求不同才符合预期。记录请求边界及 @PreDestroy;若跨请求相同,先排查是否误将实例字段放入 @ApplicationScoped。即使观察到预期,也只证明这组请求在该容器配置下的行为,不证明异步线程的上下文传播。
练习二: 临时给工程加入第二个 @ApplicationScoped 且没有专用 qualifier 的 PriceCatalog 实现,@Inject ProcurementUseCases(RequestStore, PriceCatalog) 保持不变。写出预期故障位置和修复后的判据。
解: 两个符合 @Default 的候选使第二个参数变成歧义依赖,预期部署校验失败,而不是 /health 请求随机选错价格;mvn verify 成功也不能反证 CDI 没有歧义。分别标记 @FixedCatalog 与 @PartnerCatalog 并在目标注入点指定前者,预期部署成功、固定目录返回的 paper 价格仍是工程中定义的 3.50,未知 SKU 按现有实现抛 IllegalArgumentException。现有未认证演示入口并未采集 CDI 候选和生命周期证据;仍须新增诊断用例或通过后续正式接口观察,状态仍为 NOT_RUN。
边界与资料
这章设计验证对象选择与生命周期,相关实验目前尚未运行;不能把已经成功的数据库探针误当作 CDI 身份测试。05 篇记录一次受管 DataSource 的只读访问,并继续讨论尚无运行证据的会话 bean 入口;12、18–19 篇分别处理 JDBC 释放和事务回滚。现有 @Transactional 注解也不能单独证明数据库变更原子性。证据不足时保留实验缺口,不能用健康检查或数据库探针的 200 填补它。
规范阅读入口:Jakarta EE 11 Platform、CDI 4.1:限定符、依赖解析、生产者与 disposer、上下文、Jakarta Annotations 3.0:生命周期注解。版本化规范陈述的是平台契约;Open Liberty 配置与实际运行证据须另行记录,不能互相替代。






