版本化API设计

要点

  • API 版本化解决的核心问题是:在引入破坏性变更时,不中断已有客户端的正常运行
  • URL 路径版本化(/api/v1//api/v2/)是 Hono 项目中最直接的方案,用 basePath()app.route() 组合即可完成
  • Header 版本化通过中间件解析 Accept 头的 vendor 媒体类型,路由表不感知版本差异
  • 查询参数版本化(?version=2)实现简单,但容易被客户端遗漏,不适合长期维护
  • 多版本并行运行时,业务逻辑层应该共享,版本差异集中在 DTO 转换层处理
  • 废弃一个版本需要给出时间线、响应 Sunset 头、提供迁移文档,不能直接下线

1. 为什么需要 API 版本化

一个对外提供服务的 API,运行一段时间之后,几乎一定会遇到需要改动的情况。字段要重命名、返回结构要调整、某个接口的语义要重新定义。这些改动如果直接应用到线上,已经接入的客户端就会出现兼容性问题——轻则字段缺失导致页面渲染异常,重则请求格式不匹配直接报错。

API 版本化的目标就是在引入这些变更时,让旧版本客户端继续正常工作,同时让新版本客户端使用改进后的接口。

版本化需要解决的三件事:

  1. 向后兼容:已有客户端不受影响,旧版本的 URL 和行为保持可用
  2. 支持破坏性变更:新版本可以修改字段名、删除接口、调整响应结构
  3. 客户端迁移:给已有调用方足够的时间和信息切换到新版本

Hono 的路由系统天然支持多版本并存——basePath()app.route() 和中间件机制提供了灵活的组织方式。

2. URL 路径版本化

URL 路径版本化是最常见的方案,版本号直接出现在 URL 中(GET /api/v1/usersGET /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 app

basePath 已经把 /api/v1/api/v2 写进了各自的路由定义,主入口挂载时用 '/' 就行。请求 /api/v1/users 命中 v1 的路由,请求 /api/v2/users 命中 v2 的路由。

如果每个版本内的路由按资源做了拆分(参照 06 模块化路由拆分),组合方式就是 basePath() + app.route() 的嵌套,v1 和 v2 各自挂载自己的 usersposts 等模块。每个版本内部的路由模块可以独立演进,互不干扰。

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.tsroutes/

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 废弃时间线

一个常见的废弃流程分四个阶段:

  1. 发布公告:在 API 文档和更新日志中声明 v1 进入维护期,给出预计下线日期
  2. 添加响应头:上线 DeprecationSunset 头,客户端可以开始检测和迁移
  3. 只修关键问题:v1 不再添加新特性,只修复安全漏洞和严重 bug
  4. 正式下线:到达 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 Gone404 Not Found 的区别在于语义:404 表示资源不存在,410 表示资源曾经存在但已永久移除。对于废弃的 API 版本,410 更准确。

延伸阅读

总结

Hono 的版本化 API 设计围绕路由组织展开,核心方案有两种:

  1. URL 路径版本化:每个版本用 basePath() 创建独立的路由树,通过 app.route() 挂载到主 app。结构清晰、调试方便,适合面向第三方的公开 API。
  2. Header 版本化:通过中间件解析 Accept 头的 vendor 媒体类型,路由表保持统一。URL 干净,适合内部服务间的调用。

多版本并行运行的关键是把版本差异隔离在 DTO 层,业务逻辑共享在 Service 层。版本废弃不能直接删除路由,要用 Sunset 头给出时间线,用 410 Gone 替代 404 标记已下线的版本。

下一篇看大型项目的路由组织——当路由数量增长到一定规模之后,如何按业务域拆分、如何组织中间件和错误处理。