← 返回蜂巢洞察

如何使用Hono和Zod构建类型安全的API

如果你之前曾经开发过 Node.js API,那么你就应该了解这种麻烦:TypeScript 中定义的类型与运行时验证的结果不一致,而 OpenAPI 文档的内容也与这两者都不同。 有时有人会在接口中添加新的字段,但相应的模式文件却从未得到更新。因此,文档内容会一直保持过时的状态,直到有客户端提交错误报告为止。这种情况下,既不会出现编译错误,也不会有测试失败的情况——这三个信息来源就这样出现了分歧。 在本教程中,你将学习如何使用 Hono 和 Zod 将这些问题统一起来。你会学到我在实际开发中使用的那些模式,包括我在维护的开源视频处理工具包 ClipForge 中所采用的方案。这些方法能够确保

如果你之前曾经开发过 Node.js API,那么你就应该了解这种麻烦:TypeScript 中定义的类型与运行时验证的结果不一致,而 OpenAPI 文档的内容也与这两者都不同。

有时有人会在接口中添加新的字段,但相应的模式文件却从未得到更新。因此,文档内容会一直保持过时的状态,直到有客户端提交错误报告为止。这种情况下,既不会出现编译错误,也不会有测试失败的情况——这三个信息来源就这样出现了分歧。

在本教程中,你将学习如何使用 HonoZod 将这些问题统一起来。你会学到我在实际开发中使用的那些模式,包括我在维护的开源视频处理工具包 ClipForge 中所采用的方案。这些方法能够确保验证规则、类型定义和文档内容在设计上保持一致。

读完本文后,你应该能够做到以下几件事:

  • 使用一个统一的 Zod 模式文件来处理运行时验证、TypeScript 类型定义以及 OpenAPI 文档格式

  • 合理设计路由结构,确保接口契约与处理逻辑相互分离

  • 将数据库模式和 HTTP 接口模式明确划分为两个独立的层次

  • 确保所有错误路径都能返回统一格式的错误信息

  • 将这些设计模式应用到小型任务 API 中,进而扩展到复杂的多服务系统中

先决条件

为了充分理解本文的内容,你需要掌握以下基础知识:

  • JavaScript 语言及基础类型的概念

  • REST API 的工作原理(路由、请求体、状态码等)

  • 对 Node.js 的基本了解,包括如何安装包和运行脚本

你不需要事先具备使用 Hono、Zod 或 Drizzle 的经验。

目录

1. 数据描述不一致的问题

大多数TypeScript API最终都会为相同的数据维护三种不同的描述方式:

  1. 运行时验证会在请求到达时进行检查

  2. TypeScript类型是编译器在构建阶段能够理解的数据结构

  3. API文档,即你向使用者提供的规范说明

这些描述分别保存在不同的文件中,更新的时间安排也各不相同,而且它们之间彼此无法相互参考。

三种API数据描述方式往往不同步:TypeScript类型、运行时验证以及OpenAPI文档

图1:相同的数据被三种不同的方式描述,而这些描述往往不同步。

手工编写的接口在运行时会被忽略;Joi或Yup等工具可以验证数据,但不会自动提供类型信息;OpenAPI文件通常也是手动编辑的,甚至有时根本不会被修改。

解决这个问题的方法不是“更加小心”,而是使用一种能够同时生成这三种描述方式的定义方式。

一个统一的规范能够生成运行时验证结果、TypeScript类型以及OpenAPI文档

图2:一个统一的规范定义能够生成三种不同的输出结果。

当你通过@hono/zod-openapi组合使用Hono和Zod时,就能实现这一目标。

2. 什么是Hono?

Hono是一个基于Web标准API构建的小型、快速的Web框架。它使用的RequestResponse等基本数据类型在Node.js、Deno、Bun以及Cloudflare Workers环境中都能正常使用。

与Express相比,这种差异非常重要:

Express Hono
仅适用于Node的req / res Web标准API
参数是未类型化的字符串 参数会通过Zod进行类型验证和转换
验证工作由开发者自行完成 路由定义直接生成OpenAPI文档
仅能在Node上运行 可在Node、Bun、Deno以及边缘计算环境中运行

Hono的核心代码大小约为14KB。在处理相同的工作量时,它在Node上的运行速度大约是Express的5到7倍;而在Bun或Cloudflare Workers环境下,这种优势会更加明显,因为这些运行环境本身就是为Web标准优化的。

