Skip to content

第 11 篇:API 版本与兼容性 —— 别让你的客户因为升级而流失

在前面的文章中,我们探讨了配置管理等重要话题。现在,让我们关注另一个关键的横切关注点:API 版本与兼容性管理。

API 是系统与外部世界交互的接口,不正确的版本管理可能导致客户流失、系统不稳定、维护成本增加等问题。

URL 版本 vs Header 版本

API 版本管理有两种主要方式:URL 版本和 Header 版本。

URL 版本

typescript
// URL 版本实现
// 例如: /api/v1/users, /api/v2/users

// API 版本枚举
export enum ApiVersion {
  V1 = 'v1',
  V2 = 'v2',
  V3 = 'v3',
}

// 版本路由模块
@Module({
  imports: [
    // V1 版本模块
    forwardRef(() => ApiV1Module),
    // V2 版本模块
    forwardRef(() => ApiV2Module),
    // V3 版本模块
    forwardRef(() => ApiV3Module),
  ],
})
export class ApiVersionModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      // V1 版本路由
      .apply(ApiVersionMiddleware)
      .forRoutes({ path: 'api/v1/*', method: RequestMethod.ALL })
      // V2 版本路由
      .apply(ApiVersionMiddleware)
      .forRoutes({ path: 'api/v2/*', method: RequestMethod.ALL })
      // V3 版本路由
      .apply(ApiVersionMiddleware)
      .forRoutes({ path: 'api/v3/*', method: RequestMethod.ALL });
  }
}

// API 版本中间件
@Injectable()
export class ApiVersionMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const urlParts = req.url.split('/');
    const version = urlParts[2]; // /api/v1/users 中的 v1
    
    // 将版本信息附加到请求对象
    (req as any).apiVersion = version;
    
    // 设置响应头
    res.setHeader('API-Version', version);
    
    next();
  }
}

// V1 版本控制器
@Controller('api/v1/users')
export class UsersV1Controller {
  constructor(private readonly usersService: UsersService) {}

  @Get(':id')
  async getUser(@Param('id') id: string): Promise<UserV1Dto> {
    const user = await this.usersService.findById(id);
    return this.mapToV1Dto(user);
  }

  private mapToV1Dto(user: User): UserV1Dto {
    return {
      id: user.id,
      username: user.username,
      email: user.email,
      createdAt: user.createdAt,
    };
  }
}

// V2 版本控制器
@Controller('api/v2/users')
export class UsersV2Controller {
  constructor(private readonly usersService: UsersService) {}

  @Get(':id')
  async getUser(@Param('id') id: string): Promise<UserV2Dto> {
    const user = await this.usersService.findById(id);
    return this.mapToV2Dto(user);
  }

  private mapToV2Dto(user: User): UserV2Dto {
    return {
      id: user.id,
      username: user.username,
      email: user.email,
      fullName: `${user.firstName} ${user.lastName}`,
      createdAt: user.createdAt,
      updatedAt: user.updatedAt,
    };
  }
}

Header 版本

typescript
// Header 版本实现
// 例如: Accept: application/vnd.myapp.v1+json

// Header 版本中间件
@Injectable()
export class HeaderVersionMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const acceptHeader = req.headers['accept'] as string;
    let version = ApiVersion.V1; // 默认版本

    if (acceptHeader) {
      // 解析 Accept 头中的版本信息
      const versionMatch = acceptHeader.match(/application\/vnd\.myapp\.v(\d+)\+json/);
      if (versionMatch) {
        const versionNumber = versionMatch[1];
        version = `v${versionNumber}` as ApiVersion;
      }
    }

    // 将版本信息附加到请求对象
    (req as any).apiVersion = version;
    
    // 设置响应头
    res.setHeader('API-Version', version);
    
    next();
  }
}

// 统一的 API 控制器
@Controller('api/users')
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
    private readonly versionService: ApiVersionService,
  ) {}

  @Get(':id')
  async getUser(
    @Param('id') id: string,
    @Request() req,
  ): Promise<any> {
    const user = await this.usersService.findById(id);
    const version = (req as any).apiVersion;
    
    return this.versionService.transformUser(user, version);
  }
}

