ASP.NET Core API 版本管理与契约演进:船岸版本碎片化下的兼容性工程

本篇定位 :岸端系统每周发版,船端客户端可能三个月不更新------船在大洋上,卫星带宽按兆收费,客户端版本冻在发版那天。这不是"API 要不要加版本号"的小问题,是一套兼容性工程:版本怎么协商、契约怎么演进、老版本怎么观测、什么时候才能杀。本篇讲 Asp.Versioning 落地、契约演进铁律、弃用流程和船岸特有的能力协商机制。


1. 开篇:一个"没人用"的字段删除,瘫痪了半个船队

某次迭代,岸端把备件同步 DTO 里的 warehouseCode 字段重构成了 locationCode------重构很彻底,旧字段直接删除。代码评审时有人提了一句"老客户端怎么办",结论是"这个字段前端早就不展示了,没人用"。

发布后第三天,太平洋上三条船的同步客户端开始报错。原因:那三条船跑的是四个月前的客户端版本,同步报文解析用的是强类型反序列化,缺字段在它们的旧版 Newtonsoft 配置下直接抛异常。更糟的是客户端的重试逻辑------同步失败后数据积压在本地,每 15 分钟重试一次,每次全量重推,卫星流量费涨了三倍,船上备件库存数据四天没更新。

复盘时的关键对话:

"为什么不先确认没有老版本调用?"

"日志里看的都是最近的请求,老版本三天才同步一次,发布后那两天刚好没赶上。"

"为什么删字段而不是留着?"

"......没人说过不能删。"

这篇文章就是把"没人说过不能删"这句话,变成一套人人遵守的工程规则。

💬 互动一下:你的 API 有版本号吗?如果有,上一次破坏兼容性(删字段/改类型/改枚举)是什么时候,怎么发现的?如果答案是"没版本号,前后端一起改"------那你的系统里只有一个客户端。船岸架构里,客户端永远比你以为的多。


2. 为什么船岸架构天然需要版本化

互联网产品的 API 版本化是"可选项"------App 商店强制更新、Web 端刷新就是新版。船岸系统有三个本质不同:

维度 互联网产品 船岸系统
客户端更新 商店推送,天级覆盖 靠港/卫星推送,月级甚至季度级覆盖
更新强制性 可强制升级 船在海上无法强制,旧版本必须继续工作
客户端数量级 版本分布连续(老版本<5%) 版本分布离散且长期共存(42条船可能横跨10个版本)
失败代价 用户重试/刷新 卫星流量浪费、船上业务停摆、数据积压错乱
服务端发版节奏 天级/周级 岸端周级,船端冻结

结论:岸端 API 必须假设"未来 6 个月内发布过的任何客户端版本,都可能在任意时刻发来请求"。 这不是保守,是大洋上的物理现实。


3. 版本化策略选型

3.1 四种版本化方式

csharp 复制代码
// 方式1:URL 段版本(最直观、最常用)
// POST /api/v1/spare-parts/apply
// POST /api/v2/spare-parts/apply

// 方式2:Header 版本
// POST /api/spare-parts/apply
// X-Api-Version: 2.0

// 方式3:查询串版本(不推荐,容易被缓存层忽略)
// POST /api/spare-parts/apply?api-version=2.0

// 方式4:MediaType 版本(REST 纯粹主义者偏好)
// Accept: application/json;v=2.0

PMS 的选型:URL 段版本为主。理由:

  • 船端开发团队(外包/驻场)水平参差,URL 版本肉眼可见、抓包即知、不会被中间栈意外吞掉 Header
  • YARP 网关路由配置直接按路径前缀分流,版本路由一目了然
  • Header/MediaType 方式在卫星链路上经过多层代理(船端缓存代理、地面站、CDN),自定义 Header 被剥离的事故发生过

3.2 Asp.Versioning 集成

.NET 8 时代版本化库是 Asp.Versioning.Mvc(原 Microsoft.AspNetCore.Mvc.Versioning 的社区延续):

xml 复制代码
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
csharp 复制代码
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;  // 不带版本号时按v1处理
    options.ReportApiVersions = true;                    // 响应头返回支持的版本
    options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";        // v1, v2 分组名
    options.SubstituteApiVersionInUrl = true;  // Swagger 模板替换
});

ReportApiVersions 会在响应头里带上:

复制代码
api-supported-versions: 1.0, 2.0
api-deprecated-versions: 1.0

