最近在群里看到有哥们为了给十几个 API 做版本控制,居然去折腾 Kong 网关 and K8s 路由,还要给每个版本拉个微服务分支。这一套折腾下来,版本没管好,运维先累瘫了。说实话,小团队天天去套大厂那一套“多版本网关动态路由”的方案,纯粹是给自己加戏,心智负担直接拉满。
大厂之所以在网关层做 API 分流,是因为他们后台服务跨着几十个团队,语言各异、数据库不通,只能用物理隔离加网关路由的方式续命。我们这种前后端一共才几个人的小团队,明明一套 Nest.js/Express 就能搞定的事情,非要搞黑盒网关,一旦路由配置出点偏差,排查起来简直是灾难。
对于我们来说,最痛的其实不是路由问题,而是前端老版本客户端没升级,后端改了数据库字段,导致接口字段直接崩溃。所以,API 版本管理本质上是 DTO(数据传输对象)的转换和向前兼容问题。
我们干脆在应用层直接用 TypeScript 和 Zod Schema 来做版本拦截与多版本 DTO 自动转换。核心逻辑很简单:路由层依然用同一个 Endpoint,或者通过简单的版本路由前缀(/v1/user, /v2/user)分发到同一个 Controller,然后根据请求带过来的版本号(可以放 Header 或 Query),直接在 Zod 的 transform 里做结构转换。
下面是我们目前正在跑的一套真实方案。不需要搞多套实体,用 Zod 直接搞定:
import { z } from 'zod';
// 原始 V1 版本的 DTO 定义const UserV1Schema = z.object({ id: z.string(), user_name: z.string(), // 老版本用的是下划线 avatar_url: z.string(), status: z.number(), // 老版本用 0/1 代表状态});
// 新 V2 版本的 DTO 定义const UserV2Schema = z.object({ id: z.string(), username: z.string(), // 驼峰命名 avatar: z.string(), // 字段精简 isActive: z.boolean(), // 状态改成布尔值 email: z.string().email(), // 新增字段});
type UserV1 = z.infer<typeof UserV1Schema>;type UserV2 = z.infer<typeof UserV2Schema>;
// 版本适配器:将 V2 的数据向下兼容转换成 V1export function adaptUserV2ToV1(v2Data: UserV2): UserV1 { return { id: v2Data.id, user_name: v2Data.username, avatar_url: v2Data.avatar, status: v2Data.isActive ? 1 : 0, };}
// 统一对外输出的 Schema,在 Schema 层面做版本分发和兼容export const UserResponseSchema = z.preprocess((val: any) => { // 假设 val 是我们从数据库查出来的最新 V2 格式数据 return val;}, z.custom<UserV2>()).transform((data, ctx) => { // 从上下文中获取客户端请求的版本号 const version = (ctx as any).meta?.version || 'v2';
if (version === 'v1') { // 自动兼容旧版本客户端,做字段平滑转换 try { const v2Parsed = UserV2Schema.parse(data); return adaptUserV2ToV1(v2Parsed); } catch (err) { // 降级处理或者抛出不兼容异常 throw new Error('Failed to adapt data to v1'); } }
// 默认返回 V2 数据 return UserV2Schema.parse(data);});
// 模拟 Express 控制器中的使用场景export async function getUserController(req: any, res: any) { const clientVersion = req.headers['x-api-version'] || 'v2';
const mockDbUser = { id: "usr_9527", username: "tianma", avatar: "https://avatar.io/tianma.png", isActive: true, email: "tianma@example.com" };
try { // 注入版本信息到解析上下文 const result = UserResponseSchema.safeParse(mockDbUser, { meta: { version: clientVersion } } as any);
if (!result.success) { return res.status(500).json({ error: "Data serialization error" }); }
return res.json(result.data); } catch (error) { return res.status(400).json({ error: "Version mismatch" }); }}这样搞的好处简直太多了。首先,代码极其直观,哪个字段在哪个版本被干掉了,哪个字段换了名字,在 adapt 函数里一目了然。其次,类型安全直接拉满,前端同事用 zod-to-ts 生成出来的类型跟后端也是完全匹配的,根本不需要猜。最爽的是,部署时还是同一个容器,根本不存在网关配置漂移导致的诡异 Bug。
对于日活没有上百万、开发团队就几个人的项目,千万别去碰微服务和网关路由那套大坑。老老实实在单体应用里把 DTO 转换写好,比什么都强。
你们目前是怎么做 API 版本控制的?也是用 DTO 适配器,还是直接任性地让前端改代码适配?