深入 Play 05:Guice 怎样构建对象与配置边界
在 injector 上连续取得两个未标单例的控制器,本批测试得到两个不同对象;向同一条路由发送 16 个并发请求,却观察到同一个控制器引用。这两个结果同时成立:Guice 决定每次注入怎样构造对象,生成 Router 决定已注入的引用如何被后续请求使用。
因此,“控制器没有 Singleton 注解,所以每个 HTTP 请求新建一个控制器”不是可靠结论。需要分别检查对象作用域、持有关系和请求调用方式,再讨论字段能否安全共享。
从接口到构造器依赖图
示例用一个库存接口把查询边界从控制器中抽出:
1 | |
实现从配置读取合成库存,构造时拒绝负数:
1 | |
接口放在 inventory-core 模块,实现放在应用源码中。模块通过 bind(InventoryService.class).to(ConfiguredInventoryService.class) 告诉 Guice 如何满足接口依赖。接口本身不能直接构造,删除这个绑定就会破坏控制器构造链。
控制器构造器显式要求 InventoryService、TraceLog 和 Config。这些依赖是类的真实输入,测试不必通过静态变量修改库存;框架的 Config 绑定与应用的接口绑定共同构成对象图。
本批使用 javax.inject.Inject 和 javax.inject.Singleton,与 3.0.6 生成器和官方示例一致。Java 注入指南 提供对应写法。这个选择不表示任何 Jakarta 包都不存在或永远不能使用;不同 API 和框架版本需要分别核验。
ApplicationLoader 负责引导,Guice 负责构造
Play 应用启动时,加载器在环境与配置上下文中构建应用。采用 Guice 实现时,builder 加载配置和模块,再建立满足组件依赖的对象图。控制器并不是在类路径上出现就能自动绕过全部配置检查。
GuiceApplicationBuilder.applicationModule 首先取初始配置,将 builder 的配置合并在其前面,再依据最终配置加载模块。它还建立日志工厂与其他应用绑定。模块是否加载、接口如何构造、配置值是否存在,是不同的故障位置。
本篇测试使用 new GuiceApplicationBuilder().build(),不自定义 ApplicationLoader。对自定义 loader 或编译期 DI 的应用,不能沿用这次 builder 的全部行为。当前结论明确限定在已固定的 Guice 应用路径。
“启动期验证”也要描述具体对象何时被解析。如果错误对象从未被请求或加载,不能凭一次健康检查宣称全部依赖已验证。当前生成 router 要注入 LabController,该控制器又需要库存服务;此外失败测试还显式要求目标类,确保执行到了声明的构造边界。
未标单例也不等于每个请求创建一次
JUnit 连续调用:
1 | |
未指定 scope 的控制器在两次直接获取中不同;带 Singleton 的库存实现返回相同对象。这说明当前 injector 的直接构造语义,却还没有解释 HTTP 请求复用。
生成的 router.Routes 构造器接收并保存 LabController_0,请求匹配后调用该引用的方法。生成器模板 与当前编译产物都能看到这种持有关系。Router 不会因为控制器未标单例就对每个匹配请求重新调用 injector。
真实 HTTP 实验中,/scope 返回控制器和服务的 System.identityHashCode。16 个并发请求使用同一条已生成的路由,样本只出现一个控制器标识与一个服务标识。identity hash 本身存在碰撞可能,所以实例观测必须结合生成代码和直接引用断言,不能把数字相同当作对象恒等的通用证明。
这是当前普通注入路由的结论,不能推广到 provider 路由、自定义 router、重新加载之后的应用实例或其他 DI 实现。开发重新加载可能重建对象图;单例也通常相对于其 injector 生命周期,不是整个机器永久只有一个对象。
共享实例为什么不提供并发保护
库存实现的字段是构造后不再变化的 int,因此本批并发读取没有竞争写入。Singleton 只描述实例复用,未给方法自动加锁;若改成可变库存并进行检查再扣减,多个请求仍可能同时读到旧值。
同样,控制器即使没标单例,也不能把当前请求保存到可变字段并假定下一请求看不到。Router 持有的控制器可能被多个请求并发调用。请求 id、身份和请求体应作为方法参数或请求属性传递,字段适于保存明确线程安全的依赖。
TraceLog 使用并发队列避免有限样本写入冲突,但队列无界,读取按 id 过滤也是线性扫描。它只服务于回环接口的阶段实验,不能直接成为生产日志缓存。服务线程安全和观测容量是两项独立要求,换成 Singleton 注解不会消除任一项。
配置优先级必须说明加载入口
生产实验中 application.conf 有 lab.stock=7,启动参数指定 -Dlab.stock=13,真实 /config 返回 13。这个结果验证该生产加载入口的系统属性覆盖,而不是“任何配置来源都采用完全相同优先级”。
Play Configuration.load 处理显式属性、直接设置、应用配置与 Play reference overrides,再交给 ConfigFactory 解析完整配置。依赖的 reference.conf 提供默认值;应用和更高优先级的显式设置可以覆盖它。环境变量只有通过配置替换或显式加载进入应用,不能因为 shell 中存在某个同名变量就假定自动覆盖。
签名密钥使用:
1 | |
这里第二行显式引入环境变量。实验脚本每次生成随机值,不提交真实部署密钥;可选替换未提供时保留本地实验默认值。是否必需、有没有 fallback,以及最终类型如何解析,都应依据实际配置定义说明。
测试的 GuiceApplicationBuilder.configure("lab.stock", 11) 则加入 builder 自己的配置层。configuration.withFallback(initialConfiguration) 说明这一层在初始配置之前,不能把它与普通 application.conf 的位置混同。配置经过读取后构造库存对象,本批没有热重读或动态刷新承诺。
替换依赖和覆盖配置是两种实验
测试构造假的库存实现,并用 overrides 替换模块绑定:
1 | |
断言 injector 返回同一个 fake,Config 返回 11,而查询订单 JSON 中库存为 99。这说明配置覆盖与绑定替换分别生效:替换后查询使用 fake,不会因为配置改成 11 就重新调用已被替换的实现。
overrides 是测试应用图的明确覆盖方式;普通重复 bindings 不应当作必然覆盖机制。Java GuiceBuilder API 分别提供对应入口,选择时要保留它们的语义区别。
测试结束在 finally 中调用 stop(replacement),释放该应用拥有的资源。创建了新 Application 就有新的生命周期,不能仅因为测试方法返回便认定 actor system、线程或其他组件自动不存在。本批没有连接池,停止行为还不能替代数据库资源释放测试。
四类失败怎样对应修复位置
失败测试分别建立缺失配置、非法配置、缺失接口绑定与循环依赖的应用图,并要求目标类解析。缺失配置通过初始加载配置去掉 lab.stock;负数则提供了 key 和类型,但构造器主动拒绝其领域含义。
| 变化 | 实际失败边界 | 应检查的位置 |
|---|---|---|
| 去掉 lab.stock | Config 读取缺失 key | 配置加载与键名 |
| stock=-1 | 库存构造器校验 | 值的允许范围 |
| disable(Module.class) | 接口没有实现绑定 | 模块加载与绑定声明 |
| CycleA 依赖 CycleB,反向又依赖 CycleA | 当前 Guice 循环构造拒绝 | 对象依赖图 |
这些反例没有临时添加静态服务来“绕过”失败。循环依赖尤其需要检查责任方向:两个服务相互要求完成构造,可能意味着共同逻辑应移到明确第三方,或某个依赖应在操作边界传入。Provider 有时可以改变解析时间,但推迟问题不能证明对象图设计合理。
当前循环样例使用两个 concrete class,构造器相互注入,实际被拒绝;不能扩大为“Guice 在所有配置中永远不能处理任何循环”。启动异常的包装信息也可能比根因更长,排查应保留原始日志,并确认测试失败发生在所声明对象上。
在 play-lab/ 中执行 bash sbtw test,可重跑上述断言;执行 python3 lab/verify.py,可核验系统属性覆盖和并发路由实例。当前批次到此得到一个具备固定工具链、真实 HTTP、类型化路由和可替换依赖的起点。后续 Action 组合才能在这个已验证的对象图上研究鉴权与审计。
参考资料与继续阅读
参考固定版本的 Java DI 指南、GuiceApplicationBuilder 和 Configuration。完整测试在 累计工程 中。
上一篇:Request 与 Result。下一批从 Action 组合开始,尚未发布的章节不生成文章链接。