客户端可以凭此知道自己用的版本是不是已被弃用。

3.3 Controller 多版本共存

csharp 复制代码
// ===== V1:原始版本,保留不动 =====
namespace Pms.Api.Controllers.V1;

[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/spare-parts")]
[Obsolete("请迁移至 v2")]
public class SparePartsController : ControllerBase
{
    [HttpPost("apply")]
    public async Task<IActionResult> Apply([FromBody] V1.SparePartApplyRequest request)
    {
        // v1 逻辑冻结,只修安全 bug,不加功能
        var result = await _service.ApplyV1Async(request.ToCommand());
        return Ok(result);
    }
}

// ===== V2:新版本,契约优化 =====
namespace Pms.Api.Controllers.V2;

[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/spare-parts")]
public class SparePartsController : ControllerBase
{
    [HttpPost("apply")]
    public async Task<IActionResult> Apply([FromBody] V2.SparePartApplyRequest request)
    {
        // v2:warehouseCode 重构为 locationCode,新增紧急申请字段
        var result = await _service.ApplyV2Async(request.ToCommand());
        return Ok(result.ToV2Dto());
    }
}

关键纪律:v1 的代码冻结后只做安全修复,绝不"顺手优化" 。两个版本的 Controller 可以共享应用服务层,差异收敛在 DTO 和适配方法(ToCommand()/ToV2Dto())里。

3.4 Minimal API 版本化

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApiVersioning()
    .AddApiExplorer();

var app = builder.Build();

var v1 = app.NewApiVersionSet().HasApiVersion(new ApiVersion(1, 0)).Build();
var v2 = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(2, 0))
    .ReportApiVersions()
    .Build();

app.MapPost("/api/v{version:apiVersion}/spare-parts/apply", ApplyV1)
   .WithApiVersionSet(v1);

app.MapPost("/api/v{version:apiVersion}/spare-parts/apply", ApplyV2)
   .WithApiVersionSet(v2);

4. 契约演进铁律:什么能改,什么永远不能改

版本号不是免责金牌------每开一个新版本都意味着长期维护成本(PMS 的 v1 可能要养 12 个月)。能用兼容演进解决的,绝不升版本。

4.1 兼容变更(不需要新版本)

变更 规则
新增字段 ✅ 随便加,但必须是可选字段/有默认值。老客户端忽略新字段,新客户端容忍字段缺失
新增端点 ✅ 随便加
新增枚举值 ✅ 可加,但老客户端收到未知枚举值必须能降级处理(见 4.3)
字段放宽约束 ✅ 如长度限制 50→100(新客户端发更长的内容给老服务端才是问题,注意方向)
加响应头 ✅ 随便加

4.2 破坏性变更(必须新版本)

变更 后果
删除字段 ❌ 老客户端强类型反序列化/取值失败
重命名字段 ❌ 等价于删旧+加新,老客户端看不到旧字段
改字段类型 ❌ 如 int→string、string→object,反序列化直接崩
改字段语义 ❌ 最隐蔽:quantity 从"件数"变成"箱数",类型不变但数据全错
删除枚举值 ❌ 老客户端还在发这个值
改 URL/HTTP 方法 ❌ 老客户端 404
收紧校验 ❌ 老客户端发的合法数据突然 400
改错误码结构 ❌ 老客户端按旧结构解析错误失败

4.3 枚举扩展的双向兼容

新增枚举值是"兼容变更",但有个隐藏前提------双方对未知值都要宽容

csharp 复制代码
// ===== 服务端:收到未知枚举值(更新的客户端发来的)不能崩 =====
// System.Text.Json 配置
var options = new JsonSerializerOptions
{
    // 未知枚举值:序列化成数字兜底,而不是抛异常
    Converters = { new JsonStringEnumConverter { } }
};
// 反序列化时未知字符串默认抛异常------用自定义 Converter 映射到 Unknown 兜底值

// ===== 客户端:收到未知枚举值(更新的服务端发来的)要能降级 =====
public enum ApplyStatus
{
    Unknown = 0,      // 未知状态的兜底
    Submitted = 1,
    Approved = 2,
    Rejected = 3,
    // v2 新增:PartiallyApproved = 4
    // 老客户端收到 4 → 映射到 Unknown,显示"未知状态,请升级客户端"而不是白屏
}

4.4 Expand-Contract 模式

功能开关篇讲过的迁移模式,在契约演进里同样是核心手法。以"warehouseCode 重构为 locationCode"为例:

复制代码
阶段1(Expand,v1.x):响应同时输出两个字段
  { "warehouseCode": "WH-01", "locationCode": "LOC-A01" }
  - 新老客户端各取所需
  - 服务端内部双写或映射
  - 日志统计:还有多少客户端在读 warehouseCode

阶段2(观察期):
  - 遥测确认 warehouseCode 读取量归零(老版本全部下线)
  - 至少覆盖一个完整的"最长客户端生命周期"(PMS 取 6 个月)

阶段3(Contract,v2):新字段转正,旧字段标记弃用
  - v2 响应只保留 locationCode
  - v1 继续双字段输出(冻结)

💬 互动一下:Expand-Contract 的精髓是"先扩后收,观察期覆盖最慢的客户端"。你们系统删除一个字段前,会观测多久?有没有过"以为没人用、其实三天才调一次"的教训?


5. 弃用流程:让老版本"安乐死"

只加版本不杀版本,系统会在 3 年内背着 8 个版本的 DTO 走。弃用要工程化:

5.1 弃用信号三件套

csharp 复制代码
// 1. 代码标记
[ApiVersion("1.0", Deprecated = true)]

// 2. 标准响应头(Asp.Versioning 自动输出)
// api-deprecated-versions: 1.0
// Sunset: Sat, 31 Jan 2027 23:59:59 GMT
// Link: <https://docs.example.com/api/v2-migration>; rel="sunset"; type="text/html"

// 3. 响应体警告(可选,给不看 Header 的客户端)
// { "warning": "v1 将于 2027-01-31 停止支持,请迁移 v2", "migrationGuide": "..." }

Sunset(RFC 8594)是标准的"API 计划下线"头,配合 Link rel="sunset" 指向迁移文档。

5.2 版本调用量遥测

杀版本的前提是看得见谁还在用

csharp 复制代码
// ===== 版本使用埋点中间件 =====
public class ApiVersionTelemetryMiddleware
{
    public async Task InvokeAsync(HttpContext context)
    {
        await _next(context);

        var version = context.GetRequestedApiVersion()?.ToString() ?? "unspecified";
        var clientVersion = context.Request.Headers["X-Client-Version"].FirstOrDefault() ?? "unknown";
        var shipId = context.Request.Headers["X-Ship-Id"].FirstOrDefault() ?? "unknown";
        var path = context.Request.Path;

        _metrics.ApiVersionCall(version, clientVersion, shipId, path,
            context.Response.StatusCode);
    }
}

// Prometheus 指标
// api_version_calls_total{api_version="1.0", client_version="3.2.1", ship="SHIP_07", path="/sync/push", code="200"}

看板要回答三个问题:

  1. 每个 API 版本最近 7 天还有多少调用、来自哪些船?
  2. 最老的还在调用的客户端版本是多少、在哪条船上?
  3. 弃用宣布后,老版本调用量的下降曲线是否符合预期?

5.3 船端版本治理

PMS 的特殊机制------客户端版本注册表 + 最低支持版本

csharp 复制代码
// 船端客户端每次同步上报自身版本
public record ClientHeartbeat(string ShipId, string ClientVersion, DateTimeOffset ClientTime);

// 岸端维护版本策略
public class ClientVersionPolicy
{
    public Version MinSupportedVersion { get; init; }      // 低于此版本:拒绝服务,强制升级
    public Version MinRecommendedVersion { get; init; }    // 低于此版本:正常服务但推送升级提醒
    public Dictionary<Version, DateOnly> DeprecationDates { get; init; }
}

// 同步响应头下发版本策略
// X-Min-Supported-Version: 3.0.0
// X-Upgrade-Recommended: 3.4.1
// X-Sunset-Date: 2027-01-31

船端客户端逻辑:低于最低版本 → 跳转升级引导(靠港时更新),同步功能只读不写;低于推荐版本 → 首页横幅提醒。

5.4 能力协商(Capability Negotiation)

比版本号更细的机制------客户端声明自己支持什么,服务端按能力响应。版本号是粗粒度的"代沟",能力位是细粒度的功能矩阵:

csharp 复制代码
// 客户端同步时上报能力
public record SyncCapabilities
{
    public string ClientVersion { get; init; }
    public bool SupportsV2SyncProtocol { get; init; }      // 支持 v2 同步报文
    public bool SupportsCompressionGzip { get; init; }     // 支持 gzip
    public bool SupportsDeltaSync { get; init; }           // 支持增量同步
    public bool SupportsRealtimePush { get; init; }        // 支持 SignalR 推送
    public IReadOnlyList<string> SupportedEventTypes { get; init; }  // 支持的事件类型
}

// 服务端按能力降级
if (capabilities.SupportsDeltaSync)
    await SendDeltaSyncAsync(shipId, sinceVersion);
else
    await SendFullSnapshotAsync(shipId);  // 老客户端:全量快照兜底

这与功能开关篇的 Kill Switch 联动:岸端功能开关决定"服务端开不开",能力协商决定"这个客户端能不能用"------两者都为真才启用新路径。


6. YARP 网关层的版本路由

json 复制代码
{
  "Routes": {
    "spareparts-v1": {
      "ClusterId": "pms-cluster",
      "Match": { "Path": "/api/v1/spare-parts/{**catch-all}" },
      "Metadata": { "Deprecated": "true", "Sunset": "2027-01-31" }
    },
    "spareparts-v2": {
      "ClusterId": "pms-cluster",
      "Match": { "Path": "/api/v2/spare-parts/{**catch-all}" }
    }
  }
}

网关层可做的版本治理:

  • v1 灰度下线:v1 路由先按船灰度返回 429/410(与功能开关篇的 ShipRollout 分桶复用)
  • 410 Gone:版本彻底下线后返回 410(不是 404),语义明确------"这个版本存在过,现在没了,请看 Sunset 文档"
  • 老版本限流收紧:v1 同步接口给更低配额,推动升级(注意:只对非关键业务,库存同步这类不能卡)

7. OpenAPI 按版本分组

csharp 复制代码
builder.Services.AddSwaggerGen(options =>
{
    // 按 API 版本分组生成文档
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "PMS API",
        Version = "v1",
        Description = "⚠️ 已弃用,将于 2027-01-31 下线,请迁移 v2"
    });
    options.SwaggerDoc("v2", new OpenApiInfo { Title = "PMS API", Version = "v2" });

    options.OperationFilter<SwaggerDefaultValues>();
});

