深入 Play E02:编译期依赖注入,显式组件图能检查什么
把 HelloController 的 Message 参数漏掉,编译器能够立即指出构造器参数不匹配。把运行时配置 lab.greeting 改成空字符串,同一份已编译产物仍会在启动时失败。编译期依赖注入将一部分装配关系变成 Java 表达式的类型约束,配置值、资源状态和业务协议仍需要运行验证。
Play 提供的组件接口允许应用自行构造 Controller、Router、过滤器和生命周期依赖。本章用没有 Guice 运行时构件的独立工程验证这种装配,再以相同业务源码的 Guice 工程作有限对照。是否使用运行时容器与是否经过完整生产启动,是两个可以分别检查的问题。
从加载入口到组件图
下载两套依赖注入工程与实验记录,核对SHA256SUMS。解压后的 play-electives/e02-compile-di 包含manual与Guice应用;接入后重跑2+1项JUnit、两套stage及10个生产/失败场景,见 evidence/e02/shared/delivery.json。启动耗时保留为本机观测,不能推导框架普遍快慢。
实验固定 Play 3.0.6、Scala 2.13.15、sbt 1.10.7、JDK 21.0.11。Play 源码提交为 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。两个工程均使用 Jakarta Inject 2.0.1 注解 API;Guice 对照额外使用实际解析的 Guice 6.0.0。注解 API 只提供类型,手工调用构造器时不会因为参数上存在注解就自动执行容器装配。
生产启动先从配置读取 play.application.loader,再加载 ApplicationLoader。本实验将入口写成 public 顶层类 lab.ManualLoader,避免把需要外部实例的非静态内部类误用为框架可独立构造的入口。Context 提供环境、初始配置与应用生命周期等构建上下文,组件图由应用代码创建。
flowchart TD
A[play.application.loader] --> B[ManualLoader]
B --> C[Components / Context]
C --> D[Config + ApplicationLifecycle]
D --> E[Greeting implements Message]
E --> F[HelloController]
F --> G[生成的 router.Routes]
C --> H[默认过滤器 + HeaderFilter]
G --> I[Application]
H --> I
I --> J[生产 HTTP 服务器]
官方编译期 DI 文档把手工调用构造器作为基础机制,Components 接口是减少装配代码的辅助。这个入口仍然通过运行时类加载进入系统,编译期 DI 不等于整个应用没有反射。
日志也有明确的初始化责任。BuiltInComponentsFromContext 不替应用执行 LoggerConfigurator,因此本实验入口在创建应用前显式初始化日志配置。下面是实际生产启动和独立 javac 验证使用的完整类。
1 | |
配置文件只负责选择入口和提供业务值;手工工程的 build.sbt 不加入 guice。最终 stage 的 67 个 JAR 中没有 com.google.inject.guice-* 或 org.playframework.play-guice_*,对照工程的 71 个 JAR 中存在这两项。验证按具体 Maven 构件名前缀判断,不能因为应用自己的名字含有 guice 就认定容器存在。
类型约束落在实际构造表达式上
Components 将服务保存为字段,再将同一个引用交给 Controller。生成路由接收 Controller 实例与 Scala HttpErrorHandler,构造参数改变时,这段显式调用也要通过编译。服务替换通过另一个构造参数传入,正常启动路径传入空值表示使用真实服务。
1 | |
这里的服务复用来自 final 字段持有同一对象,不依赖 @Singleton 注解被解释。若把 new Greeting(...) 放在每次请求都会调用的方法中,同样可以通过编译,却会创建多个实例。因此“构造器参数正确”和“对象作用域正确”必须分别检查。JUnit 使用 assertSame 检查组件返回同一个服务引用,并通过真实 Router 验证它产生预期响应。
固定负例 negative/MissingDependency.java 调用无参 new HelloController()。该文件位于两个应用的编译源目录之外,验证驱动单独调用 JDK 21 javac,要求退出非零,并保留真实错误:
1 | |
这对应 JLS 15.9.3的构造器选择约束。省略实参无法匹配所需构造器,错误发生在程序运行前。反之,传入类型正确但业务含义错误的 Message,或者传入 null,不能靠这个规则自动拒绝。编译期类型检查不会验证所有业务不变量。
循环关系也不能只凭“手工装配”四个字判定消失。构造器要求互相先存在时,直接装配会暴露结构困难;若代码改用可变字段、供应器或延迟查找绕开构造顺序,仍可能在运行时形成循环。对必须同生命周期存在的资源,更稳妥的做法是重新检查依赖方向,而不是让测试只覆盖图能够被创建。
过滤器不会因为采用组件接口就可以省略
HttpFiltersComponents 提供默认过滤器列表,其固定版本实现包含 IP、CSRF、安全响应头和 Allowed Hosts。实验保留这组默认值,再加入自定义 HeaderFilter;直接返回仅含自定义过滤器的列表会失去原来的过滤边界。接口源码可以用于核对实际顺序。
HTTP 验证要求正常请求返回 hello:prod,同时存在自定义 X-Graph: explicit 和默认 X-Content-Type-Options: nosniff。再将 Host 改为 untrusted.invalid,要求返回 400。Controller 能被直接调用,只证明业务方法可执行;这组真实请求还验证应用安装了过滤器以及后端确实执行了它们。
本章没有通过一个 GET 请求就声称 CSRF 已完成全面验收。CSRF 默认组件被装配是源码事实,Cookie、表单 token 与 CORS 的交互仍由第 27 篇的专门矩阵验证。向这份小工程增加认证 Action 或自定义 BodyParser 时,也需要检查对应对象的构造方式,不能假定应用存在完整的 Guice Injector。
固定版本 ContextBasedBuiltInComponents在创建 DefaultApplication 时使用 SimpleInjector 和 NewInstanceInjector。本章业务图由 Components 的构造表达式建立;框架保留的 injector 接口不能被解读成所有运行时注入扩展都与 Guice 行为相同。
生命周期与测试替换
Greeting 接收 ApplicationLifecycle,注册停止钩子。应用停止时设置可观测的 closed 标志,并在配置指定的文件中追加一行 greeting-closed。该标记证明钩子接入了真实停止流程,不代表连接池、HTTP 客户端或线程池都已经得到测试;这个最小服务没有创建这些资源。
sequenceDiagram
participant T as 测试或生产启动
participant C as Components
participant G as Greeting
participant L as ApplicationLifecycle
T->>C: Context / 可选替换服务
C->>G: new Greeting(config, lifecycle)
G->>L: 注册 stop hook
T->>C: application / 路由请求
C-->>T: hello:prod
T->>L: 应用停止 / SIGTERM
L->>G: 执行 hook
G-->>T: closed=true / journal 一行
第一个 JUnit 创建真实 Components,验证 Router 的响应、自定义过滤器、服务引用和停止后的 closed=true。第二个测试直接提供 () -> "test-double",要求完整应用路由返回 hello:test-double。替换发生在构造应用图时,测试不需要先启动容器再覆盖绑定,也没有改写生产配置来碰巧选中替身。
替换对象的生命周期属于它自身的契约。此处 lambda 没有外部资源,所以不需要关闭动作;若替换对象持有线程、文件或数据库连接,测试仍须提供对应清理。手工装配增加了所有权的可见性,但不会自动补上遗漏的停止钩子。
真实网络驱动又分别启动两个工程的 stage 进程。每次先检查响应和过滤器,再发送 SIGTERM,要求退出码 143、监听端口关闭、日志文件恰好包含一行 greeting-closed。这些观察将应用内停止测试与完整 JVM 停止连接起来,二者都保留独立记录。
已编译产物仍然可能启动失败
Greeting 构造器读取 lab.greeting 并拒绝空白值。负例不修改任何源文件,而是用 -Dlab.greeting= 启动已经通过编译的 manual 产物。进程实际退出码为 255,错误链包含 nonempty lab.greeting required,端口没有保持监听。
这个失败位于配置值验证阶段。Java 编译器能够确认 Config 对象被传入,却不知道部署环境会传来什么字符串。相同边界还包括文件路径是否可写、数据库是否可连接、密钥是否满足要求,以及反射选择的 loader 类名是否正确。
对于昂贵的组件,初始化失败还有清理顺序问题:先打开资源 A,再创建资源 B,若 B 失败而应用尚未完成启动,不能假定正常关闭流程一定替构造器释放 A。需要在拥有这些资源的构造或工厂中处理部分初始化,并做独立故障实验。本章只验证配置失败,数据库和文件初始化失败的完整资源回收标为未运行。
六次有限启动观测
两工程共享完全相同的 Controller、Service、Filter、接口和 routes 源码,业务响应一致,日志级别与 JVM 参数一致。每次生产进程使用 -Xms64m -Xmx256m,从创建进程开始计时,以首次真实 /hello 返回 200 为终点;每轮交替启动顺序,减少固定顺序影响。它测量的是包含 JVM、类加载、框架初始化和 HTTP 轮询的整体等待时间。
| 轮次 | manual 首次 200 / 秒 | Guice 首次 200 / 秒 |
|---|---|---|
| 1 | 1.281 | 1.599 |
| 2 | 1.514 | 2.477 |
| 3 | 2.226 | 3.074 |
| 中位数 | 1.514 | 2.477 |
这批样本不足以给出性能优劣结论。保留的前一轮 run-01 中位数是 manual 1.981 秒、Guice 1.882 秒,排序相反。两轮使用相同生产产物,后一次驱动只收紧了 Guice 构件名断言;开发机同时存在其他任务,实验没有控制 CPU 调度与文件缓存。因此不能把第二轮中位数之差全部归因于依赖注入机制。
如果需要做性能决策,应在受控环境增加样本、报告分布,并分别采集类加载、图创建和外部资源初始化的耗时。手工装配在本例中的确定收益是依赖关系出现在可编译的构造表达式中;省去了容器构件不等于消除了生产启动的主要成本。
运行工程与验收边界
两个独立模块位于 examples/play-electives/e02-compile-di/manual 与 guice。从它们的父目录运行,JAVA_HOME 指向 JDK 21,PLAY_LAB_SBT_LAUNCHER 指向已校验的 sbt 1.10.7 launcher,准备方式见工程 README。
1 | |
manual 的 2 项 JUnit 和 Guice 的 1 项 JUnit 均实际执行并通过,没有跳过。两个 stage 均成功构建。最终 run-final 包含 10 条场景记录:两个依赖边界、缺少构造器依赖、坏运行配置,以及六次真实生产启动和停止。所有自建进程结束,监听关闭,不需要数据库 fixture。
原始证据保存在 examples/play-electives/evidence/e02/isolated/:build-02 包含成功构建日志,run-final/artifacts.json 是实际 JAR 与 SHA-256,missing-dependency-javac.log 保留预期编译失败,bad-config.log 保留预期启动失败,observations.json 和各 journal 保存请求及停止结果。两个完整 Java 示例另经独立 javac --release 21 编译。共享仓库接入和页面浏览器检查属于另外的验收表面。
| 问题 | 已验证结果 | 不包含的推论 |
|---|---|---|
| 显式组件装配 | 无 Guice 构件的产物可服务真实 HTTP | 全应用无反射 |
| 缺少构造依赖 | javac 非零,指出需要 Message | 任意业务约束都在编译期验证 |
| 测试替换 | 构造参数替换后实际路由返回替身值 | 替身资源自动关闭 |
| 过滤器与生命周期 | 安全头、Host 400、stop hook、端口关闭 | CSRF 全矩阵或任意资源回收 |
| 启动与性能 | 配置负例失败;两组有限启动观测 | 稳定速度排序 |
| 数据库部分初始化、磁盘满、类加载器隔离 | NOT_RUN | 不能视为已通过 |
两个改动练习
- 给
HelloController增加必须提供的审计服务,观察手工组件图的编译错误。修复后对正常与替换图分别验证调用次数,再故意传入错误业务实现,说明哪些错误仍要靠运行测试发现。 - 给真实服务增加一个独占文件资源,并在下一项资源初始化时注入失败。分别验证启动失败、正常 SIGTERM 与测试替换三条路径,记录文件句柄和停止钩子的终态,不能只凭应用退出码认定清理完成。
上一篇:E01 Scala API对照 · 下一篇:E03 Ebean与Hibernate集成 · 系列起点:最小应用
