深入 Play 32:版本迁移,把编译通过和协议兼容分开验证
把 Play 2.9 应用中的 akka.util.ByteString 留到 Play 3,编译会失败。把 play.server.akka.server-header 留到 Play 3,应用却能启动,配置查询也能返回旧值,实际 HTTP 响应仍然没有那个 Server 头。两处改动都涉及 Akka 到 Pekko 的命名迁移,故障出现的位置并不相同。
版本迁移需要同时检查 Java 类型、解析出的依赖、配置的实际消费者和网络行为。compile 能发现缺失的类,无法证明某个字符串配置已被服务器使用;单元测试能检查 Result,也无法替代打包进程在线路上发送的完整响应。
四个组合,分别改变框架和 Scala
实验使用四个独立的 Play Java 工程。前三个固定 Scala 2.13.15,依次迁移 Play 2.8.22、2.9.6、3.0.6;第四个保持 Play 3.0.6,把 Scala 改为 3.3.4。所有组合使用 sbt 1.10.7 和 Amazon Corretto 21.0.11,同一套 Java 路由和 Python HTTP 断言贯穿整个矩阵。
| 工程 | Play | Scala 设置 | 实际框架 groupId | 实际服务器 |
|---|---|---|---|---|
play28 |
2.8.22 | 2.13.15 | com.typesafe.play |
AkkaHttpServer |
play29 |
2.9.6 | 2.13.15 | com.typesafe.play |
AkkaHttpServer |
play30 |
3.0.6 | 2.13.15 | org.playframework |
PekkoHttpServer |
play30-scala3 |
3.0.6 | 3.3.4 | org.playframework |
PekkoHttpServer |
这些版本已实际解析、编译、运行测试并生成 stage。表中的服务器同时由启动日志、配置值、ActorSystem 实际类名与加载的 Play jar 路径确认。构建文件声明某个版本,只能说明解析意图;resolved-and-staged.json 记录了实际 jar 坐标及 SHA256,并核对编译依赖与打包文件的字节一致性。
flowchart LR
P28["Play 2.8.22<br/>Scala 2.13.15 · Akka"] -->|框架升级| P29["Play 2.9.6<br/>Scala 2.13.15 · Akka"]
P29 -->|坐标 包名 配置迁移| P30["Play 3.0.6<br/>Scala 2.13.15 · Pekko"]
P30 -->|单独升级 Scala| S3["Play 3.0.6<br/>Scala 3.3.4 · Pekko"]
Contract[同一路由与 HTTP 契约] -.-> P28
Contract -.-> P29
Contract -.-> P30
Contract -.-> S3
官方 3.0 迁移指南第 5–11 行 要求旧应用先完成 2.9 迁移,再处理 3.0 的变化。把 Scala 升级放在最后,可以保留一个已经通过网络契约的 Play 3 / Scala 2.13 基线。发生失败时,待比较的差异便集中在最后一步。
固定源码为 Play 2.8.22 的 4564a5cf3a811399eccc4023d422c0413be09bf7、2.9.6 的 b900221530c4162a59ac0452056af1ba557bc47c 和 3.0.6 的 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。后文引用固定提交中的说明,历史版本的支持声明不代表当前版本的支持政策。
2.8 基线也需要说明运行条件
Play 2.8.22 的 sbt 插件在 PlaySettings.scala 第 98–111 行 明确提示:支持 Java 8、11,Java 17 为实验支持。Java 21 不在这个范围内。本实验把 JDK 固定为 21,是为了控制迁移对照中的变量;2.8 的本机有限用例通过,不能改写为“Play 2.8 官方支持 JDK 21”。
旧工程首先遇到构建工具依赖冲突。sbt 1.10.7 的 meta-build 选择了 scala-xml 2.3.0,而旧 Play 插件链中的组件要求更早的版本,sbt 的兼容性检查阻止加载构建。实验只在 play28/project/plugins.sbt 增加单个模块的兼容性声明:
1 | |
这条配置改变的是构建工具对该模块版本关系的判断,没有把应用的所有依赖冲突降为警告,也没有证明 scala-xml 任意版本之间都兼容。它必须和后续生成路由、编译、测试及打包的实际结果一起阅读。首次失败保存在 play28/build-attempt3.log,适用范围限于本实验的插件组合。
随后,Guice 4.2.3 的 cglib 访问 ClassLoader.defineClass,在 JDK 21 上触发 InaccessibleObjectException。实验保留 Guice 版本,在三个实际启动 JVM 的位置分别增加:
1 | |
sbt 启动脚本把它传给 Java;测试 JVM 通过 Test / javaOptions 获取;打包后的启动脚本使用 -J--add-opens=java.base/java.lang=ALL-UNNAMED。三个进程的参数互不自动继承,给 sbt 加一次参数不能证明生产包获得了同一参数。play28/build-attempt4.log 保存了未加参数时的失败。
这样的基线只能承担兼容性对照。正式迁移仍应先对照目标框架的 JDK 支持范围,清点插件和运行参数,再决定旧系统是否值得在新 JDK 上增加临时兼容措施。
2.8 到 2.9:先处理平台和 API 边界
2.9 迁移指南第 25–71 行 给出了 sbt、Java 和 Scala 的边界:sbt 至少 1.9,Java 至少 11,支持 Java 17、21;Scala 2.12 被移除,支持 2.13 和 3.3。本实验已经固定在 sbt 1.10.7、Scala 2.13.15,因此这一步不需要同时调整 Scala 源码。
插件声明改为 com.typesafe.play:sbt-plugin:2.9.6,框架 groupId 仍然不变。实际解析出的 Guice 从 4.2.3 变为 6.0.0,2.9 工程去掉了仅用于旧 Guice 的 JVM opening,随后独立运行测试和生产包。这个结果只覆盖当前依赖图;应用自己锁定旧 Guice 或加入第三方注入模块时,不能直接套用。
最小工程没有使用每一种被迁移指南列出的 API。例如,HttpExecutionContext 到 ClassLoaderExecutionContext 的调整在指南第 116–124 行列出,但本工程直接使用 ActorSystem dispatcher,不把这项列为已实验通过的替换。真实应用应从自己的 import、依赖与配置清单出发,补齐用到的分支。
2.9 到 3.0:坐标、类型和配置分别迁移
Play 3 的插件声明变为:
1 | |
groupId 变化影响 sbt 插件和直接依赖;Akka 到 Pekko 的变化影响 Java 类型、服务器 provider、配置前缀和部分构件名。固定迁移指南第 19 至 46 行说明了坐标变化,第 71 至 158 行列出类型与配置变化。
| 本实验中的使用点 | Play 2.9.6 | Play 3.0.6 |
|---|---|---|
| ActorSystem | akka.actor.ActorSystem |
org.apache.pekko.actor.ActorSystem |
| 流与字节 | akka.stream、akka.util |
org.apache.pekko.stream、org.apache.pekko.util |
| 服务器 provider | play.core.server.AkkaHttpServerProvider |
play.core.server.PekkoHttpServerProvider |
| 服务器配置前缀 | play.server.akka |
play.server.pekko |
| 服务器构件 | play-akka-http-server_2.13 |
play-pekko-http-server_2.13 |
Play 的多数 Java API 仍以 play.* 为包名。Maven groupId 变为 org.playframework,并不意味着把全部 play.mvc import 改成 org.playframework.mvc。同样,不能把所有 javax.* 一次替换为 jakarta.*。这组实验的 2.9 工程继续使用 javax.inject,3.0 工程采用 jakarta.inject;两者的实际依赖图仍同时包含相关注入 API。它不是整个 Java EE 命名空间的迁移测试。
下面的完整控制器针对 Play 3.0.6,展示异步执行与已知长度流所需的实际 import。展示类另行用该版本的 staged classpath 编译通过;完整工程还包含 WS、会话与流取消端点。
1 | |
supplyAsync 的第二个参数明确指定 executor,响应头记录实际执行线程。这里的任务只构造 JSON,适合用来验证调度边界;不能把耗时 JDBC 或阻塞远程调用搬进这个默认 dispatcher,再把“返回 CompletionStage”当成资源隔离。
编译失败能拦住旧类型
negative_import.py 仅把 org.apache.pekko.util.ByteString 临时改回 akka.util.ByteString,随后运行 clean compile。Play 3 工程实际以退出码 1 失败,日志包含 akka.util 不存在及 javac 失败信息。脚本在 finally 恢复原文件,并记录恢复后的 SHA256;之后重新编译、测试、打包和运行 HTTP 矩阵。
负例故意保持其他代码不变,因而能把这次失败归到遗留 import。它没有通过再引入 Akka 依赖来消除编译错误,因为那会改变待验证的类型系统和依赖图。
旧配置可能正常解析但不生效
配置负例把 Play 3 的有效键置为 null,另外设置旧键:
1 | |
生产进程 /runtime 读回旧键为 legacy-only,有效键为 <unset>,实际 HTTP 响应没有 Server 头。这是同一个进程中三项独立观测:HOCON 保存了旧键,应用能读取它,Pekko 服务器没有把它作为自己的头配置。
flowchart LR
File[application.conf] --> Config[Config 解析成功]
Config --> Old[旧键 akka.server-header<br/>应用可读 legacy-only]
Config --> Current[有效键 pekko.server-header<br/>null]
Current --> Server[PekkoHttpServer 读取配置]
Server --> Wire[真实 HTTP 响应<br/>无 Server 头]
Old -. 不被此消费者读取 .-> Server
PekkoHttpServer.scala 第 81–103 行把消费者固定在 play.server.pekko 下,并从该配置读取 server-header。配置迁移因此需要沿“键存在→谁读取→运行结果”核验,单独调用 Config.getString 不足以证明生效。
Scala 3 是另一个已经运行的对照
play30-scala3 与 play30 保持相同 Java 业务源码,只改变工程名称和 scalaVersion := "3.3.4"。Play、服务器、WS 与测试的全部网络契约重新运行,两个 JUnit 测试和 stage 同样通过。
实际解析出的服务器构件从 play-pekko-http-server_2.13:3.0.6 变为 play-pekko-http-server_3:3.0.6。Scala 3 工程同时包含 scala3-library_3:3.3.4 和 scala-library:2.13.14,不能用“还有 scala-library 2.13”判定升级失败;应检查 Scala 3 库和框架交叉发布构件的组合,而不是要求依赖图里完全消失 2.13 字样。
Scala 3 迁移指南第 5–25 行明确要求 Java 应用也配置 Scala 版本,因为 Play 自身使用 Scala。这个独立工程验证了 Java 应用、生成路由及相应库组合,未包含 Scala 业务源码、宏、ScalaTest 或 specs2 测试迁移;这些内容需要另外建立迁移用例。
保持相同的 HTTP 契约
每个工程运行两个 JUnit 测试,再从 target/universal/stage/bin 启动真实生产进程。Python harness 同时启动本机下游 HTTP 服务,完整读取响应正文,并在结束时关闭自己的下游服务、连接和生产进程。最终日志总计 8 个 JUnit 测试通过,四个 stage 均通过;旧配置负例另跑了一次完整生产 HTTP 矩阵。
| 场景 | 实际断言 | 防止漏掉的变化 |
|---|---|---|
| 正常与错误路由 | 正常 JSON 200,缺失路由 JSON 404,业务非法值与畸形 JSON 400 | 路由、BodyParser、错误输出格式 |
| 真正异步执行 | dispatcher 线程执行成功返回 200,异常 Stage 经 HTTP 返回 JSON 500 | 调度路径与服务器错误处理 |
| WS 完整消费 | 下游分两次写入,得到完整 complete-loopback-body |
只收到头或部分正文就误判成功 |
| WS 超时 | 下游已经进入且仍受闸门阻塞时,上游返回 504;随后释放下游 | 超时结果与下游任务结束混为一谈 |
| 已知、未知长度 | 相同 part-two 正文;前者 Content-Length 为 8,后者 HTTP/1.1 chunked |
实体长度与线路编码 |
| 客户端取消流 | socket 读到首段 tick 后关闭,服务端终止计数增加 |
仅关闭客户端而未确认上游流结束 |
| 会话 | 设置 cookie 后原客户端可读,同版本无 cookie 请求读到 absent | 会话写入和请求携带 |
| 打包与退出 | jar 哈希匹配,真实服务器启动;无强制 kill,监听端口关闭,下游 handler 全部结束 | 测试 classpath 与生产包不一致、资源未回收 |
超时用闸门确定事件顺序:下游进入后保持未返回,上游 250 ms 请求超时产生 504,再释放下游。它证明客户端超时与业务处理结束是两个事件,不证明下游必定收到取消指令。流取消则使用另一条链路:客户端关闭 socket 后轮询 watchTermination 的计数,确认服务端对应流已经终止。
JUnit 中还有一个边界:Helpers.route 遇到异常 Stage 时,这组测试观察到异常传播,而生产 HTTP 链路经自定义 HttpErrorHandler 输出 JSON 500。因而 JUnit 断言异常类型及消息,真实 HTTP 断言状态码和错误正文;不能把两个入口的返回形式强行写成一样。
记录中的 HTTP 请求条数包含启动及流终止轮询,会随运行改变。它们不用于比较吞吐量。实验限于隔离工程、loopback HTTP/1.1 和固定输入,未验证 TLS、反向代理、HTTP/2、线上负载或跨版本旧 session cookie 的兼容性。为集中验证契约,实验配置关闭了过滤器;生产应用必须保留自身的 CSRF、Host、认证等约束。
从源码重新运行
下载四套迁移工程与实验记录,核对SHA256SUMS。解压后的 play-electives/migration 对应下文源码目录。接入后四组合重新执行8项JUnit、stage、生产HTTP与编译/旧配置负例,4个控制器class与对应生产jar字节一致,见 evidence/migration/integration;旧版本本机可运行不等于上游支持当前JDK。
源码目录为 examples/play-electives/migration/,四个子目录对应版本表。原始隔离记录位于 examples/play-electives/evidence/migration/isolated/,每个组合保留首次失败、最终 build 日志、JUnit XML、HTTP 观测和服务器日志。基础应用入口见第 00 篇源码说明。
先把 JAVA_HOME 设为本机 JDK 21 的安装目录,把 PLAY_LAB_SBT_LAUNCHER 设为 sbt 1.10.7 launcher 的完整路径。后续命令从仓库根目录执行,Python 需要 3.11 或更新版本:
1 | |
构建脚本把仓库设置限制到 Maven Central 和 sbt 历史插件官方 Ivy 下载入口。旧插件不在某个 Maven 路径上,不足以推断版本不存在;本实验保留了解析失败日志及后续实际下载、加载成功的记录。
负例会临时修改 play30 控制器,实验前应保持该文件没有未保存修改。脚本恢复源码后重新生成生产包,再运行正常和旧配置场景:
1 | |
collect_evidence.py 会核验测试 XML、构建退出码、解析与打包 jar、控制器 class 字节、HTTP 结果和资源终态,最后生成 manifest。构建等待有 420 秒上限;服务启动、socket、下游闸门和退出等待也分别有界。正常及负例运行记录均未出现强制 kill,但这不等于已经完成操作系统信号注入的清理测试。
两个改动练习
第一个练习检验旧会话迁移。在相同的受控密钥和 cookie 设置下,让 2.9 工程生成 session cookie,再把该 cookie 发送到 3.0 工程。分别记录签名验证、读取结果和失败响应;增加密钥不同的对照。现有矩阵只验证各版本自己写、自己读,没有这个跨版本结果。
第二个练习检验应用自己的 Scala 依赖。保持 Play 3.0.6,把一个实际使用的 Scala 库加入两个 Scala 组合,检查它是否提供匹配构件,再让相同 Java 端点调用它。先分别证明解析和编译,最后重跑 WS、流取消和生产退出矩阵。库缺少构件、源码不兼容和运行行为变化,应各自保留错误证据,不能合并为一句“Scala 升级失败”。
上一节:31 性能与容量。下一节:33 相同约束下的Web栈。
