GET /orders/42 能调用 order(long, boolean),依赖的不是控制器里手写 Long.parseLong,而是生成路由选择了对应的参数 binder。将输入换成 /orders/no,控制器还没执行就得到 400;将路径换成 /missing,则没有这个绑定过程,直接进入 404 fallback。

类型化路由把接口声明和代码调用连接起来,但仍有运行期错误边界。缺失值、非法值、重复 query 与百分号编码需要分别验证。尤其当参数是业务标识或权限字段时,能转换成一个 Java 值不代表请求没有歧义。

路由声明里哪些值来自请求

当前工程包含两个对照路由:

1
2
GET /orders/:id controllers.LabController.order(id: Long, verbose: Boolean ?= false)
GET /reverse controllers.LabController.reverse(id: Long)

第一条的 id 来自 path capture,verbose 来自 query,并有缺省值 false。第二条没有 path capture,因此 id 是必须提供的 query。相同参数名在不同路由中有不同来源,不能只看控制器方法签名猜测来源。

?= false 表示缺失时采用默认值。它不会把 verbose=wrong 改成 false,也不会等价于“忽略任何错误”。框架先调用 binder,只有没有得到输入结果时才使用默认分支。已有输入但转换失败仍是失败,这个区别决定了 API 是否会静默接受客户端拼写错误。

静态路径、捕获路径与方法共同参与选择 handler。声明顺序也可能影响有重叠的路径模式,后续设计接口时应避免用一个过宽的捕获路径掩盖具体路径。当前实验路由没有这种重叠,不能用它证明任意 routes 排列都等价。

从 Param 的两种结果追到控制器调用

GeneratedRouter.fromPath/fromQuery 把参数转换为 Param[T],其中保存 Either[String, T]:左边是错误消息,右边是类型化值。Scala 的 Option 则描述是否存在一个绑定结果,不能把 None 与“已解析但值非法”混为一谈。

可以用下面的概念模型阅读源码:

1
2
3
4
5
6
7
8
没有输入
-> 检查默认值
-> 无默认则 Missing parameter -> 400

存在输入
-> binder 转换
-> 转换失败 -> Left(error) -> 400
-> 转换成功 -> Right(value) -> 调用控制器

这只是解释本批标量参数的模型。Option、列表与 Java Optional 在 fromQuery 中有专门的空值处理;直接拿这张图推导所有集合参数,会遗漏源码里的分支。

GeneratedRouter.call 在错误分支选择 badRequest action,在成功分支把值传给 handler generator。于是非法订单号不会进入 order,也不会调用库存服务。这种短路针对路由绑定,不等于业务已经验证了 id 范围。

例如 -1 可以解析为 long。是否允许负数、订单是否属于当前用户、库存能否查询,都需要在业务契约中定义。本批接口只返回合成数据,没有身份隔离,不能作为授权示例。

用输入矩阵定位缺失和非法

在源码包的 play-lab/ 中运行 python3 lab/verify.py。真实客户端结果如下:

输入 当前结果 所属阶段
/orders/42 200,verbose=false path 成功、query 缺省
/orders/42?verbose=true 200,verbose=true 两个参数成功
/orders/no 400 Long path binder 失败
/orders/42?verbose=no 400 Boolean query binder 失败
/reverse 或 /reverse?id=no 400 必需 query 缺失或转换失败
/missing、POST /health 404 无匹配 handler

无路由与绑定错误都可能显示错误页面,但修复位置不同。前者检查 HTTP 方法、路径和前缀;后者检查已匹配路由的参数。把全部 4xx 都改成“订单不存在”会丢失客户端输入错误与实际不存在状态的区别。

这批测试同时通过生成反向路由得到 URL,再发真实请求核对正向结果。它没有构造一组自定义 binder,也没有覆盖 long 溢出、所有 Unicode 路径或畸形百分号序列;这些未运行输入不写成已通过的边界。

重复 query 不是自动报错

verbose=true&verbose=false 对客户端来说含有两个不同值。当前标量解析器在取输入时使用首个元素,QueryStringBindable.Parsing 可看到 params.get(key).flatMap(_.headOption)。真实 HTTP 测得结果为 true。

因此不能将类型化绑定描述成“重复值会被拒绝”。当前实现的行为是首值参与标量转换;换成列表参数会有另一套契约。上游注释中出现旧后端名称也不能用作当前服务器选择的证据,后端应从实际构建与运行记录核对。

首值策略对一个展示开关可能可接受,对权限、金额、幂等键则可能造成组件间理解不一致。如果代理、签名模块或业务代码分别取不同位置的值,同一个请求可能得到不同解释。这里的实验只证明 Play 当前 binder 的选择,不证明沿途所有组件都采用同一规则。

对要求唯一的字段,入口应显式检查该 query key 的值数量;重复时给出定义好的客户端错误。这个限制是 API 契约的一部分,不应寄希望于 Long 或 Boolean 类型自动执行。当前示例没有加入这项业务规则,保留重复值反例以展示框架默认边界。

编码发生在哪里

真实请求 /orders/%34%32 最终得到 id=42,说明这条路径上捕获值经过解码后成功绑定。这个输入只含数字的百分号编码,没有证明编码斜杠、路径层级或非法编码的处理方式。百分号编码测试应该保留原始请求路径,否则客户端先行改写 URL 后,实验可能没有发送预想输入。

反向路由通过对应 binder 的 unbind 构造 path/query,并按生成器的规则编码;代码生成入口 区分路径参数和 query 参数。手工拼接 "/orders/" + id 在 long 样例里看似简单,但字符串参数中的空格、问号、斜杠会引入额外边界。

编码后的 URL 与业务标识也不应混用。数据库应保存领域值,路由层负责传输表示;预先编码后再交给反向路由,可能重复编码。当前只研究 Long 参数;后续引入自定义业务类型时,必须测试 bind(unbind(value)) 能否恢复声明的语义,而不是仅比较 URL 长得相似。

反向路由 round-trip 的验收目标

控制器的反向方法调用生成 API:

1
2
3
public Result reverse(long id) {
return ok(controllers.routes.LabController.order(id, false).url());
}

请求 /reverse?id=42 得到 /orders/42,随后请求该 URL,JSON 中 id=42、verbose=false。生成器省略等于缺省值的 query;客户端不用显式发送 false,正向路由仍恢复对应值。

Round-trip 验证的是接口表示能否保持已声明参数语义,不要求字符串完全保留。例如默认值省略、编码形式规范化,都可能改变文本。应用部署有路径前缀时,还需检查 router prefix 是否进入反向 URL;当前实验没有额外 context prefix。

编译期的调用约束在第 01 篇已经用失败副本验证。这里增加了运行期的 round-trip,它们共同解决两种问题:调用处传入什么类型,以及生成 URL 后服务器绑定成什么值。它们仍不替代业务授权与状态查询。

HEAD 与方法不匹配要单独核验

默认请求处理器允许未显式声明的 HEAD 尝试 GET handler;fallback 实现 明确保留真实 HEAD 请求。客户端对 HEAD /health 观察到 200 且没有正文,不能据此说 handler 以 GET 请求执行。

其他方法不匹配在当前声明下得到 404,没有自动出现 405。若业务要求区分“路径不存在”与“方法不允许”,需要明确设计相应路由或请求处理行为;状态码契约不能从另一个框架的默认值直接移植。

HEAD 尤其需要谨慎处理有副作用的 GET:即使响应正文不发送,handler 仍可能执行。当前 health 只读固定字符串,所以本实验没有副作用;不把这个结果推广成“HEAD 不会执行任何业务”。

参考资料与继续阅读

固定源码的 GeneratedRouter、Binders 和 Java 路由指南 分别提供生成调用、转换规则和声明契约。完整请求矩阵随 累计工程 交付。

上一篇:请求生命周期。下一篇:Request 与 Result。