版本化API设计
要点
- API 版本化解决的核心问题是:在引入破坏性变更时,不中断已有客户端的正常运行
- URL 路径版本化(
/api/v1/、/api/v2/)是 Hono 项目中最直接的方案,用basePath()和app.route()组合即可完成 - Header 版本化通过中间件解析
Accept头的 vendor 媒体类型,路由表不感知版本差异 - 查询参数版本化(
?version=2)实现简单,但容易被客户端遗漏,不适合长期维护 - 多版本并行运行时,业务逻辑层应该共享,版本差异集中在 DTO 转换层处理
- 废弃一个版本需要给出时间线、响应
Sunset头、提供迁移文档,不能直接下线
1. 为什么需要 API 版本化
一个对外提供服务的 API,运行一段时间之后,几乎一定会遇到需要改动的情况。字段要重命名、返回结构要调整、某个接口的语义要重新定义。这些改动如果直接应用到线上,已经接入的客户端就会出现兼容性问题——轻则字段缺失导致页面渲染异常,重则请求格式不匹配直接报错。
API 版本化的目标就是在引入这些变更时,让旧版本客户端继续正常工作,同时让新版本客户端使用改进后的接口。
版本化需要解决的三件事:
- 向后兼容:已有客户端不受影响,旧版本的 URL 和行为保持可用
- 支持破坏性变更:新版本可以修改字段名、删除接口、调整响应结构
- 客户端迁移:给已有调用方足够的时间和信息切换到新版本
Hono 的路由系统天然支持多版本并存——basePath()、app.route() 和中间件机制提供了灵活的组织方式。
2. URL 路径版本化
URL 路径版本化是最常见的方案,版本号直接出现在 URL 中(GET /api/v1/users、GET /api/v2/users)。客户端通过 URL 就能判断自己在调用哪个版本,调试时看请求日志也很直观。这种方案的好处是「版本号即路径」,缓存、日志、CDN 规则都不需要额外配置。
Hono 的 basePath() 可以给一组路由统一加上前缀,每个版本用独立的 basePath 创建:
// src/api/v1/index.ts
import { Hono } from 'hono'
const v1 = new Hono().basePath('/api/v1')
v1.get('/users', (c) => {
return c.json({
users: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
],
})
})
export default v1// src/api/v2/index.ts
import { Hono } from 'hono'
const v2 = new Hono().basePath('/api/v2')
// v2 返回结构做了调整:name 拆分为 firstName / lastName,外层增加 data 包裹
v2.get('/users', (c) => {
return c.json({
data: [
{ id: 1, firstName: 'Alice', lastName: 'Smith' },
{ id: 2, firstName: 'Bob', lastName: 'Jones' },
],
pagination: { page: 1, total: 2 },
})
})
export default v2然后在主入口挂载两个版本:
// src/index.ts
import { Hono } from 'hono'
import v1 from './api/v1'
import v2 from './api/v2'
const app = new Hono()
app.route('/', v1)
app.route('/', v2)
export default appbasePath 已经把 /api/v1 和 /api/v2 写进了各自的路由定义,主入口挂载时用 '/' 就行。请求 /api/v1/users 命中 v1 的路由,请求 /api/v2/users 命中 v2 的路由。
如果每个版本内的路由按资源做了拆分(参照 06 模块化路由拆分),组合方式就是 basePath() + app.route() 的嵌套,v1 和 v2 各自挂载自己的 users、posts 等模块。每个版本内部的路由模块可以独立演进,互不干扰。
3. Header 版本化
Header 版本化不在 URL 中出现版本号,而是通过请求头传递版本信息。常见的格式是自定义 vendor 媒体类型:
Accept: application/vnd.myapp.v2+json
vnd.myapp.v2 中的 v2 就是客户端请求的 API 版本。这种方案的好处是 URL 保持干净,/api/users 始终是同一个地址——版本信息对路径「透明」,不会污染缓存键和日志路径。
3.1 用中间件解析版本头
Hono 的中间件可以在路由处理之前拦截请求,提取版本信息并写入 Context:
// src/middleware/api-version.ts
import { createMiddleware } from 'hono/factory'
type ApiVersion = 'v1' | 'v2'
type Env = {
Variables: {
apiVersion: ApiVersion
}
}
export const apiVersioning = createMiddleware<Env>(async (c, next) => {
const accept = c.req.header('Accept') || ''
// 从 Accept 头提取版本号,格式:application/vnd.myapp.v2+json
const match = accept.match(/vnd\.myapp\.(v\d+)\+json/)
const version = match?.[1] as ApiVersion | undefined
// 未指定版本时默认为 v1
c.set('apiVersion', version || 'v1')
await next()
})中间件把解析出的版本存入 c.set('apiVersion', ...),后续路由通过 c.get('apiVersion') 读取。
3.2 在路由中根据版本分发
提取出版本之后,路由层按版本分发到不同的处理函数:
// src/routes/users.ts
import { Hono } from 'hono'
import { apiVersioning, type Env } from '../middleware/api-version'
const app = new Hono<Env>()
app.use('*', apiVersioning)
const handleV1 = (c: any) => {
return c.json({ users: [{ id: 1, name: 'Alice' }] })
}
const handleV2 = (c: any) => {
return c.json({
data: [{ id: 1, firstName: 'Alice', lastName: 'Smith' }],
pagination: { page: 1, total: 1 },
})
}
app.get('/users', (c) => {
const version = c.get('apiVersion')
if (version === 'v2') {
return handleV2(c)
}
return handleV1(c)
})
export default app这种方案的路由表只有一套 /users,不按版本拆分路由文件。版本差异集中在处理函数内部。如果版本分支变多,可以抽一个分发中间件统一处理,但通常两三个版本分支还不需要这层抽象。
4. 查询参数版本化
查询参数版本化在 URL 尾部附加版本参数:
GET /api/users?version=2
实现方式很直接——从 c.req.query() 读取版本号:
// src/routes/users.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/users', (c) => {
const version = c.req.query('version') || '1'
if (version === '2') {
return c.json({ data: [{ id: 1, firstName: 'Alice', lastName: 'Smith' }] })
}
return c.json({ users: [{ id: 1, name: 'Alice' }] })
})
export default app查询参数版本化的问题在于容易被忽略。客户端开发者构造请求时可能只写了 /api/users,忘了带 version=2,就会静默地命中 v1 的逻辑——这是一种「静默降级」,调试时不容易发现。在 URL 路径版本化和 Header 版本化里,版本号是显式声明的,遗漏的概率更低。
5. 三种版本化策略的对比
| 维度 | URL 路径 | Header | 查询参数 |
|---|---|---|---|
| 可读性 | 高,URL 自带版本信息 | 低,需要看请求头 | 中,参数在 URL 尾部 |
| 缓存友好 | 好,不同版本 URL 不同 | 需要注意 Vary 头 | 中,?version= 影响缓存键 |
| 浏览器调试 | 直接看地址栏 | 需要打开 DevTools | 看 URL 即可 |
| 客户端实现 | 改 URL 前缀 | 改请求头配置 | 加查询参数 |
| CDN / 网关识别 | 容易,按路径匹配 | 需要解析 header | 需要解析 query string |
| 适合场景 | 公开 API、第三方对接 | 内部服务、统一网关 | 临时过渡、低频接口 |
对于 Hono 项目,如果 API 面向外部第三方开发者,URL 路径版本化的维护成本通常最低。客户端文档里写清楚 /api/v1/users 和 /api/v2/users 的区别,对接方不需要额外配置请求头。
如果 API 主要服务于前端和移动端,Header 版本化可以让 URL 保持稳定,前端通过 axios 拦截器统一注入 Accept 头就行。
查询参数版本化更适合作为过渡手段——临时给某个接口加个版本参数,等正式版本方案落地后再迁移。
6. 多版本并行运行
6.1 共享业务逻辑层
多版本并存不代表业务逻辑要写两遍。两个版本之间的差异通常集中在数据结构和序列化方式上,核心的查询、校验、权限逻辑可以复用:
// src/services/user-service.ts
// 业务逻辑层,不感知 API 版本
export interface UserRecord {
id: number
firstName: string
lastName: string
email: string
}
export async function getUsers(): Promise<UserRecord[]> {
return [
{ id: 1, firstName: 'Alice', lastName: 'Smith', email: '[email protected]' },
{ id: 2, firstName: 'Bob', lastName: 'Jones', email: '[email protected]' },
]
}
export async function getUserById(id: number): Promise<UserRecord | null> {
return { id, firstName: 'Alice', lastName: 'Smith', email: '[email protected]' }
}// src/api/v1/dto.ts
import type { UserRecord } from '../../services/user-service'
export function toV1User(record: UserRecord) {
return {
id: record.id,
name: `${record.firstName} ${record.lastName}`,
email: record.email,
}
}// src/api/v2/dto.ts
import type { UserRecord } from '../../services/user-service'
export function toV2User(record: UserRecord) {
return {
id: record.id,
firstName: record.firstName,
lastName: record.lastName,
email: record.email,
}
}版本差异被隔离在 DTO(Data Transfer Object)层。Service 层只写一份,各版本各自负责转换。
6.2 版本路由引用各自的 DTO
// src/api/v1/routes/users.ts
import { Hono } from 'hono'
import { getUsers } from '../../../services/user-service'
import { toV1User } from '../dto'
const app = new Hono()
app.get('/users', async (c) => {
const records = await getUsers()
return c.json({ users: records.map(toV1User) })
})
export default app// src/api/v2/routes/users.ts
import { Hono } from 'hono'
import { getUsers } from '../../../services/user-service'
import { toV2User } from '../dto'
const app = new Hono()
app.get('/users', async (c) => {
const records = await getUsers()
return c.json({
data: records.map(toV2User),
pagination: { page: 1, total: records.length },
})
})
export default app两个版本的路由各自引用自己的 DTO,共享同一个 Service 层。目录结构是 src/services/ 放业务逻辑,src/api/v1/ 和 src/api/v2/ 各自放 dto.ts 和 routes/。
6.3 TypeScript 类型约束
版本化的 DTO 可以配合 TypeScript 类型做区分,避免在 v1 路由里误用 v2 的返回结构:
// src/api/v1/types.ts
export interface V1User {
id: number
name: string
email: string
}
export interface V1UserListResponse {
users: V1User[]
}
// src/api/v2/types.ts
export interface V2User {
id: number
firstName: string
lastName: string
email: string
}
export interface V2UserListResponse {
data: V2User[]
pagination: { page: number; total: number }
}DTO 函数的返回类型加上显式标注,编译器会在结构不匹配时报错:
// src/api/v1/dto.ts
import type { UserRecord } from '../../services/user-service'
import type { V1User } from './types'
export function toV1User(record: UserRecord): V1User {
return {
id: record.id,
name: `${record.firstName} ${record.lastName}`,
email: record.email,
}
}7. 版本废弃策略
7.1 使用 Sunset 头标记废弃
旧版本有客户端在用,直接删除路由会导致这些客户端报错。Sunset 头(RFC 8594)用来告知客户端某个 API 将在什么时间停止服务:
// src/middleware/deprecation.ts
import { createMiddleware } from 'hono/factory'
export const deprecationHeaders = createMiddleware(async (c, next) => {
await next()
c.header('Deprecation', 'true')
c.header('Sunset', 'Sat, 01 Mar 2027 00:00:00 GMT')
c.header('Link', '</docs/migration/v1-to-v2>; rel="successor-version"')
})把中间件挂到即将废弃的版本路由上:
// src/api/v1/index.ts
import { Hono } from 'hono'
import { deprecationHeaders } from '../../middleware/deprecation'
const v1 = new Hono().basePath('/api/v1')
v1.use('*', deprecationHeaders)
// ... 路由定义
export default v1客户端收到带有 Sunset 头的响应后,可以据此触发告警或自动迁移流程。
7.2 废弃时间线
一个常见的废弃流程分四个阶段:
- 发布公告:在 API 文档和更新日志中声明 v1 进入维护期,给出预计下线日期
- 添加响应头:上线
Deprecation和Sunset头,客户端可以开始检测和迁移 - 只修关键问题:v1 不再添加新特性,只修复安全漏洞和严重 bug
- 正式下线:到达 Sunset 日期后,v1 路由返回
410 Gone
// v1 下线后
const v1Gone = new Hono().basePath('/api/v1')
v1Gone.all('*', (c) => {
return c.json(
{
error: 'API version v1 has been retired',
successor: '/api/v2',
migration: '/docs/migration/v1-to-v2',
},
410
)
})410 Gone 和 404 Not Found 的区别在于语义:404 表示资源不存在,410 表示资源曾经存在但已永久移除。对于废弃的 API 版本,410 更准确。
延伸阅读
- 01-基础路由 —
basePath()和app.route()的基础用法 - 06-模块化路由拆分 — 版本内部的路由按资源拆分的组织方式
- RFC 8594 - The Sunset HTTP Header Field — Sunset 头的规范定义
- API Versioning Best Practices — Swagger 官方的版本化实践指南
总结
Hono 的版本化 API 设计围绕路由组织展开,核心方案有两种:
- URL 路径版本化:每个版本用
basePath()创建独立的路由树,通过app.route()挂载到主 app。结构清晰、调试方便,适合面向第三方的公开 API。 - Header 版本化:通过中间件解析 Accept 头的 vendor 媒体类型,路由表保持统一。URL 干净,适合内部服务间的调用。
多版本并行运行的关键是把版本差异隔离在 DTO 层,业务逻辑共享在 Service 层。版本废弃不能直接删除路由,要用 Sunset 头给出时间线,用 410 Gone 替代 404 标记已下线的版本。
下一篇看大型项目的路由组织——当路由数量增长到一定规模之后,如何按业务域拆分、如何组织中间件和错误处理。