likes
comments
collection
share

基于装饰器和 Zod Schema 构建类型安全的 API 接口

作者站长头像
站长
· 阅读数 15

写在前面

对于前端团队来说,当业务对接的下游比较多,业务上存在聚合下游多个微服务的接口数据,以及为各类端场景设计更加适配的数据结构的诉求时,通常会选择使用 Node.js 构建 BFF 层来解决这些开发协助上的痛点。而随着 Typescript 的普及,基于 Koa 等相关衍生的 Node.js 框架构建 BFF 层面临的一大问题就是类型定义和数据校验能力的缺失,我们期望的框架可能会是这样:

  1. 开发构建阶段需要有完备的接口 TS 类型定义来提供强类型的开发体验

  2. 线上运行时阶段需要有完善的强类型校验能力保证项目运行的稳定性

  3. 为 API 接口自动生成类型完备的接口定义文档供多端生成 client 进行调用

为了实现这些能力,我们基于 Koa 扩展并设计了一套基于装饰器和 Zod Schema 定义的接口开发插件,帮助开发者能够快速开发构建出类型安全的 API 接口并自动实现运行时参数校验,以及自动生成接口定义文档。

方案调研

结合 BFF 场景的诉求,为了解决类型安全问题,一般有如下几种思路。

Schema First

BFF 的开发者首先通过合适的接口描述语言(Interface Definition Language,IDL)定义 API 接口,再由 Code Gen 代码生成工具解析 IDL 生成 client 和 server 端的 Typescript 代码和 Typescript 类型文件。完成代码生成之后,再进行具体业务逻辑的开发工作。

基于装饰器和 Zod Schema 构建类型安全的 API 接口

优点

  • 职责边界划分清晰,server 与 client 能够基于 IDL 定义进行良好协作

  • Schema 跨编程语言,适配更多类型的 server 和 client

缺点

  • 系统中额外引入新的类型语言,有额外学习理解 IDL 的成本,且 IDL 的能力与 Typescript 生态不能完全对齐

  • Codegen Tools 实现相对复杂度较高,需要同时生成类型和代码两部分内容以及考虑接口修改场景下的覆盖问题,项目中需要额外考虑 IDL 和生成类型,生成代码的维护方式

Code First

研发流程上,首先编写 Typescript 代码,完成路由配置以及请求响应结构体的定义,然后通过工具生成目标接口 schema。通过 schema 生成 client 代码和 Typescript 类型,进行具体业务开发。

基于装饰器和 Zod Schema 构建类型安全的 API 接口 优点

  1. 对 BFF 的研发更加友好,不需要额外理解 schema 语法和工具的成本,Schema 主要作为生成产物被消费。

  2. 对于 BFF 与前端在同一个团队维护的场景,可以做进一步的技术栈统一,研发协作效率更高

缺点

  • 代码的表达能力更强,Code2Schema 的实现复杂度不一,可能存在信息量的减少。采用常见的 JSON Schema 方案相对比较合适,有较多开源方案。如果需要生成其他 Schema 如 graphql / thrift IDL 等等成本会更大。

一体化

以 trpc 框架为例,它是基于 TypeScript 实现的端到端类型安全方案(End-to-end type-safe APIs)。

tRPC has no build or compile steps, meaning no code generation, runtime bloat or build step.

在 trpc 中,预设了前后端在同一个 monorepo 中,前端能够直接引用 BFF 端的 Typescript 类型,从而解决 Code Gen 工具进行 Typescript 类型生成的难题。

优点

  • 不再需要依赖 schema 定义,前端可以直接复用服务端的类型,降低很多复杂度

  • 进一步屏蔽了开发者对应 HTTP 协议的感知,开发者认知学习成本更低更符合直觉

缺点

  • 强制要求前后端在同一个仓库,存在一定耦合,要求团队有较为完善的 monorepo 工程化建设。

  • HTTP 协议作为一个广泛应用的协议,由框架进行完全的抽象屏蔽并不一定是适用于各类场景的通用方案。

这里举个例子, HTTP 协议中的语义可能会被请求链路中多个组件依赖:
参考 RFC-2616HTTP Method 的定义:
- 幂等 HTTP 方法:GETHEADPUTDELETEOPTIONSTRACE
- 非幂等 HTTP 方法:POSTLOCKPATCH

Nginx 默认只会对幂等接口进行重试操作

技术选型