// 版本转换服务
@Injectable()
export class ApiVersionService {
  transformUser(user: User, version: ApiVersion): any {
    switch (version) {
      case ApiVersion.V1:
        return {
          id: user.id,
          username: user.username,
          email: user.email,
          createdAt: user.createdAt,
        };
      
      case ApiVersion.V2:
        return {
          id: user.id,
          username: user.username,
          email: user.email,
          fullName: `${user.firstName} ${user.lastName}`,
          createdAt: user.createdAt,
          updatedAt: user.updatedAt,
        };
      
      case ApiVersion.V3:
        return {
          id: user.id,
          username: user.username,
          email: user.email,
          profile: {
            firstName: user.firstName,
            lastName: user.lastName,
            avatar: user.avatar,
          },
          timestamps: {
            created: user.createdAt,
            updated: user.updatedAt,
            lastLogin: user.lastLoginAt,
          },
        };
      
      default:
        throw new BadRequestException(`Unsupported API version: ${version}`);
    }
  }
}

字段废弃策略(deprecated + 替代字段)

正确的字段废弃策略能够帮助客户端平滑过渡到新版本:

typescript
// API 版本 DTO 定义
export class UserV1Dto {
  id: string;
  username: string;
  email: string;
  createdAt: Date;
}

export class UserV2Dto {
  id: string;
  username: string;
  email: string;
  
  // 新增字段
  firstName: string;
  lastName: string;
  
  // 废弃字段标记
  @Deprecated('Use firstName and lastName instead')
  fullName?: string;
  
  createdAt: Date;
  updatedAt: Date;
}

export class UserV3Dto {
  id: string;
  username: string;
  email: string;
  
  // 重构为嵌套对象
  profile: {
    firstName: string;
    lastName: string;
    avatar?: string;
  };
  
  timestamps: {
    created: Date;
    updated: Date;
    lastLogin?: Date;
  };
  
  // 明确标记已废弃的字段
  @Deprecated('Use profile.firstName instead', true) // true 表示已移除
  firstName?: never;
  
  @Deprecated('Use profile.lastName instead', true)
  lastName?: never;
}

// 废弃注解装饰器
export function Deprecated(message: string, removed: boolean = false): PropertyDecorator {
  return function (target: Object, propertyKey: string | symbol) {
    Reflect.defineMetadata('deprecated', { message, removed }, target, propertyKey);
  };
}

// API 文档生成器
@Injectable()
export class ApiDocumentationService {
  generateSchema(version: ApiVersion): any {
    switch (version) {
      case ApiVersion.V1:
        return this.generateV1Schema();
      
      case ApiVersion.V2:
        return this.generateV2Schema();
      
      case ApiVersion.V3:
        return this.generateV3Schema();
      
      default:
        throw new BadRequestException(`Unsupported API version: ${version}`);
    }
  }

  private generateV2Schema(): any {
    const schema = {
      type: 'object',
      properties: {
        id: { type: 'string' },
        username: { type: 'string' },
        email: { type: 'string', format: 'email' },
        firstName: { type: 'string' },
        lastName: { type: 'string' },
        createdAt: { type: 'string', format: 'date-time' },
        updatedAt: { type: 'string', format: 'date-time' },
      },
      deprecatedProperties: {
        fullName: {
          message: 'Use firstName and lastName instead',
          willBeRemovedIn: 'v3',
        },
      },
    };

    return schema;
  }
}

// 响应拦截器处理废弃字段
@Injectable()
export class DeprecationInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const version = (request as any).apiVersion;
    
    return next.handle().pipe(
      map((data) => {
        if (version === ApiVersion.V2) {
          // 在 V2 响应中包含废弃字段的警告头
          const response = context.switchToHttp().getResponse();
          response.setHeader('Warning', '299 - "fullName field is deprecated, use firstName and lastName instead"');
        }
        
        return data;
      }),
    );
  }
}

向后兼容的黄金法则

保持向后兼容是 API 版本管理的核心原则:

typescript
// 向后兼容检查服务
@Injectable()
export class CompatibilityCheckService {
  private readonly breakingChanges: Map<ApiVersion, string[]> = new Map();

  constructor() {
    this.initializeBreakingChanges();
  }

  private initializeBreakingChanges(): void {
    // 定义各版本的破坏性变更
    this.breakingChanges.set(ApiVersion.V1, []);
    this.breakingChanges.set(ApiVersion.V2, [
      'Removed legacy authentication endpoint',
      'Changed error response format',
    ]);
    this.breakingChanges.set(ApiVersion.V3, [
      'Removed deprecated user fields',
      'Changed pagination parameters',
    ]);
  }

  // 检查版本兼容性
  checkCompatibility(
    fromVersion: ApiVersion,
    toVersion: ApiVersion,
  ): { compatible: boolean; breakingChanges: string[] } {
    if (fromVersion === toVersion) {
      return { compatible: true, breakingChanges: [] };
    }

    // 确定版本顺序
    const versions = [ApiVersion.V1, ApiVersion.V2, ApiVersion.V3];
    const fromIndex = versions.indexOf(fromVersion);
    const toIndex = versions.indexOf(toVersion);

    if (fromIndex > toIndex) {
      // 降级通常更安全
      return { compatible: true, breakingChanges: [] };
    }

    // 升级时检查破坏性变更
    const breakingChanges: string[] = [];
    for (let i = fromIndex + 1; i <= toIndex; i++) {
      const versionBreakingChanges = this.breakingChanges.get(versions[i]) || [];
      breakingChanges.push(...versionBreakingChanges);
    }

    return {
      compatible: breakingChanges.length === 0,
      breakingChanges,
    };
  }

  // 生成迁移指南
  generateMigrationGuide(
    fromVersion: ApiVersion,
    toVersion: ApiVersion,
  ): MigrationGuide {
    const compatibility = this.checkCompatibility(fromVersion, toVersion);
    
    return {
      fromVersion,
      toVersion,
      compatible: compatibility.compatible,
      breakingChanges: compatibility.breakingChanges,
      migrationSteps: this.getMigrationSteps(fromVersion, toVersion),
      deprecationWarnings: this.getDeprecationWarnings(toVersion),
    };
  }

  private getMigrationSteps(
    fromVersion: ApiVersion,
    toVersion: ApiVersion,
  ): MigrationStep[] {
    const steps: MigrationStep[] = [];

    // 根据版本差异生成具体步骤
    if (fromVersion === ApiVersion.V1 && toVersion >= ApiVersion.V2) {
      steps.push({
        version: ApiVersion.V2,
        changes: [
          'Replace fullName with firstName and lastName',
          'Handle new updatedAt field',
        ],
        impact: 'low',
      });
    }

    if (fromVersion < ApiVersion.V3 && toVersion === ApiVersion.V3) {
      steps.push({
        version: ApiVersion.V3,
        changes: [
          'Update user object structure to use profile and timestamps',
          'Remove usage of deprecated firstName and lastName fields',
        ],
        impact: 'high',
      });
    }

    return steps;
  }

  private getDeprecationWarnings(version: ApiVersion): DeprecationWarning[] {
    const warnings: DeprecationWarning[] = [];

    switch (version) {
      case ApiVersion.V2:
        warnings.push({
          field: 'fullName',
          message: 'Will be removed in v3',
          alternative: 'firstName and lastName',
        });
        break;

      case ApiVersion.V3:
        warnings.push({
          field: 'firstName',
          message: 'Removed in v3',
          alternative: 'profile.firstName',
        });
        warnings.push({
          field: 'lastName',
          message: 'Removed in v3',
          alternative: 'profile.lastName',
        });
        break;
    }

    return warnings;
  }
}

interface MigrationGuide {
  fromVersion: ApiVersion;
  toVersion: ApiVersion;
  compatible: boolean;
  breakingChanges: string[];
  migrationSteps: MigrationStep[];
  deprecationWarnings: DeprecationWarning[];
}

interface MigrationStep {
  version: ApiVersion;
  changes: string[];
  impact: 'low' | 'medium' | 'high';
}

interface DeprecationWarning {
  field: string;
  message: string;
  alternative: string;
}

自动化:OpenAPI diff 工具检测破坏性变更

自动化工具可以帮助检测 API 变更中的破坏性变更:

typescript
// API 变更检测服务
@Injectable()
export class ApiChangeDetectionService {
  constructor(
    private readonly compatibilityCheckService: CompatibilityCheckService,
  ) {}

  // 比较两个版本的 OpenAPI 规范
  async compareApiSpecifications(
    oldSpec: any,
    newSpec: any,
  ): Promise<ApiChangeReport> {
    const changes: ApiChange[] = [];
    
    // 比较路径变更
    changes.push(...this.comparePaths(oldSpec.paths, newSpec.paths));
    
    // 比较数据模型变更
    changes.push(...this.compareSchemas(oldSpec.components?.schemas, newSpec.components?.schemas));
    
    // 识别破坏性变更
    const breakingChanges = this.identifyBreakingChanges(changes);
    
    return {
      changes,
      breakingChanges,
      hasBreakingChanges: breakingChanges.length > 0,
      compatibilityReport: this.generateCompatibilityReport(breakingChanges),
    };
  }

