一个接口的请求和响应,通常要在两个地方各写一遍:服务端写一遍处理逻辑,客户端再写一遍调用代码。两边只要有一处对不上,就得等到运行时才炸出来。TeamTavern 这个找游戏队友的网站,把这件事换了个做法——浏览器端和 API 服务端都写在同一个 PureScript 代码库里,由同一次 spago build 编译。

真正值得说的是两边怎么对话:每一个 HTTP 接口就是一个类型,服务端的处理函数和客户端的调用都从这个类型推导出来。路由库用的是 Jarilo,属于 purescript-bklaric。熟悉 Haskell 的 Servant 的话,这套思路会眼熟。

打开网易新闻 查看精彩图片

一个路由就是一个类型

先看登录玩家用的那个路由。它被声明成一个类型:向 /sessions 发一个 POST,带 JSON 请求体,应答是 204 No Content,或者一个 400 并携带三种原因之一,或者 500。没有别的可能。

请求体本身也是一个类型,用 Variant 描述两种登录方式:一种是邮箱或昵称加密码,一种是 Discord 的 accessToken。400 的三种原因同样是类型:unknownPlayer、wrongPassword、unknownDiscord。

这里没有 StartSession 类型的值。这些片段都是裸的类型级数据声明的,PostJson_、NoContent 这些只是少数几个底层构造的类型同义词。带路径参数的路由长这样:games 后面接一个字符串的 handle,再接 posts,再接一个整数的 id。

每个路由单独放在 Routes/ 下的一个模块里,紧挨着它请求体和响应体的 record、variant 类型。Server/ 和 Client/ 都从那里导入。

服务端:一张处理函数的记录表

所有路由被合并成一个大类型,每个路由挂在一个名字下。服务端就是一次调用,接收这个类型,外加一张记录表,每个名字对应一个处理函数。

serve 带一个约束 JunctionRouter junction handlers,并且有一条从路由到记录表的函数依赖。于是编译器能从 AllRoutes 反推出记录表的类型:它有哪些字段、每个处理函数收到什么、允许返回什么。

对 viewPost 来说,path 就是 handle 字符串加 id 整数,因为路由捕获了一个叫 handle 的字符串和一个叫 id 的整数。路由器在处理函数跑起来之前就已经把路径段解析成了整数,一个 /games/valorant/posts/abc 这样的请求,在代码看到它之前就已经被应答了。对 startSession 来说,body 就是已经解码好的请求体 variant。

返回类型也是同样推出来的。由此带来两个结果。

第一,处理函数的错误类型是 Void,它不能失败。内部出的任何问题,都必须变成这个路由声明的某个响应。实际上每个处理函数都被包在一个小函数里,把它的类型化错误转成对应的响应,并且只在它是 internal 那一种时才记日志。

第二,responseRow 精确地装着这个路由声明的那些响应。StartSession 没有 404,所以一个想给它回 notFound 的处理函数根本编译不过。同样,AllRoutes 里有一个路由却没有对应处理函数的服务端,也编译不过。

客户端:同一个类型,反着读

在客户端,一次请求就是对 fetch 的调用,把路由当作代理传进去。又是函数依赖在干活:路由决定方法、用路径和查询记录拼出 URL、编码请求体并设置 Content-Type。

调用一个帖子的接口时,传入 handle 和 id。漏掉 id,或者把 id 当成字符串传进去,都编译不过。

拿回来的东西是一个 Variant,每个声明的响应对应一个分支,JSON 体已经按路由的类型解码好了。从登录页用 Discord 登录,代码大致是这样:调用 StartSession,注入 discord 分支和 accessToken,然后对结果做匹配——noContent 就跳回去,badRequest 里的 unknownDiscord 就带着 nickname 跳到昵称页面,其余情况报错。

页面给新玩家预填的 nickname,正是服务端放进 unknownDiscord 里的那个字段。客户端对这个载荷的理解不可能和服务端跑偏,因为它的定义只有一份。

改动一次会怎样

这个网站有 47 个路由。当其中一个发生变化时,编译器会找出这次改动牵动的地方:

改一个响应体里的字段名,构造它的处理函数和每一个读它的页面都编译不过。加一个路径捕获,这个路由的每一次 fetchPath 调用都少一个字段。删掉一个响应,返回它的处理函数和按名字匹配它的页面都编译不过。往 AllRoutes 里加一个路由,服务端在它有处理函数之前构建不起来。

有一种情况编译器不会强制。给一个路由加一个响应,只要现有客户端代码用的是 onMatch 加一个默认分支,就仍然编译得过。这是一个取舍:换成 match 会让每个调用点都穷尽,代价是到处都要把 internal 写出来。

类型到此为止的地方

路由类型描述的是服务端和客户端之间传递的东西。它管不到的东西也值得说清楚:它不保证处理函数内部真的按声明的语义去做事,也不保证数据库查询、邮件发送这些副作用正确。类型能保证的是两边对接口形状的理解一致,而不是接口背后的行为正确。