第 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}`);
});
}
}创业团队行动清单
立即行动:
- 选择合适的 API 版本管理策略(URL 或 Header)
- 建立字段废弃标记机制
- 实现基本的版本转换服务
一周内完成:
- 实现向后兼容性检查
- 建立 API 变更检测机制
- 添加版本迁移指南生成
一月内完善:
- 集成自动化 API 测试
- 建立完整的版本生命周期管理
- 实现 API 使用统计和监控
总结
API 版本与兼容性管理是保障客户满意度和系统稳定性的重要手段,正确的实现需要:
- 版本策略选择:URL 版本 vs Header 版本各有优劣
- 兼容性保障:遵循向后兼容的黄金法则
- 废弃管理:建立清晰的字段废弃策略
- 自动化检测:使用工具检测破坏性变更
在下一篇文章中,我们将探讨测试支持,这是保障代码质量的重要手段。