  private comparePaths(oldPaths: any, newPaths: any): ApiChange[] {
    const changes: ApiChange[] = [];

    // 检查删除的路径
    for (const path in oldPaths) {
      if (!newPaths[path]) {
        changes.push({
          type: 'endpoint_removed',
          path,
          severity: 'breaking',
          description: `Endpoint ${path} has been removed`,
        });
      }
    }

    // 检查新增和修改的路径
    for (const path in newPaths) {
      const oldPath = oldPaths[path];
      const newPath = newPaths[path];

      if (!oldPath) {
        changes.push({
          type: 'endpoint_added',
          path,
          severity: 'non_breaking',
          description: `New endpoint ${path} has been added`,
        });
        continue;
      }

      // 比较 HTTP 方法
      changes.push(...this.compareMethods(path, oldPath, newPath));
    }

    return changes;
  }

  private compareMethods(path: string, oldPath: any, newPath: any): ApiChange[] {
    const changes: ApiChange[] = [];

    for (const method in oldPath) {
      const oldMethod = oldPath[method];
      const newMethod = newPath[method];

      if (!newMethod) {
        changes.push({
          type: 'method_removed',
          path,
          method,
          severity: 'breaking',
          description: `Method ${method} for endpoint ${path} has been removed`,
        });
        continue;
      }

      // 比较响应状态码
      changes.push(...this.compareResponses(path, method, oldMethod, newMethod));
      
      // 比较请求参数
      changes.push(...this.compareParameters(path, method, oldMethod, newMethod));
    }

    return changes;
  }

  private compareResponses(
    path: string,
    method: string,
    oldMethod: any,
    newMethod: any,
  ): ApiChange[] {
    const changes: ApiChange[] = [];

    const oldResponses = oldMethod.responses || {};
    const newResponses = newMethod.responses || {};

    // 检查删除的响应状态码
    for (const statusCode in oldResponses) {
      if (!newResponses[statusCode]) {
        changes.push({
          type: 'response_removed',
          path,
          method,
          statusCode,
          severity: 'breaking',
          description: `Response status ${statusCode} for ${method} ${path} has been removed`,
        });
      }
    }

    return changes;
  }

  private compareParameters(
    path: string,
    method: string,
    oldMethod: any,
    newMethod: any,
  ): ApiChange[] {
    const changes: ApiChange[] = [];

    const oldParams = oldMethod.parameters || [];
    const newParams = newMethod.parameters || [];

    // 创建参数映射
    const oldParamMap = new Map(oldParams.map(p => [`${p.name}_${p.in}`, p]));
    const newParamMap = new Map(newParams.map(p => [`${p.name}_${p.in}`, p]));

    // 检查必需参数变更
    for (const [key, oldParam] of oldParamMap) {
      const newParam = newParamMap.get(key);
      if (!newParam) {
        if (oldParam.required) {
          changes.push({
            type: 'required_parameter_removed',
            path,
            method,
            parameter: oldParam.name,
            severity: 'breaking',
            description: `Required parameter ${oldParam.name} has been removed`,
          });
        }
        continue;
      }

      // 检查必需性变更
      if (oldParam.required && !newParam.required) {
        changes.push({
          type: 'parameter_made_optional',
          path,
          method,
          parameter: oldParam.name,
          severity: 'non_breaking',
          description: `Parameter ${oldParam.name} is now optional`,
        });
      } else if (!oldParam.required && newParam.required) {
        changes.push({
          type: 'parameter_made_required',
          path,
          method,
          parameter: oldParam.name,
          severity: 'breaking',
          description: `Parameter ${oldParam.name} is now required`,
        });
      }
    }

    return changes;
  }

  private compareSchemas(oldSchemas: any, newSchemas: any): ApiChange[] {
    const changes: ApiChange[] = [];

    if (!oldSchemas || !newSchemas) {
      return changes;
    }

    // 检查模型变更
    for (const schemaName in oldSchemas) {
      const oldSchema = oldSchemas[schemaName];
      const newSchema = newSchemas[schemaName];

      if (!newSchema) {
        changes.push({
          type: 'schema_removed',
          schema: schemaName,
          severity: 'breaking',
          description: `Schema ${schemaName} has been removed`,
        });
        continue;
      }

      // 比较字段变更
      changes.push(...this.compareSchemaFields(schemaName, oldSchema, newSchema));
    }

    return changes;
  }

  private compareSchemaFields(
    schemaName: string,
    oldSchema: any,
    newSchema: any,
  ): ApiChange[] {
    const changes: ApiChange[] = [];

    const oldProperties = oldSchema.properties || {};
    const newProperties = newSchema.properties || {};

    // 检查必需字段变更
    const oldRequired = new Set(oldSchema.required || []);
    const newRequired = new Set(newSchema.required || []);

    for (const fieldName in oldProperties) {
      if (!newProperties[fieldName]) {
        if (oldRequired.has(fieldName)) {
          changes.push({
            type: 'required_field_removed',
            schema: schemaName,
            field: fieldName,
            severity: 'breaking',
            description: `Required field ${fieldName} has been removed from ${schemaName}`,
          });
        }
        continue;
      }

      // 检查字段类型变更
      const oldField = oldProperties[fieldName];
      const newField = newProperties[fieldName];

      if (oldField.type !== newField.type) {
        changes.push({
          type: 'field_type_changed',
          schema: schemaName,
          field: fieldName,
          severity: 'breaking',
          description: `Field ${fieldName} type changed from ${oldField.type} to ${newField.type}`,
        });
      }
    }

    return changes;
  }

