Java 后端和 Typescript 前端虽然都是类型语言,但传统上这两个域上的类型之间存在脱节。本文推荐的这个工具让我们在一个地方修改一个方法或类,并立即在其他地方直接使用它,或者在我们误用它时在编译时看到错误。
这个工具捕获了如此多的错误并使开发速度如此之快,以至于我现在无法想象没有它可以工作。
我知道有一些类似的工具,但它们要么增加了大量的复杂性(OpenAPI),要么被锁定在某个堆栈中(Remix)。构建我们自己的以相对较小的成本提供了显着的控制(即它并不那么复杂)。
通常,我们有一个 Java 后端,它公开 HTTP 端点供我们的前端调用。,这个后端提供简单的CRUD创建/读取/更新/删除,有些做特定的请求,还有很多要记住的返回许多不同类型的对象。
因为我们非常依赖这些 API 调用,所以我们在工具上投入了大量精力,以使我们的开发人员尽可能无缝地进行前端/后端通信。
在我详细介绍之前,这里有一个简短的概述:
最终结果是,当我们向后端添加端点时,我们的 API 客户端会自动生成调用它的方法,从而使前端到后端的调用几乎与本机调用一样简单。
继续阅读以了解其工作原理,或查看 Github 上的 演示 。
API 定义
这是简单的部分。我们的 API 接口如下所示:
- interface UserApi {
- UserDto getUser(int userId);
- }
我们 API 的后端实现
Javalin 是一个出色的网络服务器,它提供了我们所需的功能与简单性之间的平衡。后端调用可能如下所示:
- POST /api/UsersApi/getUser
- {
- 'userId': 1001
- }
因此,我们为每个调用创建一个处理程序。它涉及一些反射,这有点毛茸茸,但让开发人员的事情变得更容易:
- public static void main(){
- var app = Javalin.create();
- // UsersApi is the interface that defines the endpoints.
- // UsersService is the backend implementation of UsersApi.
- // We repeat the below for every API we want to expose.
- expose(app,UsersApi.class, new UsersService())
- app.start()
- }
-
- private
void expose(Javalin app, Class api, T implementation) { - String apiName = api.getSimpleName();
- for (Method method : api.getMethods()) {
- // handle calls to, for example, POST /api/UsersAPI/getUser
- app.post("/api/" + apiName + "/" + method.getName(), (ctx) -> {
- Map
body = ctx.bodyAsClass(Map.class); - List
- for (Parameter param : method.getParameters()) {
- String json = body.get(param.getName());
- var arg = GSON.fromJson(json, param.getParameterizedType());
- args.add(arg);
- }
- try {
- Object result = method.invoke(implementation, args.toArray());
- String json = objectMapper.writeValueAsBytes(result);
- ctx.result(json);
- } catch (Exception e) {
- throw new RuntimeException("Failed to invoke " + apiName + "/" + method, e);
- }
- });
- }
- }
差不多就是这样。对于每个 API,然后是每个方法,公开一个对给定参数进行反序列化的端点,然后使用这些参数调用实际实现的方法。
我在这里要注意的唯一特别之处是,我们的请求主体不是我们可以立即反序列化的单个对象,而是最好将其视为 JSON 字符串的参数名称的键值对。所以它本质上是双重序列化的 JSON。
所以!我们的后端已准备好接收请求。接下来是 API 客户端。
Typescript客户端
这里的代码和上面的代码有点相似——给定一个像UsersAPI这样的接口,迭代它的方法,并迭代它的参数。但是,在此过程中,我们通过向字符串附加一些 Typescript 来构建字符串。这里的代码有点难看,所以我要写一些伪代码来描述它:
- String toTypescript(Class... api) {
- for each api:
- typescript += "class ${api.getSimpleName()} {"
- for each method:
- typescript += "${method.getName()}("
- for each parameter:
- typescript += "${parameter.getName()}: ${getType(parameter)}, "
- typescript += "): Promise<${getType(method.returnType)}">
- var body = Map
- typescript += "return fetch('/api/${api}/${method}', {"
- typescript += " method: 'POST',"
- typescript += " headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},"
- typescript += " body: JSON.stringify({"
- for each parameter:
- typescript += "${parameter.getName()}: JSON.stringify(${parameter.getName}),
- typescript += " }"
- typescript += "}).then(res => res.json())
- typescript += "}"
- return typescript
- }
嗯……这样更易读吗?如果您愿意,可以改为阅读 实际代码 。
以下是您可能希望在生成的 Typescript 中看到的内容,例如:
- class UsersAPI {
- getUser(userId: number): UserDto {
- return fetch('/api/UsersApi/getUser', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
- body: JSON.stringify({userId: JSON.stringify(userId)})
- }).then(res => res.json())
- }
- }
我们将上述文件存储在target/ts/api.ts中,并使用Exec Maven插件生成该文件,该插件让我们在运行mvn package时运行客户端生成器。
我跳过的一个魔法是getType(parameter)的调用。这可以将Java类转换为Typescript的等价物。这里基本上是转换的工作原理。
现在你几乎已经准备好调用new UsersApi().getUser(1001) - 我们只是缺少返回的UserDto的Typescript类型。
为我们的Java类型提供Typescript定义
这个问题很简单。我们有一个Java包,里面有我们想在前端使用的所有类型(com.company.dtos),我们把Maven插件typescript-generator指向它。
-
cz.habarta.typescript-generator -
typescript-generator-maven-plugin -
2.32.889 -
-
-
generate -
-
generate -
-
compile -
-
-
-
-
com.company.dto.** -
-
target/ts/types.ts -
假设我们有一个这样的 UserDto 类:
public record UserDto(int userId, String username) {}
我们最终会得到一个types.ts像这样的文件:
- interface UserDto {
- userId: number,
- username: string
- }
把它们放在一起
所以,现在我们有:
最终结果是添加新端点如下所示:
注意事项
在此过程中,我们学到了一些值得注意的教训,包括: