Java 方法返回 CompletionStage<play.mvc.Result>,Scala 方法返回 Action[A],其中异步处理函数产生 Future[play.api.mvc.Result]。两者能够提供相同的 HTTP 契约,但类型和组合方式并不相同。把一种 API 的 Result 直接赋给另一种,在本章的负例中会编译失败。

类型化请求还解决了另一个具体问题:身份信息如何经过 Action 组合进入异步计算。实验中的 Java 和 Scala 实现都切换到名为 api-worker 的线程,普通 ThreadLocal 没有随之传播,显式捕获的用户却保持正确。这个结果来自两个独立版本工程的真实生产 HTTP 请求。

固定两个组合,先比较 HTTP 契约

主对照固定 Play 3.0.6、Scala 2.13.15、sbt 1.10.7、JDK 21.0.11。另一个独立工程固定 Play 3.0.6 与 Scala 3.3.4,运行相同 Java、Scala 源码和同一套网络矩阵。Scala 3 构件实际包含 play_3-3.0.6、play-java_3-3.0.6、scala3-library_3-3.3.4,并解析到基础 scala-library-2.13.14。后者是构件组合的一部分,不能把这个目录误写成 Scala 2.13.15 工程。

两个工程都启用 PlayScala,显式加入 guice 与 javaCore。混合源码能编译,不意味着所有 Java 运行时绑定已经存在。初次构建遗漏 javaCore,JUnit 创建应用时失败:

1
2
[Guice/MissingImplementation]: No implementation for ApplicationLifecycle was bound.
ApplicationLifecycle: "play.inject.ApplicationLifecycle"

固定源码中,play-java 的 reference.conf 启用 Java BuiltInModule,后者将 Java ApplicationLifecycle 绑定到委托实现。补齐构件后,两套工程都完成 clean、test、stage,各执行 2 个 JUnit,无跳过。Java 模块配置、BuiltInModule

实验定义的业务输入是 JSON 对象里的 value,取值为 1 到 100 的整数值;2.0 也属于有效值,字符串 "2" 不属于。用户由合成测试头 X-User 提供,合法格式为 1 到 20 个小写字母。成功返回用户和数值的两倍;字段错误统一为 {"error":"value"}。

这份契约避免把语言默认转换行为当作产品约定。Java 使用 Jackson JsonNode,Scala 使用 Play JSON 的 JsValue;同样叫整数读取,具体允许哪些数字表示仍需验证。Java 显式检查十进制数值是否为整数及范围,Scala 使用 asOpt[Int] 后检查范围。

输入 Java API Scala API
value 为 2、2.0 200,value 为 4 相同 JSON
0、2.5、字符串、缺字段、2147483648 400,error 为 value 相同 JSON
JSON 语法错误 400 400
超过 1 KiB 413 413
text/plain 415 415
合法正文但用户缺失或格式非法 401 401
无用户且正文语法错误 400 400

两套 Scala 版本均实际执行了这些输入。框架生成的 parser 错误页面只约定状态码,实验没有要求其 HTML 文案逐字节一致;业务成功与业务错误则比较解析后的 JSON 对象。

Action 的类型包含哪些信息

Scala 的 Action[A] 携带正文类型 A。使用 parse.json 后,处理块直接得到 Request[JsValue];加入身份组合后得到 UserRequest[JsValue]。Java 的 Controller 仍接收 Http.Request,通过 BodyParser 注解声明解析器,再从 request.body().asJson() 读取正文。

Scala 中同步形式 auth(parser) { request => Result } 最终也经由异步 Action 构造。在 Play 3.0.6 的 ActionBuilder 实现中,同步处理块被包装为成功 Future;async 则接收已经返回 Future 的处理块。包装成功 Future 不会将前面的同步工作自动迁移到其他线程。Action.scala

flowchart LR
  R["HTTP 请求"] --> P["BodyParser"]
  P -->|成功| A["Request JsValue"]
  P -->|错误| E["400 / 413 / 415"]
  A --> U["UserAction 检查身份"]
  U -->|失败| D["401"]
  U -->|成功| T["UserRequest JsValue"]
  T --> B["业务块"]
  B --> F["Future Scala Result"]
  F --> O["HTTP 响应"]

