如何使用Hono和Zod构建类型安全的API
如果你之前曾经开发过 Node.js API,那么你就应该了解这种麻烦:TypeScript 中定义的类型与运行时验证的结果不一致,而 OpenAPI 文档的内容也与这两者都不同。 有时有人会在接口中添加新的字段,但相应的模式文件却从未得到更新。因此,文档内容会一直保持过时的状态,直到有客户端提交错误报告为止。这种情况下,既不会出现编译错误,也不会有测试失败的情况——这三个信息来源就这样出现了分歧。 在本教程中,你将学习如何使用 Hono 和 Zod 将这些问题统一起来。你会学到我在实际开发中使用的那些模式,包括我在维护的开源视频处理工具包 ClipForge 中所采用的方案。这些方法能够确保
如果你之前曾经开发过 Node.js API,那么你就应该了解这种麻烦:TypeScript 中定义的类型与运行时验证的结果不一致,而 OpenAPI 文档的内容也与这两者都不同。
有时有人会在接口中添加新的字段,但相应的模式文件却从未得到更新。因此,文档内容会一直保持过时的状态,直到有客户端提交错误报告为止。这种情况下,既不会出现编译错误,也不会有测试失败的情况——这三个信息来源就这样出现了分歧。
在本教程中,你将学习如何使用 Hono 和 Zod 将这些问题统一起来。你会学到我在实际开发中使用的那些模式,包括我在维护的开源视频处理工具包 ClipForge 中所采用的方案。这些方法能够确保验证规则、类型定义和文档内容在设计上保持一致。
读完本文后,你应该能够做到以下几件事:
使用一个统一的 Zod 模式文件来处理运行时验证、TypeScript 类型定义以及 OpenAPI 文档格式
合理设计路由结构,确保接口契约与处理逻辑相互分离
将数据库模式和 HTTP 接口模式明确划分为两个独立的层次
确保所有错误路径都能返回统一格式的错误信息
将这些设计模式应用到小型任务 API 中,进而扩展到复杂的多服务系统中
先决条件
为了充分理解本文的内容,你需要掌握以下基础知识:
JavaScript 语言及基础类型的概念
REST API 的工作原理(路由、请求体、状态码等)
对 Node.js 的基本了解,包括如何安装包和运行脚本
你不需要事先具备使用 Hono、Zod 或 Drizzle 的经验。
目录
1. 数据描述不一致的问题
大多数TypeScript API最终都会为相同的数据维护三种不同的描述方式:
运行时验证会在请求到达时进行检查
TypeScript类型是编译器在构建阶段能够理解的数据结构
API文档,即你向使用者提供的规范说明
这些描述分别保存在不同的文件中,更新的时间安排也各不相同,而且它们之间彼此无法相互参考。
图1:相同的数据被三种不同的方式描述,而这些描述往往不同步。
手工编写的接口在运行时会被忽略;Joi或Yup等工具可以验证数据,但不会自动提供类型信息;OpenAPI文件通常也是手动编辑的,甚至有时根本不会被修改。
解决这个问题的方法不是“更加小心”,而是使用一种能够同时生成这三种描述方式的定义方式。
图2:一个统一的规范定义能够生成三种不同的输出结果。
当你通过@hono/zod-openapi组合使用Hono和Zod时,就能实现这一目标。
2. 什么是Hono?
Hono是一个基于Web标准API构建的小型、快速的Web框架。它使用的Request和Response等基本数据类型在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就会:
在运行时验证该数据结构是否正确
使用
z.infer推导出对应的TypeScript类型当你添加
.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. 一个架构,三种用途
以下是本文后续内容所依据的思维模型:
图3: taskSchema是验证、类型定义及文档生成的唯一依据。
运行时验证:错误数据在到达处理函数之前就会被拒绝,且会以结构化的方式显示错误信息,而不会生成堆栈跟踪。
TypeScript类型:类型信息是通过
z.infer<typeof schema>从架构中推导出来的,而不是单独维护的。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架构。只不过所使用的工具更加先进而已:
图4:仍然是MVC架构。路由和处理器对应控制器,规范对应契约,服务对应模型。
模型负责处理自身的业务逻辑及数据库访问操作。
视图/契约规范定义了数据在数据库端以及HTTP接口端的呈现形式。
控制器通过路由来声明契约内容,而处理器则负责执行这些契约规定。
通过演示API发出的请求流程如下所示:
图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请求可以修改任意字段。
图6:请求接口模板都是基于同一个基础资源模板衍生而来的。
我们使用同一个数据源,但创建了三种不同的合同/接口格式。
7. 如何将数据库模式与API模式分开
人们很容易认为数据库中的记录与API返回的数据结构是相同的。在简单的演示环境中,这两种数据结构确实看起来一样;但在实际生产环境中,它们会存在差异。
因此,我们应该有意识地将它们保存在不同的文件中:
图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(),
});
一个表格定义实际上会生成三种输出:
从列中推断出的TypeScript类型
使用
drizzle-kit生成的SQL迁移脚本通过
drizzle-zod生成的Zod模式定义
这些在数据库层面上非常有用,但它们并不能替代你的HTTP接口模式。
一旦你添加了内部的archivedAt列,或者使用了计算字段来生成API响应,而这些内容并不属于数据库表结构中实际的列,那么这种分离方式就会显示出它的价值。这样的设计使得代码的修改变得更为容易,而不会导致繁琐的重构工作。
图8:服务决定了客户端能够看到哪些信息。
8. 如何将路由定义为契约
在这种架构中,一个路由并不仅仅“处理请求”。实际上,路由本身就定义了一整套契约规范:包括该方法、路径、请求数据结构以及响应数据结构。
图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 }`这种形式,还是原始的堆栈跟踪信息。
在演示中,每一次失败都会被视作一个“信息包”:
图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实际上只是代码生成的副产品,而不是需要单独进行处理的任务。
图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等服务的集成模块
图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本身仅起到契约层的作用。
图14:API负责接收上传请求并返回作业状态信息,而实际的处理工作则由工作进程来完成。
图14展示了一个包含五个参与者的序列图:Web界面、Hono API、BullMQ、工作进程以及Postgres数据库。该图清晰地展示了“接收上传请求”与“完成处理任务”之间的异步处理流程。下面是图中每个步骤的详细说明:
Web界面向Hono API发送`POST /videos/upload`请求。
Hono API会运行`Zod + 中间件`来验证输入数据、检查会话状态、实施速率限制等相关操作。
Hono API会在Postgres数据库中插入相应的作业记录,确保在开始处理之前作业信息已经存在。
Hono API会将`enqueue process-video`指令发送到BullMQ队列中。
Hono API会立即向Web界面返回代码`202 + jobStatusSchema`。此时,客户端虽然已经获得了作业状态信息,但转录工作尚未完成。
稍后,BullMQ会将`process job`指令传递给工作进程。
工作进程会在后台执行`transcribe / analyze / thumbnails`等操作来完成转录及分析任务。
当各个处理阶段完成后,工作进程会将结果信息写入Postgres数据库中。
Web界面会通过`GET /videos/jobs/{id}`请求向Hono API查询作业状态。
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(),
});
filePath和sessionId这类字段存在持久化方面的问题。公开的jobStatusSchema并不需要暴露所有这些字段。这与在“Tasks”演示中采用的数据库与API分离的设计思路是一样的,只不过现在是应用到了实际的工作流程中。
同样的生产习惯依然适用
ClipForge在启动时会使用Zod来验证环境变量,在/doc和/reference路径配置OpenAPI相关内容,通过ApiError来统一处理各种错误情况,并且在接收到
这个道理很简单:如果一个小型的应用程序能够被正确地构建起来,那么大型应用程序也不需要采用不同的设计理念。它所需要的只是更多的包、更多的中间件,以及一些运行时间更长的任务而已,而这些基础依然建立在“以契约为核心”的设计原则之上。
结论
类型安全的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等托管服务,则可以通过内容分发网络来提供这些生成的网站内容,而且通常还是免费的。 不过,您的网站还需要具备实际的功能。读者可能想要留下评论,或者有人想通过电子邮件与您联系。也许您还想实时显示价格信息,需要用户登录后才能查看某些页面,或者在网站发布之前收
阅读全文