// 中间件
app.UseSwaggerUI(options =>
{
    options.SwaggerEndpoint("/swagger/v1/swagger.json", "PMS API v1 (Deprecated)");
    options.SwaggerEndpoint("/swagger/v2/swagger.json", "PMS API v2");
});

8. 契约测试:让破坏性变更在 CI 里红掉

光靠纪律不够,要自动化兜底。两道防线:

8.1 响应快照测试

csharp 复制代码
public class V1ContractSnapshotTests
{
    [Fact]
    public async Task V1_SparePartApply_Response_Shape_Unchanged()
    {
        // 用 WebApplicationFactory 起服务,调用 v1 端点
        var response = await _factory.CreateClient()
            .PostAsJsonAsync("/api/v1/spare-parts/apply", SampleRequest);

        var json = await response.Content.ReadAsStringAsync();

        // 快照对比:字段集合必须稳定(只允许新增,不允许减少/改名)
        var actualProps = JsonDocument.Parse(json).RootElement
            .EnumerateObject().Select(p => p.Name).ToHashSet();

        await VerifyJson(actualProps);  // Verify 快照:新增字段需要显式确认
    }
}

8.2 消费者驱动契约(Pact 思路)

船端客户端团队提供"我依赖哪些字段"的契约文件,岸端 CI 验证自己的响应满足所有在役客户端契约:

csharp 复制代码
// 模拟 v1 客户端的反序列化契约------用 v1 客户端的 DTO 反序列化 v1 服务端响应
[Fact]
public async Task V1_Response_CanBe_Deserialized_By_OldestSupportedClient()
{
    var serverResponse = await GetV1ResponseAsync();

    // 用"最老支持版本客户端"的 DTO 模型反序列化
    var oldClientDto = JsonSerializer.Deserialize<OldClient.V1.SparePartApplyResponse>(
        serverResponse, OldClientJsonOptions);

    oldClientDto.Should().NotBeNull();
    // 老客户端读取的每个字段都不能是默认值/缺失
    oldClientDto!.WarehouseCode.Should().NotBeNullOrEmpty();
}

这条测试的价值:任何人在 v1 上删字段,CI 立刻红------不用等太平洋上的船报错。


9. 十个踩坑总结

# 踩坑 正解
1 "没人用"就删字段 你看不到调用不代表没有------老客户端几天才调一次。用遥测说话,观察期覆盖最慢客户端生命周期
2 v1 代码冻结后还在"顺手优化" 冻结就是冻结,只修安全 bug。任何重构先动 v2
3 新增枚举值老端崩 双向宽容:服务端未知值不崩、客户端未知值降级显示"请升级"
4 版本号只写在文档里 版本必须机器可读(URL/Header),响应头带 supported/ deprecated 列表
5 新旧版本共用同一个 DTO 类 DTO 按版本物理隔离(V1./V2. 命名空间),改 v2 不小心就动了 v1
6 Header 版本被代理剥离 船岸链路多层代理,优先 URL 段版本
7 弃用只发个公告 工程化弃用:Deprecated 标记 + Sunset 头 + 遥测曲线 + 最低版本强升,四件套
8 破坏性变更偷偷混在小版本里 破坏即升大版本,且评审时必须回答"最老在役客户端怎么办"
9 版本无限累积没人杀 每个版本登记弃用日期(功能开关篇的"开关债"同款治理),到期走下线流程
10 改了语义没改类型 最隐蔽的破坏:字段名类型不变、含义变了(件数→箱数)。语义变更等同破坏性变更

10. 落地 Checklist

  • 接入 Asp.Versioning,URL 段版本化,默认 v1 + ReportApiVersions
  • DTO 按版本物理隔离(V1/V2 命名空间),版本间通过适配方法转换
  • 制定契约演进铁律:加字段自由、删改必升版本、枚举双向宽容
  • 所有破坏性变更走 Expand-Contract:双字段 → 遥测观察 6 个月 → 再收
  • 版本调用量埋点:api_version + client_version + ship_id 三维度
  • 弃用四件套:Deprecated 特性、Sunset 头、迁移文档、调用量曲线
  • 客户端版本注册表:最低支持版本/推荐版本/强升逻辑
  • 关键同步链路实现能力协商(能力位 + 服务端降级)
  • CI 接入契约测试:v1 响应快照 + 最老客户端 DTO 反序列化验证
  • YARP 网关版本路由,下线返回 410 Gone 而非 404

11. 总结

API 版本管理的本质不是技术选型,是对"你无法控制的客户端"的尊重

  1. 岸端快、船端慢是物理现实------假设任何 6 个月内的客户端都可能随时出现
  2. 兼容优先于版本------加字段不升版本,破坏性变更才升版本,版本是负债不是勋章
  3. Expand-Contract + 遥测让字段安乐死------先双写、再观察、最后收,观察期以最慢的客户端为准
  4. 弃用要工程化------Sunset 头、版本遥测、最低版本强升、能力协商,让老版本下线是可计划、可观测、可执行的
  5. 契约测试兜底------让"删字段"在 CI 里变红,而不是在太平洋上变红

那场三条船同步瘫痪的事故,最后修复只用了半小时(把字段加回去双输出),但船上等了四天。兼容性工程的代价从来不在写下代码的那一刻,而在你以为"没人用"的那一刻。

相关推荐
吃饱了得干活1 小时前
Redis 从单机到集群:持久化、主从复制、哨兵与集群完全指南
redis·后端
老孙讲技术1 小时前
【监控开发】把车间未戴帽和烟火报警接进安监值班群:workwearDetect 对图与 setMessageCallback 订 smokeAlarm
后端·物联网·音视频开发
计算机毕设定制辅导-无忧学长1 小时前
基于Spring Boot的玄幻小说个性化推荐平台设计与实现
java·vue.js·spring boot·后端·mysql·推荐算法
码路漫漫1 小时前
LRU 真的能解决日志按业务 Key 分文件的落地瓶颈吗?
java·后端
小满zs1 小时前
Go语言第十一章(协程)
后端·go
1001101_QIA3 小时前
从 CPU 到 GPU:高速缓存、指令并行与 CUDA 执行原理入门
java·后端·spring
2501_928996223 小时前
信创备份一体机性能焦虑根源与中科热备国产CPU平台实测拆解
后端·数据安全·测试
程序员清风4 小时前
聊聊怎么缓解找工作的焦虑感?
java·后端·面试
不一样的少年_4 小时前
明明做了很多事,为什么简历看起来还是没含金量?
前端·后端·招聘