本章没有启用 deferred body parsing,所以正文解析先于 Action 组合。无用户且 JSON 语法错误返回 400,正好证明身份 Action 没有成为所有输入的第一道检查。若认证需要在读取大正文之前执行,必须单独设计 parser 与 Action 的顺序,并验证拒绝时的正文取消行为;仅增加身份包装类型不会改变这个顺序。Action 组合文档

从普通请求到 UserRequest

下面是实际编译的完整 Scala 身份 Action。类型参数 A 保留原正文类型,WrappedRequest[A] 委托其余请求信息。通过认证之后,后续处理函数要求的输入变为 UserRequest[A],不必在业务代码里再次从字符串头字段读取身份。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package lab
import jakarta.inject.Inject
import play.api.mvc._
import scala.concurrent.{ExecutionContext, Future}
final class UserRequest[A](val user: String, request: Request[A]) extends WrappedRequest[A](request)
final class UserAction @Inject()(val parser: BodyParsers.Default)(implicit val executionContext: ExecutionContext)
extends ActionBuilder[UserRequest, AnyContent] {
override def invokeBlock[A](request: Request[A], block: UserRequest[A] => Future[Result]): Future[Result] = {
val user = request.headers.get("X-User").getOrElse("")
if (!user.matches("[a-z]{1,20}")) Future.successful(Results.Unauthorized("{\"error\":\"unauthorized\"}").as("application/json"))
else {
LabWorker.LOCAL.set(user)
try block(new UserRequest(user, request))
finally LabWorker.LOCAL.remove()
}
}
}

这里使用的是可控的本地身份探针,生产认证应验证实际凭据,再把可信结果装入请求包装。类型化请求能约束后续函数的输入形状,不能证明头字段真实可信,也不能替代签名校验或权限检查。

Java 对照使用同样的校验规则,但通过 TypedKey 保存身份,并通过 @With(AuthJava.class) 连接到业务方法。完整 Action 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package lab;
import java.util.concurrent.*;
import play.libs.typedmap.TypedKey;
import play.mvc.*;
public final class AuthJava extends Action.Simple {
public static final TypedKey<String> USER=TypedKey.create("user");
@Override public CompletionStage<Result> call(Http.Request request) {
String user=request.header("X-User").orElse("");
if(!user.matches("[a-z]{1,20}")) return CompletableFuture.completedFuture(unauthorized("{\"error\":\"unauthorized\"}").as("application/json"));
LabWorker.LOCAL.set(user);
try {return delegate.call(request.addAttr(USER,user));}
finally {LabWorker.LOCAL.remove();}
}
}

两种方式都可以短路返回 401。Scala 将额外字段表达在请求类型上;Java TypedKey 将键和值类型关联起来,但 Controller 的方法签名仍是普通 Request。若 Java 方法忘记挂上 Action,编译器不会因此拒绝 attrs().get(USER),缺失属性会在运行时暴露。Scala 若直接从普通 Request 读取不存在的 user 成员,则不能通过类型检查。

更复杂的 Scala 流程可以使用 ActionTransformer 增加信息、ActionFilter 拒绝请求、ActionRefiner 同时转换和拒绝,再通过 andThen 按类型连接。这里实际运行的是一个自定义 ActionBuilder;多阶段数据库对象加载与权限 refinement 为未运行扩展,不把 API 存在当作该业务流程已经验证。

Future 的执行线程与请求上下文