对于大多数CRUD API来说,数据库仍然是性能瓶颈;但在高并发场景下,或者在需要快速启动的应用环境中,框架之间的差异就会显现出来。

对本次教程来说更重要的是:Hono的OpenAPI集成功能使得路由定义本身就可以直接作为文档使用。

3. 什么是Zod?

Zod是一个以TypeScript为优先的架构验证工具。你只需一次性描述数据的结构,Zod就会:

  1. 在运行时验证该数据结构是否正确

  2. 使用z.infer推导出对应的TypeScript类型

  3. 当你添加.openapi()元数据时,它还能生成OpenAPI文档

使用Joi或Yup时,你通常需要在运行时进行验证,然后手动编写相应的接口定义。这意味着你需要进行两次定义工作;而Zod则省去了这一步骤。

import { z } from 'zod';

const createTaskSchema = z.object({
  title: z.string().min(1).max(120),
  status: z.enum(['todo', 'in_progress', 'done']).default('todo'),
});

type CreateTaskInput = z.infer<typeof createTaskSchema>;
// { title: string; status?: "todo" | "inprogress" | "done" }

如果你修改了数据结构,所有依赖CreateTaskInput的地方都会自动更新。TypeScript会告诉你哪些地方出现了问题。

4. 一个架构,三种用途

以下是本文后续内容所依据的思维模型:

taskSchema用于运行时验证、生成TypeScript类型及OpenAPI文档

图3: taskSchema是验证、类型定义及文档生成的唯一依据。

  1. 运行时验证:错误数据在到达处理函数之前就会被拒绝,且会以结构化的方式显示错误信息,而不会生成堆栈跟踪。

  2. TypeScript类型:类型信息是通过z.infer<typeof schema>从架构中推导出来的,而不是单独维护的。

  3. OpenAPI文档:通过.openapi('Name')可以将架构信息注册到生成的API规范中,这样/reference页面上的文档就能始终保持最新状态。

同一个架构定义可以产生三种不同的结果,因此无需手动维护它们之间的同步关系。

5. 如何设置项目环境

我们将使用api-conf-demo提供的示例项目进行演示。这个小型任务API能够清晰地展示这些设计理念。之后,我们还会看看在ClipForge项目中,这些理念是如何在更大规模上得到应用的。

克隆该项目仓库并安装依赖项:

git clone https://github.com/otutukingsley/api-conf-demo.git
cd api-conf-demo
npm install

启动服务器:

npm run dev

你可以在http://localhost:8080地址访问该API,而/reference页面上则提供了交互式的文档。

项目的主要文件夹结构如下:

src/
├── db/schema/          # 数据持久化层(使用Drizzle数据库表结构)
├── lib/schemas/        # HTTP接口定义层(使用Zod和OpenAPI技术)
├── lib/errors/         # 用于记录所有错误信息的统一格式
├── routes/tasks/       # 路由规则及处理函数
├── services/           # 业务逻辑处理及数据库访问模块
├── app.ts              # 中间件、路由配置及OpenAPI接口实现
└── env.ts              # 经过Zod验证的环境配置文件

这仍然属于MVC架构。只不过所使用的工具更加先进而已:

采用MVC结构的层次结构,包括HTTP客户端、控制器、契约规范、服务模型以及数据库

图4:仍然是MVC架构。路由和处理器对应控制器,规范对应契约,服务对应模型。

  • 模型负责处理自身的业务逻辑及数据库访问操作。

  • 视图/契约规范定义了数据在数据库端以及HTTP接口端的呈现形式。

  • 控制器通过路由来声明契约内容,而处理器则负责执行这些契约规定。

通过演示API发出的请求流程如下所示:

POST /tasks请求的流程图,包括中间件、路由契约、处理器、服务以及数据库

图5:通过演示API的请求流程,其中也包括了验证失败的情况。

在那个流程图中,请求会依次经过客户端、中间件、路由契约、处理器、服务以及数据库。中间件负责处理日志记录和CORS相关功能;路由契约则会使用Zod库来验证请求体中的数据。

如果验证失败,客户端会收到422 ApiError响应,此时处理器也不会被执行;如果验证通过,处理器就会接收到一个类型为CreateTaskInput的对象,然后调用TaskService.create()方法,服务层会负责将数据插入数据库并完成解析操作,最后客户端会收到表示请求成功的201状态码以及包含任务详情的JSON响应。

6. 如何定义API规范

