Hono 生产实战 07:OpenAPI、RPC 和接口文档治理
前后端分离项目里,接口文档往往有两种结局。
第一种是没人写。
第二种是写了但没人信。
比没有文档更糟糕的,是过期文档。
前端照着文档调接口,发现字段不对;后端说“你看代码吧”;测试同学只能打开浏览器抓包;新人入职第一周都在猜接口。
Hono 项目想做得稳,接口契约必须有治理。
这篇文章讲 Hono 里三种常见做法:
重点不是“哪一个永远最好”,而是不同团队阶段应该怎么选。
技术栈快照时间:2026-06-23。Hono RPC、@hono/zod-openapi、Swagger UI 中间件会持续更新,生产使用前以官方文档为准。
一、接口文档的本质是契约
接口文档不是给领导看的。
它是前端、后端、测试、客户端、第三方接入方之间的契约。
一个好契约至少要回答:
1 2 3 4 5 6 7 8 9
| 请求路径是什么 请求方法是什么 需要哪些 header path/query/body 参数是什么 响应结构是什么 错误码有哪些 鉴权方式是什么 字段是否可空 字段是否废弃
|
如果文档只写:
1 2
| POST /api/user/create 创建用户
|
那它没有太大价值。
真正有价值的是结构化契约。
二、手写文档适合早期,但容易漂移
项目刚开始时,手写 Markdown 很快。
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| ## 创建文章
POST /api/posts
Request:
{ "title": "string", "content": "string" }
Response:
{ "data": { "id": "string" } }
|
这种方式适合:
但它的问题也很明显:
1 2 3 4 5
| 代码改了文档不一定改 字段可空性很难表达 错误响应经常漏写 无法自动生成 SDK 无法自动做契约测试
|
所以手写文档可以作为起步方式,但不要长期依赖它承担生产契约。
三、OpenAPI 适合对外契约
OpenAPI 的好处是标准化。
它可以描述 HTTP API 的路径、参数、请求体、响应、错误、鉴权。
有了 OpenAPI,你可以做很多事情:
1 2 3 4 5 6
| 生成 Swagger UI 生成前端 SDK 生成客户端代码 生成 mock server 做契约测试 给第三方接入方文档
|
Hono 生态里常见做法是使用 @hono/zod-openapi。
它把 Zod schema 和 OpenAPI route 定义结合起来。
这样你不用维护两套东西:
1 2
| 一套校验 schema 一套接口文档 schema
|
而是让 schema 同时服务于运行时校验和文档生成。
四、用 zod-openapi 定义接口
一个简单例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'
const app = new OpenAPIHono()
const PostSchema = z.object({ id: z.string().openapi({ example: 'post_01JZ7Q5X4R8E9K7C2V3B', }), title: z.string().openapi({ example: 'Hono 接口设计实践', }), content: z.string(), })
const ErrorSchema = z.object({ error: z.object({ code: z.string(), message: z.string(), }), })
const createPostRoute = createRoute({ method: 'post', path: '/api/posts', request: { body: { content: { 'application/json': { schema: z.object({ title: z.string().min(1), content: z.string().min(1), }), }, }, }, }, responses: { 201: { description: '创建成功', content: { 'application/json': { schema: z.object({ data: PostSchema, }), }, }, }, 400: { description: '请求参数错误', content: { 'application/json': { schema: ErrorSchema, }, }, }, }, })
app.openapi(createPostRoute, async (c) => { const input = c.req.valid('json')
const post = await createPost(input)
return c.json({ data: post, }, 201) })
|
这个例子里,最关键的是:
1 2
| schema 不只用于文档 schema 也用于接口校验
|
接口实现和接口契约被放在同一个结构里,漂移概率会低很多。
五、暴露 OpenAPI JSON
定义完 route 后,可以暴露 OpenAPI 文档:
1 2 3 4 5 6 7
| app.doc('/openapi.json', { openapi: '3.0.0', info: { title: 'My Hono API', version: '1.0.0', }, })
|
再配合 Swagger UI:
1 2 3 4 5
| import { swaggerUI } from '@hono/swagger-ui'
app.get('/docs', swaggerUI({ url: '/openapi.json', }))
|
这样团队就有了一个可访问的接口文档页面。
生产环境建议做访问控制:
1 2 3 4
| 开发环境:直接开放 /docs 测试环境:登录后访问 /docs 生产环境:只对内网或管理账号开放 公开 API:提供专门的公开文档站点
|
不要把内部管理接口文档无脑暴露到公网。
六、错误响应也要进文档
很多团队只写成功响应。
这是不够的。
前端真正需要处理的是:
1 2 3 4 5 6 7 8
| 400 参数错误 401 未登录 403 无权限 404 不存在 409 状态冲突 422 业务校验失败 429 限流 500 服务错误
|
建议统一错误结构:
1 2 3 4 5 6 7 8 9 10 11 12
| const ErrorResponseSchema = z.object({ error: z.object({ code: z.string().openapi({ example: 'POST_TITLE_REQUIRED', }), message: z.string().openapi({ example: '文章标题不能为空', }), details: z.unknown().optional(), }), requestId: z.string().optional(), })
|
然后在每个 route 的 responses 里复用。
错误码也要有命名规范:
1 2 3 4 5 6
| AUTH_REQUIRED PERMISSION_DENIED POST_NOT_FOUND POST_STATUS_CONFLICT PAYLOAD_TOO_LARGE RATE_LIMITED
|
前端不能只靠中文 message 判断业务逻辑。
真正稳定的是 error code。
七、Hono RPC 适合内部前后端协作
Hono RPC 的思路和 OpenAPI 不一样。
OpenAPI 更像标准契约。
RPC 更像 TypeScript 项目之间共享类型能力。
如果你的前端和后端都在一个 monorepo 里,或者都使用 TypeScript,那么 Hono RPC 很舒服。
后端导出 app 类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import { Hono } from 'hono'
const app = new Hono()
const routes = app .get('/api/posts/:id', async (c) => { const id = c.req.param('id')
return c.json({ data: { id, title: 'Hono RPC 入门', }, }) }) .post('/api/posts', async (c) => { const input = await c.req.json()
return c.json({ data: { id: crypto.randomUUID(), ...input, }, }, 201) })
export type AppType = typeof routes export default app
|
前端使用 hc 创建客户端:
1 2 3 4 5 6 7 8 9 10 11 12
| import { hc } from 'hono/client' import type { AppType } from '@my-app/api'
const client = hc<AppType>('https://api.example.com')
const res = await client.api.posts[':id'].$get({ param: { id: 'post_123', }, })
const data = await res.json()
|
这样前端调用路径、参数结构、返回类型都能得到 TypeScript 提示。
对于内部项目,这个体验非常好。
八、RPC 不是公开 API 文档的替代品
RPC 很适合内部团队。
但如果你要给第三方开放 API,OpenAPI 仍然更合适。
原因很简单:
1 2 3 4
| 第三方不一定用 TypeScript 第三方不一定在你的 monorepo 里 第三方需要稳定版本和文档页面 第三方需要 SDK、示例、错误码、鉴权说明
|
所以我的建议是:
1 2 3
| 内部前后端:Hono RPC 公开 API:OpenAPI 需要同时支持:RPC 提升内部效率,OpenAPI 管理外部契约
|
不要把这两个东西对立起来。
它们解决的问题不同。
九、接口版本怎么设计
接口版本越早想清楚越好。
常见方式:
1 2 3
| /api/v1/posts /api/v2/posts Accept: application/vnd.example.v1+json
|
大多数中小项目,路径版本最直观:
1 2 3 4 5
| const v1 = new OpenAPIHono()
v1.openapi(createPostRoute, createPostHandler)
app.route('/api/v1', v1)
|
版本管理原则:
1 2 3 4 5 6
| 新增字段通常不升版本 删除字段必须升版本 改变字段语义必须升版本 改变错误码语义必须升版本 改变分页规则要谨慎 旧版本要有明确下线时间
|
不要今天改一个字段,明天告诉前端“你刷新一下类型”。
契约稳定,是协作效率的基础。
十、契约测试
有了 OpenAPI,不代表万事大吉。
你还要确保实际响应符合契约。
最低限度,要给关键接口写测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import { describe, expect, it } from 'vitest' import app from '../src/app'
describe('POST /api/posts', () => { it('creates post', async () => { const res = await app.request('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Hello', content: 'World', }), })
expect(res.status).toBe(201)
const body = await res.json() expect(body.data.id).toBeTypeOf('string') expect(body.data.title).toBe('Hello') }) })
|
如果有条件,可以用 OpenAPI schema 做更严格的响应校验。
但不要一开始追求复杂测试平台。
先把核心接口的成功和失败响应测起来。
十一、接口文档治理清单
一个生产 Hono 项目,我建议至少做到:
1 2 3 4 5 6 7 8
| 1. 所有 API 都有统一响应结构 2. 所有错误都有稳定 error code 3. 所有入参使用 schema 校验 4. 核心接口进入 OpenAPI 或 RPC 契约 5. 文档页面有访问控制 6. 公开 API 有版本号 7. breaking change 有迁移说明 8. 关键接口有契约测试
|
不要等接口数量超过 100 个再补文档。
那时候补文档不是治理,是考古。
Hono 很适合把契约写在代码旁边。
用好这一点,前后端协作会轻很多。