  private identifyBreakingChanges(changes: ApiChange[]): ApiChange[] {
    return changes.filter(change => change.severity === 'breaking');
  }

  private generateCompatibilityReport(breakingChanges: ApiChange[]): CompatibilityReport {
    const severityCounts = {
      breaking: breakingChanges.length,
      non_breaking: 0, // 简化处理
    };

    return {
      compatible: breakingChanges.length === 0,
      severityCounts,
      recommendations: this.generateRecommendations(breakingChanges),
    };
  }

  private generateRecommendations(breakingChanges: ApiChange[]): string[] {
    if (breakingChanges.length === 0) {
      return ['No breaking changes detected. Safe to deploy.'];
    }

    const recommendations: string[] = [
      'Breaking changes detected. Review before deployment:',
    ];

    breakingChanges.forEach(change => {
      recommendations.push(`- ${change.description}`);
    });

    recommendations.push('Consider creating a new API version to maintain backward compatibility.');

    return recommendations;
  }
}

interface ApiChangeReport {
  changes: ApiChange[];
  breakingChanges: ApiChange[];
  hasBreakingChanges: boolean;
  compatibilityReport: CompatibilityReport;
}

interface ApiChange {
  type: string;
  path?: string;
  method?: string;
  statusCode?: string;
  parameter?: string;
  schema?: string;
  field?: string;
  severity: 'breaking' | 'non_breaking';
  description: string;
}

interface CompatibilityReport {
  compatible: boolean;
  severityCounts: {
    breaking: number;
    non_breaking: number;
  };
  recommendations: string[];
}

// API 变更检测 CLI 工具
@Injectable()
export class ApiChangeDetectionCliService {
  constructor(
    private readonly apiChangeDetectionService: ApiChangeDetectionService,
  ) {}

  async runComparison(oldSpecPath: string, newSpecPath: string): Promise<void> {
    try {
      const oldSpec = JSON.parse(fs.readFileSync(oldSpecPath, 'utf8'));
      const newSpec = JSON.parse(fs.readFileSync(newSpecPath, 'utf8'));

      const report = await this.apiChangeDetectionService.compareApiSpecifications(
        oldSpec,
        newSpec,
      );

      this.printReport(report);
      
      // 如果有破坏性变更,退出码为1
      if (report.hasBreakingChanges) {
        process.exit(1);
      }
    } catch (error) {
      console.error('Error comparing API specifications:', error);
      process.exit(1);
    }
  }

  private printReport(report: ApiChangeReport): void {
    console.log('API Change Detection Report');
    console.log('==========================');
    
    console.log(`\nChanges detected: ${report.changes.length}`);
    console.log(`Breaking changes: ${report.breakingChanges.length}`);
    console.log(`Compatible: ${report.compatibilityReport.compatible ? 'Yes' : 'No'}`);
    
    if (report.breakingChanges.length > 0) {
      console.log('\nBreaking Changes:');
      report.breakingChanges.forEach(change => {
        console.log(`  - ${change.description}`);
      });
    }
    
    console.log('\nRecommendations:');
    report.compatibilityReport.recommendations.forEach(rec => {
      console.log(`  ${rec}`);
    });
  }
}

创业团队行动清单

  1. 立即行动

    • 选择合适的 API 版本管理策略(URL 或 Header)
    • 建立字段废弃标记机制
    • 实现基本的版本转换服务
  2. 一周内完成

    • 实现向后兼容性检查
    • 建立 API 变更检测机制
    • 添加版本迁移指南生成
  3. 一月内完善

    • 集成自动化 API 测试
    • 建立完整的版本生命周期管理
    • 实现 API 使用统计和监控

总结

API 版本与兼容性管理是保障客户满意度和系统稳定性的重要手段,正确的实现需要:

  1. 版本策略选择:URL 版本 vs Header 版本各有优劣
  2. 兼容性保障:遵循向后兼容的黄金法则
  3. 废弃管理:建立清晰的字段废弃策略
  4. 自动化检测:使用工具检测破坏性变更

在下一篇文章中,我们将探讨测试支持,这是保障代码质量的重要手段。