首先从src/lib/schemas/task.ts文件开始编写HTTP契约代码:

import { z } from '@hono/zod-openapi';

export const taskStatusSchema = z
  .enum(['todo', 'in_progress', 'done'])
  .openapi('TaskStatus');

export const taskSchema = z
  .object({
    id: z.string().uuid().openapi({
      example: '8e2c9f0a-2222-4a5a-9c3e-1a2b3c4d5e6f',
    }),
    title: z.string().min(1).max(120).openapi({
      example: '编写演讲摘要',
    }),
    description: z.string().max(2000).nullable().openapi({
      example: '介绍Hono与Zod的设计模式',
    }),
    status: taskStatusSchema.default('todo'),
    dueDate: z.string().date().nullable().openapi({
      example: '2026-07-15',
    }),
    createdAt: z.string().openapi({ example: '2026-06-28 10:15:00' }),
    updatedAt: z.string().openapi({ example: '2026-06-28 10:15:00' }),
  })
  .openapi('Task');

export type Task = z.infer<typeof taskSchema>;

现在,这个对象同时具备了运行时验证功能、TypeScript类型定义,以及作为OpenAPI组件的作用。

请求规范应该基于同一个基础结构进行定义,而不应被重复声明。

export const createTaskSchema = taskSchema
  .pick({ title: true, description: true, status: true, dueDate: true })
  .partial({ description: true, status: true, dueDate: true })
  .openapi('CreateTask');

export const updateTaskSchema = createTaskSchema
  .partial()
  .openapi('UpdateTask');

export type CreateTaskInput = z.infer<typeof createTaskSchema>;
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>;

CreateTask接口中从不包含id字段,因为客户端在发送请求时并不会提供这个字段。UpdateTask接口则将所有字段都设置为可选选项,因为PATCH请求可以修改任意字段。

taskSchema衍生出createTaskSchema和updateTaskSchema

图6:请求接口模板都是基于同一个基础资源模板衍生而来的。

我们使用同一个数据源,但创建了三种不同的合同/接口格式。

7. 如何将数据库模式与API模式分开

人们很容易认为数据库中的记录与API返回的数据结构是相同的。在简单的演示环境中,这两种数据结构确实看起来一样;但在实际生产环境中,它们会存在差异。

因此,我们应该有意识地将它们保存在不同的文件中:

服务层中数据库持久化模式与HTTP合同模式的对应关系

图7:将数据库模式与HTTP模式明确地划分为两个独立的层次。

在图7中,左侧的面板对应src/db/schema/目录(负责数据持久化相关操作):

  • 其设计基于Drizzle数据库表结构定义

  • 该表结构用于驱动SQL迁移操作

  • 同时还会生成用于解析数据库记录的drizzle-zod格式模板

  • 此外,还能为TypeScript生成用于数据库操作的插入/查询函数类型定义

右侧的面板对应src/lib/schemas/目录(负责HTTP合同相关定义):

  • 使用Zod框架结合.openapi()方法来定义公共接口规范

  • 规定客户端可以发送的请求体内容及参数格式

  • 定义客户端会接收到的响应数据结构

  • 注册供/doc/reference页面使用的OpenAPI组件

中间的箭头非常重要:数据库中的数据结构并不会自动转换为API接口所需的格式。实际上,是服务层负责将这两种结构进行转换。因此,左侧数据库中存在的某些字段可能不会出现在右侧的API接口中。

简而言之:

  • src/db/schema/:数据库中记录的具体结构
  • src/lib/schemas/:HTTP接口所使用的数据结构

以下是演示中使用的Drizzle表格定义:

import { sql } from 'drizzle-orm';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';

export const taskStatusValues = ['todo', 'in_progress', 'done'] as const;

export const tasks = sqliteTable('tasks', {
  id: text('id').primaryKey(),
  title: text('title').notNull(),
  description: text('description'),
  status: text('status', { enum: taskStatusValues }).notNull().default('todo'),
  dueDate: text('due_date'),
  createdAt: text('created_at')
    .notNull()
    .default(sql`(current_timestamp)`),
  updatedAt: text('updated_at')
    .notNull()
    .default(sql`(current_timestamp)`),
});

export const selectTaskSchema = createSelectSchema(tasks, {
  status: z.enum(taskStatusValues),
});

export const insertTaskSchema = createInsertSchema(tasks, {
  id: () => z.string().uuid().optional(),
  title: () => z.string().min(1).max(120),
  description: () => z.string().max(2000).nullable().optional(),
  status: z.enum(taskStatusValues).optional(),
  dueDate: () => z.string().date().nullable().optional(),
});

一个表格定义实际上会生成三种输出:

  1. 从列中推断出的TypeScript类型

  2. 使用drizzle-kit生成的SQL迁移脚本

  3. 通过drizzle-zod生成的Zod模式定义

这些在数据库层面上非常有用,但它们并不能替代你的HTTP接口模式。

一旦你添加了内部的archivedAt列,或者使用了计算字段来生成API响应,而这些内容并不属于数据库表结构中实际的列,那么这种分离方式就会显示出它的价值。这样的设计使得代码的修改变得更为容易,而不会导致繁琐的重构工作。

服务将数据库中的数据与内部字段映射到公共API响应中

图8:服务决定了客户端能够看到哪些信息。

8. 如何将路由定义为契约

在这种架构中,一个路由并不仅仅“处理请求”。实际上,路由本身就定义了一整套契约规范:包括该方法、路径、请求数据结构以及响应数据结构。

路由定义作为由处理器实现的API契约

图9:路由定义了契约,而处理器则负责实现这一契约。

import { createRoute, z } from '@hono/zod-openapi';
import * as HttpStatusCodes from '@/lib/http-status-codes';
import { jsonContent } from '@/lib/openapi/json-content';
import { jsonApiErrorContent } from '@/lib/openapi/error-schema';
import {
  createTaskSchema,
  taskParamsSchema,
  taskSchema,
} from '@/lib/schemas/task';

export const createTask = createRoute({
  tags: ['Tasks'],
  method: 'post',
  path: '/tasks',
  summary: '创建任务',
  request: {
    body: jsonContent(createTaskSchema, '要创建的任务信息'),
  },
  responses: {
    [HttpStatusCodes CREATED]: jsonContent(taskSchema, '已创建的任务信息'),
    [ HttpStatusCodes.UNPROCESSABLE_ENTITY]:
      jsonApiErrorContent('验证错误'),
    [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonApiErrorContent(
      '内部服务器错误',
    ),
  },
});

export const getTask = createRoute({
  tags: ['Tasks'],
  method: 'get',
  path: '/tasks/{id}',
  summary: '根据ID获取任务信息',
  request: {
    params: taskParamsSchema,
  },
  responses: {
    [HttpStatusCodes.OK]: jsonContent(taskSchema, '请求的任务信息'),
    [ HttpStatusCodes NOT_FOUND]: jsonApiErrorContent('未找到任务'),
    [HttpStatusCodes.UNPROCESSABLE_entity]:
      jsonApiErrorContent('验证错误'),
    [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonApiErrorContent(
      '内部服务器错误',
    ),
  },
});

一个小小的辅助工具能让响应代码更易于阅读:

export function jsonContent

使用有名称的状态常量来代替那些难以理解的数字。在路由配置中,你可以使用这些常量作为对象键;在处理函数中,也可以用它们作为`switch`语句的条件。这样一来,状态码就变得易于查找且具有一致性。

9. 如何保持处理函数的简洁性

一旦路由定义好了所需的功能接口,处理函数只需要按照这个接口来执行相应的操作即可。

export const getTask: AppRouteHandler = (c) => {
  try {
    const { id } = c.req.valid('param');
    return c.json(TaskService.get(id), HttpStatusCodes.OK);
  } catch (error) {
    const apiError = ApiError.parse(error);
    switch (apiError.statusCode) {
      case HttpStatusCodes NOT_FOUND:
      case HttpStatusCodes.UNPROCESSABLE_entity:
        return c.json(apiError.toResponseBody(), apiError.statusCode);
      default:
        return c.json(
          apiError.toResponseBody(),
          HttpStatusCodes.INTERNAL_SERVER_ERROR,
        );
    }
  }
};

export const createTask: AppRouteHandler = (c) => {
  try {
    const body = c.req.valid('json');
    return c.json(TaskService.create(body), HttpStatusCodes CREATED);
  } catch (error) {
    const apiError = ApiError.parse(error);
    switch (apiError.statusCode) {
      case HttpStatusCodes.UNPROCESSABLE_entity:
        return c.json(apiError.toResponseBody(), apiError.statusCode);
      default:
        return c.json(
          apiError.toResponseBody(),
          HttpStatusCodes.INTERNAL_SERVER_ERROR,
        );
    }
  }
};

注意,处理函数中没有以下这些内容:

  • 没有对参数或请求体进行手动解析

  • 没有将数据类型强制转换为`any`

  • 没有包含任何业务逻辑代码

`c.req.valid('param')`和`c(req(valid('json'))`已经完成了验证和类型转换。数据库相关的操作由服务层来处理:

get(id: string): Task {
  try {
    const row = db.select().from(tasks).where(eq/tasks.id, id)).get();
    if (!row) throw newNotFoundError(`任务 ${id} 未找到`);
    return selectTaskSchema.parse(row);
  } catch (error) {
    throw ApiError.parse(error);
  }
},

服务层会用一个`try/catch`块来处理所有的错误,并通过`ApiError.parse()`方法将各种错误统一转换为标准格式。无论是Zod框架抛出的错误,还是自定义的错误信息,甚至是数据库驱动程序出现的异常,都会被转换成这种统一的错误类型。

10. 如何在所有地方都返回统一格式的错误信息

客户端绝对不应该去猜测错误信息应该是`{ message }`、`{ error }`这种形式,还是原始的堆栈跟踪信息。

在演示中,每一次失败都会被视作一个“信息包”:通过ApiError.parse将不同的错误源统一处理为一种JSON格式的数据结构

图10:所有的错误路径最终都会被转化为一种可预测的错误格式。

创建路由器的代码通过defaultHook实现了这种机制:

export function createRouter() {
  return new OpenAPIHono({
    defaultHook: (result, c) => {
      if (!result.success) {
        const apiError = ApiError.parse(result.error);
        return c.json(apiError.toResponseBody(), apiError.statusCode);
      }
    },
  });
}

所有的路由器都会经过这个处理流程。无论是路径参数中的无效UUID,POST请求体中缺失的字段,还是查询字符串中不合法的枚举值,这些错误在处理函数被执行之前,都会被转化为相同的错误响应格式。

ApiError.parse()就是这一处理流程中的关键部分:

public static parse(error: unknown): ApiError {
  if (error instanceof ApiError) return error;

  if (error instanceof ZodError) {
    return new ApiError('验证错误', {
      statusCode: 422,
      errors: error.flatten().fieldErrors,
    });
  }

  return new ApiError('内部服务器错误', { statusCode: 500 });
}

处理函数仍然会使用显式的switch语句来根据状态码进行不同的处理。这是有意为之的——每条路由都会明确指定自己可以返回哪些状态码。那些不应该返回403状态的处理器,其辅助函数中也不会包含403这个状态码。

这种重复性正是这种设计的目的所在。在这里,显式的处理方式反而比复杂的逻辑更加可靠。

11. 如何生成不会随代码变化而产生差异的文档

由于模式定义是与路由配置紧密关联的,因此OpenAPI实际上只是代码生成的副产品,而不是需要单独进行处理的任务。

Zod模式定义与路由配置相结合,生成OpenAPI JSON格式的文档以及交互式参考界面

图11:文档是根据与运行时代码相同的路由配置生成的。

export function configureOpenAPI(app: OpenAPIHono) {
  app.doc('/doc', {
    openapi: '3.0.0',
    info: {
      title: 'Bulletproof Tasks API',
      version: '1.0.0',
    },
  });

  app.get(
    '/reference',
    apiReference({
      spec: { url: '/doc' },
      theme: 'kepler',
      layout: 'modern',
      pageTitle: 'Bulletproof Tasks API',
    }),
  );
}
  • GET /doc会返回原始的OpenAPI JSON格式文档

  • GET /reference会提供一个交互式的参考界面

当你在路由配置文件中修改模式定义或响应状态码时,文档也会自动更新。因此根本不需要额外进行任何额外的文档生成步骤。

12. 如何让应用程序具备生产环境运行能力

仅在请求边界实施类型安全措施是不够的;配置流程及整个开发生命周期也都需要遵循同样的规范。

在系统启动时验证环境变量

import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z
    .enum(['development', 'test', 'production'])
    .default('development'),
  LOG_LEVEL: z
    .enum(['silent', 'debug', 'info', 'warn', 'error', 'fatal'])
    .default('info'),
  PORT: z.coerce.number().default(8080),
  DATABASE_URL: z.string().default('tasks.db'),
});

export const env = envSchema.parse(process.env);

如果某个必需的值缺失或格式不正确,系统会立即退出,并抛出明确的错误信息。这样总比在应用程序投入生产环境后才发现变量值为undefined要好得多。

优先使用结构化日志记录

在这个演示中,我们通过hono-pino使用了Pino框架,而不是简单的单行控制台日志工具。在生产环境中,我们应该使用JSON格式的日志;而在开发阶段,则需要易于阅读的日志形式。一个中间件就可以同时满足这两种需求,而且每个请求都可以附加一个UUID标识符。

确保程序能够干净地关闭

Docker和进程管理工具在终止进程之前会发送SIGTERM信号。我们必须妥善处理这个信号:首先关闭HTTP服务器,然后关闭数据库连接,最后才退出程序。如果不这样做,SQLite文件或Postgres连接池可能会处于未清理的状态。

保持应用程序运行环境的可移植性

Hono框架仅依赖于app模块中的Web标准API。这意味着同一个应用程序对象既可以在Node.js环境中运行,也可以在其他的运行环境中运行:

import { serve } from '@hono/node-server';
import { app } from '@/app';

serve({ fetch: app.fetch, port: env.PORT });

甚至可以在几乎不添加任何其他组件的边缘计算环境中运行。

这并不是一个简化的示例,而是整个应用程序架构的完整体现。

13. 这些模式在生产环境中的应用与扩展方式

Tasks API就是一个很好的范例。生产环境中的系统结构通常更为复杂:会涉及到文件上传、后台任务处理、多个软件包的协同使用,以及持续时间较长的工作流程。

ClipForge就是这样一个生产环境应用程序的实例。在这个项目中,上述这些设计理念得到了大规模的应用。它是一个基于Node.js、Hono、Zod、Drizzle、BullMQ和Nuxt构建的可自我托管的视频处理工具包。用户可以通过它上传视频文件,然后获得字幕、摘要、章节标记以及缩略图等输出结果。

它的架构结构如下所示:

apps/
├── api/        # Hono REST API
├── worker/     # BullMQ视频处理模块
└── web/        # Nuxt前端框架
packages/
├── shared/     # 公共使用的Zod数据结构、常量及类型
└── providers/  # 与OpenAI、Anthropic、Deepgram等服务的集成模块
ClipForge的架构包括Web接口、API、工作进程、共享模式、Postgres数据库、Redis缓存以及AI服务提供者

图12:ClipForge在Web接口、API以及工作进程包中都遵循“以合同为核心”的设计原则。

一个视频处理任务会依次经历上传、验证、提取音频等阶段,而在这些过程中并不会产生新的响应格式:

视频处理任务的各个阶段,从上传到完成处理,包括失败情况

图13:视频处理任务会按照既定的阶段顺序进行,而不会产生新的响应格式。

真正重要的是API仍然遵循“以合同为核心”的设计规则,而不是视频处理流程本身。

不同包之间共享的Zod模式

ClipForge将HTTP契约保存在`packages/shared`目录中,因此API、工作进程以及Web应用程序能够使用相同的术语表:

export const processingFeatureSchema = z.enum([
  'transcription',
  'subtitles',
  'summary',
  'chapters',
  'thumbnails',
]);