Koa-swagger-decorator 的实现充分利用现有开源的技术栈。采用 Code First 的模式设计,使用 Zod 定义请求响应结构体,生成 JSON Schema 用于描述接口,生成接口文档,并通过 swagger-ui 实现接口文档的自动托管。

具体实现

github.com/Cody2333/ko…

感兴趣直接参考 example 目录,是一个完整的基于 koa-swagger-decorator 的应用示例

示例代码

我们可以将路由信息 RouteConfig,请求体 Schema,响应体 Schema ,Middleware 以及具体的接口实现统一维护在一个地方。

koa-swagger-decorator 插件会在应用启动时收集应用中定义的所有接口元信息,将 Handler 注册到 router 上,并生成符合 OpenAPI V3 规范的 JSON Schema 定义。

// define your api handler with @routeConfig decorator and it will generate OpenAPI Docs automaticallyclass UserController {
  @routeConfig({ // define your API route info using @routeConfig decorator
      method: "post",
      path: "/users",
      summary: "create a user",
      tags: ["USER"],
      operationId: "CreateUser",
      })
  )
  @body(z.object({
      uid: z.string(), 
      name: z.string(), 
      age: z.number().min(18).optional()
  })
  @responses(xxx)
  async CreateUser(ctx: Context, args) {
      // body params will be validated using zodSchema.parse(ctx.request.body)
      // and assigned the parsed value to args.
      console.log(args, args.body.uid, args.body.name);
      ctx.body = { message: "create", id: "123" } as ICreateUserRes;
  }

静态类型推导

我们通过 zod 定义请求体和响应体的结构,可以直接基于 zod 定义的 Zod Schema 对象推导出其 Typescript 类型,无需额外工具和依赖

const CreateUserReq = z.object({
      uid: z.string(), 
      name: z.string(), 
      age: z.number().min(18).optional()
 }
export type ICreateUserReq = z.infer<typeof CreateUserReq>;

运行时参数校验

我们通过 zod 定义请求体和响应体的结构,可以直接基于 zod 定义的 Zod Schema 对象校验运行时请求参数的合法性。考虑到 koa-swagger-decorator 同时接管了路由注册和接口文档定义,我们可以在对应的路由 handler 上默认添加上基于 zod 的请求参数校验的中间件

        const validationMid = async (ctx: Context, next: any) => {
          ctx._swagger_decorator_meta = item;
          ctx.parsed = {
            query: ctx.request.query,
            params: (ctx.request as any)?.params,
            body: ctx.request.body,
          };
          if (this.config.validateRequest) {
            if (routeConfig.request?.query) {
              ctx.parsed.query = routeConfig.request?.query.parse(
                ctx.request.query
              );
            }
            if (routeConfig.request?.params) {
              ctx.parsed.params = routeConfig.request?.params.parse(
                (ctx.request as any).params
              );
            }
            if (bodySchema) {
              ctx.parsed.body = bodySchema.parse(ctx.request.body);
            }
          }

          await next();
        };

应用启动流程

接口文档生成

Koa-swagger-decorator 在应用启动阶段即完成了路由元信息的收集和 OpenAPI JSON Schema 的构建和生成,使用开源的 swagger-ui 即可快速实现可视化的接口文档生成。

客户端类型生成

有很多开源的基于 OpenAPI V3 的 client generator 可用。这里使用 openapi-client-axios 示例。它不需要生成客户端代码,只需要生成客户端 Typescript 类型。它提供了 cli 根据接口文档生成类型定义:

typegen http://localhost:3000/swagger-json > example/client/openapi.d.ts
import OpenAPIClientAxios from "openapi-client-axios";
import { Client } from "./openapi";
import common from "../config";
const api = new OpenAPIClientAxios({
  definition: "http://localhost:3000/swagger-json",
  axiosConfigDefaults: {
    baseURL: common.baseUrl,
  },
});
api.init();
async function createPet() {
  const client = await api.getClient<Client>();
  const ret = await client.CreateUser(null, { age: 18, uid: "aa" });
  console.log("response", ret.data);
}
createPet();

小结

整体上 koa-swagger-decorator 插件基于现有 Typescript 生态中的各类工具库进行了整合,为开发者提供了一套 Code First 的开发类型安全的 API 接口的实现方案。当然这一方案也不局限于 Koa 这个基础框架,通过简单调整 router 相关的实现即可适配到其他框架中。