深入 Play 01:sbt 怎样生成路由与模板
把 routes 中的订单号从 Long 改为 String,控制器方法却仍接收 long,错误通常在编译时就会暴露。把模板参数从 String 改为 Int,调用处也必须修改。Play 将这些约束放进生成代码,再交给 Scala 与 Java 编译器;这让一部分接口错误不必等到请求到达时才出现。
这一机制不能验证订单是否存在、调用者是否有权限,也不能保证每个 URL 都匹配。编译期检查负责代码之间的调用契约,运行期 binder 和业务逻辑负责输入与状态。理解 sbt 的任务和 classpath,才能判断失败属于哪一层。
构建定义也是代码,但运行位置不同
首批 累计工程 固定 sbt 1.10.7、Play 插件 3.0.6 和 Scala 2.13.15。目录中的 build.sbt 是 Scala 风格的 sbt 构建定义,不是应用运行时的业务源码。project/plugins.sbt 将 Play 插件加入构建;它不会因为应用里 import 了 play.mvc.Result 就自动出现。
以下两个表达式承担不同用途:
1 | |
:= 给 setting 或 task 定义一个值;+= 向已有序列追加一个值。斜杠表示作用范围,例如 Compile / sourceGenerators 是 Compile configuration 下的源码生成任务,Test / parallelExecution 控制测试配置下的执行方式。同名键在不同 project 或 configuration 中可以有不同值,看到键名还不足以判断消费者。
不要把“设置”理解成每个 HTTP 请求都重新读取的配置。scalaVersion 影响编译器和带 Scala 二进制后缀的依赖;lab.stock 属于应用运行配置。修改前者可能需要重新解析依赖和编译;修改后者由应用加载方式决定何时生效,两者没有同一条热更新承诺。
Play 插件修改了普通 sbt 工程的源码布局。固定实现的 PlayLayoutPlugin 将应用源码放在 app/、测试放在 test/。因此把控制器放进一个未经配置的普通目录,不会仅因文件扩展名是 .java 就加入编译。
routes 经过哪几次转换
应用的订单路由是:
1 | |
编译器首先解析方法、路径、控制器调用与参数声明;随后生成正向路由、反向路由和 Java 访问入口。正向路由负责匹配与参数绑定,反向路由负责从类型化参数构造 URL。它们引用同一个路由声明,但工作方向相反。
RoutesCompiler 插件 把路由生成任务加入 Compile / sourceGenerators。当前配置的目标目录是 crossTarget / routes / main,本次实际得到:
1 | |
这些目录由构建范围、Scala 版本和插件配置决定。旧教程可能写 src_managed/main;不能据此判断当前构建“没有生成路由”。先看当前插件实现和实际文件,再决定是否真的缺产物。
生成的 router 构造器接收 controllers.LabController 实例,并保存这个引用。其订单分支调用绑定辅助方法后才构造实际调用。生成代码中的 fakeValue[Long] 等占位值用于建立调用元数据,不表示一次真实请求的订单号;运行期参数来自 fromPath、fromQuery。截取一个占位表达式推断“框架总传 0”会忽略真正执行的分支。
Java 代码可调用:
1 | |
这次实测得到 /orders/42。缺省 verbose=false 没有写进 query,仍可正向绑定回相同值。生成 API 让调用者在编译时使用 long 和 boolean;它并不自动把一个负数订单号变成业务合法值。
Twirl 的类型检查为什么会影响控制器
模板文件 app/views/greeting.scala.html 从参数签名开始:
1 | |
控制器使用生成模板的 Java 可调用入口:
1 | |
Twirl 把模板编译成带类型的 Scala 源文件,后续编译将 render 的签名纳入调用检查。本次生成文件位于:
1 | |
Play 3.0.6 的构建使用 Twirl 2.0.7;对应 Twirl 插件源码 同样通过 source generator 接入编译。Twirl 是独立项目,需要单独固定其源码引用,不能用 Play commit 去拼一个并不存在的 Twirl 文件路径。
普通字符串在 HTML 模板中被转义。输入 <script>x</script> 时,当前测试输出 <script>x</script>。类型检查和转义承担两种不同保证:前者防止把不匹配的参数交给模板,后者处理字符串进入 HTML 的表示方式。显式使用可信 HTML 类型、脚本上下文和 URL 上下文的边界留到模板专题,不能把这次字符串测试推广为所有输出都安全。
多模块依赖会改变谁的 classpath
工程中 inventory-core 只提供库存接口,应用里的实现和控制器需要访问它:
1 | |
dependsOn 建立项目间 classpath 依赖,所以接口类会参与消费者的编译和运行。它不等于部署成另一个服务,也没有网络调用。编译日志先构建库存接口,再编译引用接口的应用,体现了依赖任务的先后关系。
aggregate 则用于把某些任务传播给其他项目。聚合执行多个项目的 compile,不意味着这些项目的类进入 root classpath。固定版本多项目指南 分别描述两者。一个聚合根项目可以协调构建,却不消费任何业务类;一个应用可以只依赖确实使用的子项目。
当前接口只有一个真实实现,但测试会替换它为合成库存服务,且多模块实验直接观察它是否在 classpath 中。因此这个接口是有具体验证目的的边界,不为尚不存在的扩展功能建立工厂或注册中心。
三个编译失败反例怎么重跑
在 play-lab/ 中运行:
1 | |
脚本建立三个临时工程副本,排除 target/ 和证据目录。每次只改变一个条件,执行 compile,检查非零退出与实际编译错误,最后由临时目录上下文清理副本。原工程保持可编译,失败日志保留在证据目录。
| 变化 | 下游保持不变 | 实际失败阶段 |
|---|---|---|
routes 的 id: Long 改成 id: String |
Java 方法仍接收 long |
生成调用的类型检查 |
模板签名 name: String 改成 name: Int |
Java 仍传入字符串 | Java 对生成 render 签名的检查 |
删除 dependsOn(inventoryCore) |
应用仍 import 库存接口 | Java 编译找不到 services.InventoryService |
本次三个命令均返回 1,日志中均存在对应编译错误。第一种和第二种错误虽然来自 routes/模板修改,最终却可能由不同编译器报出;应阅读文件位置与实际类型,不把所有错误笼统归为“sbt 不兼容”。
如果依赖下载失败,命令也可能返回 1,但这没有执行到类型检查。脚本同时检查编译错误标记,是为了避免将网络故障误记为反例通过。这个检查没有替代人工阅读:证据包还保存完整日志,以便核对报错确实对应那个单变量变化。
从错误位置缩小排查范围
控制器方法改名后,首先检查 routes 和生成调用的错误;模板签名改动后,核对 Java 调用处与生成 render 参数;接口包找不到时,检查消费者的 dependsOn 与 package 声明。已经成功编译但请求得到 400 时,转到运行期参数绑定;得到 404 时,检查方法、路径和应用前缀。
生成代码可读来定位机制,也可以复制节选作为研究证据;修复应回到 routes、模板或构建定义。否则下一次 source generator 运行会覆盖手工修改,而文章展示的“修复”无法从干净源码重现。
源码链接说明生成任务为什么存在,成功构建说明当前输入满足约束,三个失败副本说明具体输入变化会触发哪个检查。它们共同限定这篇的结论:已验证路由调用、模板参数和模块 classpath 的类型边界,未验证业务规则或所有增量编译场景。
参考资料与继续阅读
参考固定实现的 PlayLayoutPlugin、RoutesCompiler 与 多项目指南。
