最近在群里看到有哥们为了给十几个 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 的数据向下兼容转换成 V1
export 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 适配器,还是直接任性地让前端改代码适配?