把 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
2
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.22")
libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always

这条配置改变的是构建工具对该模块版本关系的判断,没有把应用的所有依赖冲突降为警告,也没有证明 scala-xml 任意版本之间都兼容。它必须和后续生成路由、编译、测试及打包的实际结果一起阅读。首次失败保存在 play28/build-attempt3.log,适用范围限于本实验的插件组合。

随后,Guice 4.2.3 的 cglib 访问 ClassLoader.defineClass,在 JDK 21 上触发 InaccessibleObjectException。实验保留 Guice 版本,在三个实际启动 JVM 的位置分别增加:

1
--add-opens=java.base/java.lang=ALL-UNNAMED

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
addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.6")

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package examples;

import jakarta.inject.Inject;
import org.apache.pekko.actor.ActorSystem;
import org.apache.pekko.stream.javadsl.Source;
import org.apache.pekko.util.ByteString;
import java.util.Arrays;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import play.http.HttpEntity;
import play.libs.Json;
import play.mvc.Controller;
import play.mvc.Result;

public final class MigrationExample extends Controller {
private final ActorSystem system;

@Inject
public MigrationExample(ActorSystem system) {
this.system = system;
}

public CompletionStage<Result> async() {
return CompletableFuture.supplyAsync(
() -> ok(Json.newObject().put("value", "async-ok"))
.withHeader("X-Async-Thread", Thread.currentThread().getName()),
system.dispatcher());
}

public Result known() {
Source<ByteString, ?> bytes = Source.from(Arrays.asList(
ByteString.fromString("part"), ByteString.fromString("-two")));
return ok().sendEntity(new HttpEntity.Streamed(
bytes, Optional.of(8L), Optional.of("text/plain")));
}
}

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
2
play.server.pekko.server-header = null
play.server.akka.server-header = "legacy-only"

生产进程 /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
2
3
4
5
6
7
8
9
10
11
export JAVA_HOME="/path/to/jdk-21"
export PLAY_LAB_SBT_LAUNCHER="/path/to/sbt-launch-1.10.7.jar"
cd examples/play-electives/migration
python3 run_build.py play28 evidence/play28/final-build.log clean test stage 'show Compile / dependencyClasspath'
python3 http_matrix.py play28 evidence/play28/final-http
python3 run_build.py play29 evidence/play29/final-build.log clean test stage 'show Compile / dependencyClasspath'
python3 http_matrix.py play29 evidence/play29/final-http
python3 run_build.py play30 evidence/play30/final-build.log clean test stage 'show Compile / dependencyClasspath'
python3 http_matrix.py play30 evidence/play30/final-http
python3 run_build.py play30-scala3 evidence/play30-scala3/final-build.log clean test stage 'show Compile / dependencyClasspath'
python3 http_matrix.py play30-scala3 evidence/play30-scala3/final-http

构建脚本把仓库设置限制到 Maven Central 和 sbt 历史插件官方 Ivy 下载入口。旧插件不在某个 Maven 路径上,不足以推断版本不存在;本实验保留了解析失败日志及后续实际下载、加载成功的记录。

负例会临时修改 play30 控制器,实验前应保持该文件没有未保存修改。脚本恢复源码后重新生成生产包,再运行正常和旧配置场景:

1
2
3
4
5
python3 negative_import.py evidence/play30/final-negative-import
python3 run_build.py play30 evidence/play30/final-build.log clean test stage 'show Compile / dependencyClasspath'
python3 http_matrix.py play30 evidence/play30/final-http
python3 http_matrix.py play30 evidence/play30/final-legacy-config --legacy-config
python3 collect_evidence.py evidence

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栈。