下面的完整 Scala Controller 与 Java 对照共享同一个资源探针和专用 Executor。显式的 ExecutionContext.fromExecutor 决定 Future 工作块及其恢复回调使用的执行环境。ActionBuilder 本身的执行上下文与这个工作线程池分别注入,不能看到一个 implicit ExecutionContext 就假定整条请求链都在同一个线程。

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
package controllers
import jakarta.inject.{Inject, Singleton}
import lab._
import play.api.libs.json._
import play.api.mvc._
import scala.concurrent.{ExecutionContext, Future}
@Singleton
final class ScalaApi @Inject()(cc: ControllerComponents, auth: UserAction, worker: LabWorker, probe: StreamProbe)
extends AbstractController(cc) {
private implicit val workerContext: ExecutionContext = ExecutionContext.fromExecutor(worker.executor)
def json: Action[JsValue] = auth(parse.json(1024)) { request =>
(request.body \ "value").asOpt[Int].filter(v => v >= 1 && v <= 100) match {
case Some(value) => Ok(Json.obj("user" -> request.user, "value" -> (value * 2)))
case None => BadRequest(Json.obj("error" -> "value"))
}
}
def context(fail: Boolean): Action[AnyContent] = auth.async { request =>
val user = request.user
val origin = Thread.currentThread().getName
Future {
if (fail) throw new IllegalStateException("synthetic")
val local: String = Option(LabWorker.LOCAL.get()).map(_.toString).getOrElse("absent")
Ok(Json.obj("user" -> user, "local" -> local,
"origin" -> origin, "worker" -> Thread.currentThread().getName))
}.recover { case _: IllegalStateException => ServiceUnavailable(Json.obj("error" -> "worker")) }
}
def stream(id: String): Action[AnyContent] = Action {
Ok.chunked(probe.source(id).asScala).as("application/octet-stream")
}
}

Java 对照在同一 Executor 上调用 CompletableFuture.supplyAsync,捕获 TypedKey 中的用户;Scala 捕获 request.user。认证阶段另设一个普通 ThreadLocal,然后在调用下游块后于 finally 中移除。工作线程不读取 Request 对象的可变环境,只使用显式捕获的用户字符串。

最后一轮 Scala 2 工程中的观测:

1
2
3
4
Java alice: origin=application-pekko.actor.default-dispatcher-4 worker=api-worker local=absent user=alice
Java bob: origin=application-pekko.actor.default-dispatcher-9 worker=api-worker local=absent user=bob
Scala alice: origin=application-pekko.actor.default-dispatcher-15 worker=api-worker local=absent user=alice
Scala bob: origin=application-pekko.actor.default-dispatcher-17 worker=api-worker local=absent user=bob

Scala 3 工程重复了同一场景,身份和 ThreadLocal 结果相同,默认 dispatcher 的线程编号不同。两个用户并发请求被提交到同一个专用工作线程,均得到各自身份,未相互串用。这个有限结果证明显式参数链在本次场景中成立;它没有验证 MDC、OpenTelemetry Context 或框架外的上下文传播组件。

sequenceDiagram
  participant H as 请求处理线程
  participant A as 身份 Action
  participant W as api-worker
  participant C as HTTP 客户端
  H->>A: 原请求
  A->>A: 校验用户并包装请求
  A->>H: UserRequest / TypedKey
  H->>W: Future / supplyAsync,捕获 user
  A->>A: finally 移除当前线程 LOCAL
  W->>W: user 正确,LOCAL absent
  W-->>H: Result 或失败恢复后的 Result
  H-->>C: JSON 响应

Future { ... } 表达式创建后即按 ExecutionContext 调度,既不是惰性的业务描述,也不自动提供取消协议。把 JDBC 调用放进 Future 只改变执行位置,调用仍然阻塞它所在的线程。工作池容量、排队和生命周期仍需明确管理。Scala 异步文档

失败对照令工作块抛出合成 IllegalStateException。Scala 用 recover 将该异常映射为 503,Java 用 exceptionally 返回相同 JSON,两套工程均验证了 503 与 {"error":"worker"}。两个恢复函数的匹配范围不同:Scala 这里仅匹配特定异常,Java 示例接收全部异常完成。这个成功反例不能扩展为“所有异常的行为一致”,真实业务应显式定义哪些异常能转换为可重试响应。

两种 Result 不能靠泛型相似直接混用

负例文件放在编译根目录之外,用 Scala 2.13.15 编译器单独执行:

1
2
3
object MixedResult {
val result: play.mvc.Result = play.api.mvc.Results.Ok("hello")
}

实际退出码为 1,诊断为:

1
2
3
error: type mismatch;
found : Result (in play.api.mvc)
required: Result (in play.mvc)

两类 Result 之间需要明确的 API 转换,不能把返回 Future[play.api.mvc.Result] 的函数直接写成 Java CompletionStage<play.mvc.Result>。异步容器转换和容器内 Result 转换也是两件事。本章通过并列 HTTP 端点比较输出,未声称完成任意 Java/Scala 异步类型的自动互转。

