本篇定位 :岸端系统每周发版,船端客户端可能三个月不更新------船在大洋上,卫星带宽按兆收费,客户端版本冻在发版那天。这不是"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"}
看板要回答三个问题:
- 每个 API 版本最近 7 天还有多少调用、来自哪些船?
- 最老的还在调用的客户端版本是多少、在哪条船上?
- 弃用宣布后,老版本调用量的下降曲线是否符合预期?
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 版本管理的本质不是技术选型,是对"你无法控制的客户端"的尊重:
- 岸端快、船端慢是物理现实------假设任何 6 个月内的客户端都可能随时出现
- 兼容优先于版本------加字段不升版本,破坏性变更才升版本,版本是负债不是勋章
- Expand-Contract + 遥测让字段安乐死------先双写、再观察、最后收,观察期以最慢的客户端为准
- 弃用要工程化------Sunset 头、版本遥测、最低版本强升、能力协商,让老版本下线是可计划、可观测、可执行的
- 契约测试兜底------让"删字段"在 CI 里变红,而不是在太平洋上变红
那场三条船同步瘫痪的事故,最后修复只用了半小时(把字段加回去双输出),但船上等了四天。兼容性工程的代价从来不在写下代码的那一刻,而在你以为"没人用"的那一刻。