export const videoUploadSchema = z.object({
  title: z.string().min(3).max(200),
  description: z.string().max(2000).optional(),
  features: z
    .array(processingFeatureSchema)
    .default([
      'transcription',
      'subtitles',
      'summary',
      'chapters',
      'thumbnails',
    ],
  priority: z.enum(['low', 'normal', 'high']).default('normal'),
});

export const jobStatusSchema = z.object({
  jobId: z.string(),
  title: z.string().optional(),
  state: jobStateSchema,
  stage: jobStageSchema,
  progress: z.number().min(0).max(100),
  result: jobResultSchema.optional(),
  failedReason: z.string().optional(),
  createdAt: z.string(),
  updatedAt: z.string(),
});

当工作进程完成某个阶段时,它并不会生成新的响应格式,而是会使用API返回给客户端的相同模式来更新状态。

路由仍然决定了契约的内容

ClipForge中的上传接口与Tasks演示示例类似,只不过会处理多部分数据格式,并且会实施速率限制:

export const uploadVideo = createRoute({
  tags: ['videos'],
  method: 'post',
  path: '/videos/upload',
  middleware: [
    sessionMiddleware,
    apiKeysMiddleware,
    rateLimitMiddleware({
      windowMs: 60_000,
      max: 10,
      keyPrefix: 'ratelimit:upload',
    }),
  ] as const,
  request: {
    body: {
      content: {
        'multipart/form-data': {
          schema: z.object({
            file: z.instanceof(File),
            title: z.string().min(3).max(200),
            description: z.string().max(2000).optional(),
            features: z.string().optional(),
            priority: z.enum(['low', 'normal', 'high']).optional(),
          }),
        },
      },
    },
  },
  responses: {
    [HTTP_STATUS.ACCEPTED]: jsonContent(
      jobStatusSchema,
      '视频已接受处理',
    ),
    [HTTP_STATUS.BAD_REQUEST]: jsonApiErrorContent('请求无效'),
    [HTTP_STATUS.UNPROCESSABLE]: jsonApiErrorContent('验证失败'),
    [HTTP_STATUS.TOO_MANY_REQUESTS]: jsonApiErrorContent('超过了速率限制'),
  },
});

处理程序会验证输入数据,生成相应的作业记录,并将任务加入BullMQ队列中,随后返回代码`202 Accepted`以及详细的作业状态信息。实际的处理工作是由工作进程来完成的,而API本身仅起到契约层的作用。

ClipForge视频上传、任务队列处理及作业状态查询的序列图

图14:API负责接收上传请求并返回作业状态信息,而实际的处理工作则由工作进程来完成。

图14展示了一个包含五个参与者的序列图:Web界面、Hono API、BullMQ、工作进程以及Postgres数据库。该图清晰地展示了“接收上传请求”与“完成处理任务”之间的异步处理流程。下面是图中每个步骤的详细说明:

  1. Web界面向Hono API发送`POST /videos/upload`请求。

  2. Hono API会运行`Zod + 中间件`来验证输入数据、检查会话状态、实施速率限制等相关操作。

  3. Hono API会在Postgres数据库中插入相应的作业记录,确保在开始处理之前作业信息已经存在。

  4. Hono API会将`enqueue process-video`指令发送到BullMQ队列中。

  5. Hono API会立即向Web界面返回代码`202 + jobStatusSchema`。此时,客户端虽然已经获得了作业状态信息,但转录工作尚未完成。

  6. 稍后,BullMQ会将`process job`指令传递给工作进程。

  7. 工作进程会在后台执行`transcribe / analyze / thumbnails`等操作来完成转录及分析任务。

  8. 当各个处理阶段完成后,工作进程会将结果信息写入Postgres数据库中。

  9. Web界面会通过`GET /videos/jobs/{id}`请求向Hono API查询作业状态。

  10. Hono API会从Postgres数据库中读取最新的作业状态信息,并以与步骤5相同的格式返回结果,其中会包含更新后的`stage`、`progress`以及最终的`result`值。

因此,API始终只扮演契约层的作用:接收请求、存储数据、将任务加入队列并响应请求。实际的处理工作则由工作进程来完成,而UI界面则是通过定期查询作业状态信息来跟踪处理进度。

数据库模式仍然保持独立

ClipForge使用Drizzle将作业信息存储在Postgres数据库中:

export const jobs = pgTable('jobs', {
  id: varchar('id', { length: 36 }).primaryKey(),
  sessionId: varchar('session_id', { length: 36 }).notNull(),
  state: jobStateEnum('state').notNull().default('waiting'),
  stage: jobStageEnum('stage').notNull().default('uploading'),
  progress: integer('progress').notNull().default(0),
  title: varchar('title', { length: 200 }).notNull(),
  features: jsonb('features').$type>().notNull(),
  provider: varchar('provider', { length: 50 }).notNull().default('openai'),
  result: jsonb('result').$type&>, 
  failedReason: text('failed_reason'), 
  createdAt: timestamp('created_at', { withTimezone: true }) 
    .notNull() 
    .defaultNow(), 
  updatedAt: timestamp('updated_at', { withTimezone: true }) 
    .notNull() 
    .defaultNow(),
});
filePathsessionId这类字段存在持久化方面的问题。公开的jobStatusSchema并不需要暴露所有这些字段。这与在“Tasks”演示中采用的数据库与API分离的设计思路是一样的,只不过现在是应用到了实际的工作流程中。

同样的生产习惯依然适用

ClipForge在启动时会使用Zod来验证环境变量,在/doc/reference路径配置OpenAPI相关内容,通过ApiError来统一处理各种错误情况,并且在接收到信号时会关闭队列服务及Redis数据库。

这个道理很简单:如果一个小型的应用程序能够被正确地构建起来,那么大型应用程序也不需要采用不同的设计理念。它所需要的只是更多的包、更多的中间件,以及一些运行时间更长的任务而已,而这些基础依然建立在“以契约为核心”的设计原则之上。

结论

类型安全的API并不是为了添加更多的TypeScript代码,而是为了消除那些重复存在的信息源。

使用Hono和Zod,你可以做到以下这些:

  • 在运行时验证请求内容

  • 自动推断数据类型

  • 根据相同的路由定义生成OpenAPI文档

  • 有意识地将数据库模式与HTTP协议结构分开

  • 确保所有错误都会返回统一且可预测的错误信息格式

如果你想了解这些设计模式的最简单、最易于理解的实现方式,可以先从api-conf-demo开始学习。然后再看看ClipForge,了解当API应用于文件上传、任务队列或多阶段处理场景时,这些设计理念依然有效。

一旦你的路由定义真正成为了“契约”,那么文档内容就不会再出现偏差,处理逻辑也会变得更加简洁,而客户端也能获得一个值得信赖的API接口。

相关文章

技术实践

在React中处理高频实时数据:从环形缓冲区到离屏canvas技术

React在很多方面都表现得非常出色。但如果你曾经尝试过每秒向它传输数千个数据点,你就会很快意识到:React并不像一根能输送大量水流的消防水管,而更像是一根普通的花园浇水软管。 如果强迫它处理过多的数据,要么会导致“草坪被淹没”(即DOM结构变得混乱),要么会使得“管道爆裂”(也就是应用程序运行出现严重问题)。 还有另一个与上述观点相关的观察结果:你的笔记本电脑通常拥有8到16个CPU核心,而你的React应用程序几乎总是只使用其中的一个核心。主线程负责处理JavaScript代码、DOM操作、布局计算以及绘制工作;而其他核心则处于闲置状态,因为主线程实在难以维持每秒60帧的渲染速度。 这两

阅读全文
技术实践

如何利用vLLM来扩展大语言模型在AI智能体中的应用范围

在本教程中,我将向您展示如何使用vLLM来优化大型语言模型的推理性能,从而提升AI代理的工作效率。我会帮助您理解大型语言模型推理的原理,分析为什么AI代理的任务会引发GPU调度和内存压力问题,并探讨vLLM是如何被设计出来以提高处理能力的。 接下来,我们将运行一台本地的vLLM服务器,并通过其兼容OpenAI的API,使用AI代理与它进行连接。 目录 背景知识 先决条件 什么是大型语言模型推理? 大型语言模型推理如何利用CPU和GPU 为什么AI代理的任务难以处理 vLLM是如何为AI代理任务提供支持的 设计动机与架构 步骤1:安装vLLM 步骤2:启动vLLM服务器 步骤3:将AI代理连接到

阅读全文
技术实践

如何使用JavaScript构建基于浏览器的PDF过滤工具开发环境

PDF编辑并不仅限于添加签名或合并文档。有时,你只是想通过增加亮度、提升对比度、添加模糊效果、将其转换为灰度图,或者应用一些创意视觉效果来改善PDF的外观——而这一切都不需要打开Photoshop或安装任何桌面软件。 在这个教程中,你将使用JavaScript、PDF.js、Canvas API以及PDF-lib构建一个基于浏览器的PDF过滤器工具。用户可以上传PDF文件,预览每一页,叠加多个过滤层,应用预设效果,处理选定的页面,预览最终结果,重新命名文件,然后直接从浏览器中下载编辑后的PDF。 由于所有操作都在用户的设备上本地进行,因此上传的PDF文件永远不会离开用户的设备,这使得这个工具既

阅读全文
技术实践

如何在没有服务器的情况下为静态网站添加动态功能

静态网站目前正受到人们的青睐,这是有原因的。一个包含HTML、CSS和JavaScript文件的文件夹,加载速度很快,托管成本也很低,而且几乎不可能出现故障。 像 Astro 、 Eleventy 和 Hugo 这样的工具,能够利用Markdown文件和模板帮您生成这样的网站结构。而Netlify、Vercel以及Cloudflare Pages等托管服务,则可以通过内容分发网络来提供这些生成的网站内容,而且通常还是免费的。 不过,您的网站还需要具备实际的功能。读者可能想要留下评论,或者有人想通过电子邮件与您联系。也许您还想实时显示价格信息,需要用户登录后才能查看某些页面,或者在网站发布之前收

阅读全文