Rust 服务接口升级,最怕新旧客户端同时在线
服务端已经发版,移动端还有旧版本;前端灰度了一半,后台任务还在调用旧接口;字段改名后,某些客户端开始默默解析失败。
API 版本治理不是大型团队才需要。
只要你的 Rust 服务被多个客户端调用,就会遇到新旧协议同时存在的问题。HTTP、gRPC、MQ 事件都一样:生产者和消费者不可能永远一起发布。
接口升级的核心不是“新版本更好”,而是新旧版本共存时系统还能正常工作。
兼容性比版本号更重要
很多接口一上来就纠结路径:
/api/v1/orders
/api/v2/orders路径版本当然有用,但它不是核心。核心是这次变更是否兼容。
| 变更 | 是否通常兼容 |
|---|---|
| 新增可选响应字段 | 兼容 |
| 删除响应字段 | 不兼容 |
| 字段改名 | 不兼容 |
| 枚举新增值 | 可能不兼容 |
| 错误码语义改变 | 不兼容 |
| 必填请求字段新增 | 不兼容 |
用 Rust 表达兼容判断:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApiChange {
AddOptionalField,
RemoveField,
RenameField,
AddRequiredRequestField,
}
fn is_breaking(change: ApiChange) -> bool {
!matches!(change, ApiChange::AddOptionalField)
}版本号只是标记,兼容性才是发布策略。
请求要宽容,响应要稳定
服务端解析请求时,可以对未知字段宽容一点。
但响应字段一旦给出去,就不要随便删。
#[derive(Debug)]
struct CreateOrderV1 {
sku_id: u64,
count: u32,
}
#[derive(Debug)]
struct CreateOrderV2 {
sku_id: u64,
count: u32,
coupon_id: Option<u64>,
}coupon_id 设计成可选,旧客户端可以不传,新客户端可以使用。
如果新增的是必填字段,旧客户端一定无法满足,那就应该引入新接口或新版本,而不是在旧接口上硬改。
我的偏好是:能用可选字段平滑演进,就不要制造破坏性升级。
枚举新增值也可能打坏客户端
很多人以为新增枚举值是兼容的。
对服务端可能是,对客户端未必。
比如订单状态新增 Refunding,旧客户端只认识 Paid、Cancelled、Failed,解析时可能直接报错。
Rust 里应该给未知值留出口:
#[derive(Debug, PartialEq, Eq)]
enum OrderStatusView {
Paid,
Cancelled,
Failed,
Unknown(String),
}
fn parse_status(raw: &str) -> OrderStatusView {
match raw {
"paid" => OrderStatusView::Paid,
"cancelled" => OrderStatusView::Cancelled,
"failed" => OrderStatusView::Failed,
other => OrderStatusView::Unknown(other.to_string()),
}
}客户端能处理 Unknown,服务端才有空间演进状态。
如果你的客户端遇到未知枚举就崩,服务端每次加状态都变成危险发布。
错误码也是 API
很多接口只把成功响应当 API,错误响应随手写。
这会让客户端很难处理:
库存不足:500
优惠券不可用:400
重复提交:500错误码应该稳定:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApiErrorCode {
InvalidRequest,
NotEnoughStock,
DuplicateRequest,
PermissionDenied,
}
fn http_status(code: ApiErrorCode) -> u16 {
match code {
ApiErrorCode::InvalidRequest => 400,
ApiErrorCode::NotEnoughStock => 409,
ApiErrorCode::DuplicateRequest => 409,
ApiErrorCode::PermissionDenied => 403,
}
}错误语义一旦改了,也算破坏性变更。
客户端会基于错误码做提示、重试、跳转和降级。服务端不能把它当内部实现细节。
废弃接口要有窗口
不要今天上线 v2,明天删 v1。
废弃接口至少要有:
- 废弃公告
- 调用量监控
- 客户端版本分布
- 下线时间
- 兼容兜底策略
#[derive(Debug)]
struct DeprecationPlan {
api: &'static str,
sunset_date: &'static str,
replacement: &'static str,
}
fn is_deprecated(plan: &DeprecationPlan) -> bool {
!plan.sunset_date.is_empty()
}如果还有大量旧客户端在调用,直接删除接口不是治理,是制造故障。
尤其是移动端、第三方开放接口、离线任务调用,升级周期都比服务端慢。
契约测试要覆盖旧版本
API 版本治理不能只靠文档。
契约测试可以把旧版本请求响应样本固定下来:
#[derive(Debug, PartialEq, Eq)]
struct OrderResponseV1 {
id: u64,
status: String,
}
fn order_v1_sample() -> OrderResponseV1 {
OrderResponseV1 {
id: 1,
status: "paid".to_string(),
}
}每次改接口,都要确认旧契约是否仍然成立。
如果你没有测试旧客户端依赖什么,就很难判断一次改动是否安全。
契约测试不是为了追求形式,而是为了让破坏性变更提前暴露。
结论
API 版本治理不是给路径加 /v2。它的核心是管理兼容性和共存窗口。
新增可选字段通常安全,删除字段和改语义通常危险;枚举新增值也要考虑旧客户端;错误码是 API 的一部分;废弃接口要看调用量和客户端升级周期;契约测试必须覆盖旧版本。
Rust 服务端可以很快演进,但客户端世界不会跟着你同时发版。