Scala 3 工程使用相同业务源码,但有独立的编译输出和 _3 构件。它的成功范围是本次冻结的 Play、JSON、Guice 与流组合;不证明任意 Scala 2 库、宏、编译器插件或 ORM 插件都能迁移。版本迁移仍需逐项检查依赖图与二进制后缀。

流取消需要独立于 Future 检查

Java 端返回 Java DSL Source,Scala 端调用 .asScala 后交给 Ok.chunked。二者共享 StreamProbe:最多 64 块,每块 8192 字节,每 40 ms 生产一块;创建真实 FileChannel,并在流终止时关闭、删除资源文件。

客户端分别连接两个端点,读完第一块后关闭 TCP,再用另一条连接查询探针。Scala 2 和 Scala 3 下的四个场景均观测到 produced=2、closed=true、fileOpen=false、fileExists=false,远早于 64 块全部生产完。计数 2 是本次缓冲和调度的结果,断言只要求小于上限,不把它写成框架恒定预取数。

这证明了流取消传播与资源回收;它不证明客户端断开会取消已经提交的普通 Future。若异步任务执行写操作,断开连接后的取消、提交和补偿必须由该任务的协议处理。示例的流源有 12 个 id 的总量上限,并拒绝重复 id,避免实验状态无限增长。

停止阶段通过 SIGTERM 关闭两个生产进程。工作 Executor 执行 shutdown、等待终止并写入 worker-stopped=true,驱动检查资源文件为零、监听端口关闭。这个 hook 验证属于应用资源生命周期,与请求是否成功返回需要分别断言。

复跑、失败历史与边界

可下载完整源码与证据,用SHA256SUMS校验。共享目录 examples/play-electives/e01-scala-api 的 Scala 2、Scala 3 组合分别重新完成 clean/test/stage,各有 2 个 JUnit 测试、15 个自有 class 与实际 stage jar 字节一致;68 个生产请求场景及跨 API 编译失败负例也已重跑。共享记录在 examples/play-electives/evidence/e01/shared,隔离运行与失败历史在 isolated。设置自己的 JAVA_HOME,按 README 下载固定 sbt launcher 并校验 SHA 后执行:

1
2
3
4
cd examples/play-electives/e01-scala-api
export PLAY_LAB_SBT_LAUNCHER="$PWD/.tools/sbt-launch-1.10.7.jar"
python3 build_checks.py --evidence evidence/build
python3 http_checks.py --evidence evidence/run

负例另由 negative_checks.py 执行,README 提供公开 Maven Central 编译器下载命令和 SCALA_COMPILER_CP 设置方式。输出目录不能覆盖旧的网络实验;每次复跑保存独立收据。

最终两套工程共执行 4 个 JUnit、68 条生产网络与退出观测,通过独立错误类型编译负例。正文中两份完整 Scala 文件和一份 Java 文件与编译输入逐字节相同;所有自有应用 class 与 stage JAR 条目逐字节相同,生产健康接口读回 stage 类来源。早期跨语言 JSON 包装类型推断失败及遗漏 javaCore 的 Guice 失败保留在 build-01、build-02,修复后的完整构建在 build-03。

本章没有运行真实凭据认证、数据库事务、多段 ActionRefiner 业务链、MDC 自动传播、Future 取消、代理/TLS、容量压测或第三方 Scala 3 插件迁移,这些均为 NOT_RUN。本机同输入结果相同,不构成语言性能或所有 API 行为等价的结论。

两个练习

类型化权限链练习。 在 UserAction 之后增加 ItemRequest 和 ActionRefiner,根据路径 id 加载有限内存对象,再用 ActionFilter 判断所属用户。分别验证缺身份、缺对象、非所属用户与成功路径,并解释各分支在哪个类型转换之前终止。不要将内存对象成功访问表述为数据库权限验证。

异步取消练习。 让一个 Future 在门闩之后产生可观察副作用,客户端先关闭 TCP,再释放门闩。分别记录副作用、响应读取和任务取消标记;另用支持显式取消的任务接口实现取消路径。比较普通 Future 与流的取消语义,保存两种终态。本练习尚未执行。

主线终章:35:可验证的订单与订阅服务。下一篇扩展:E02:编译期依赖注入。