返回文章列表

Rust 服务接口升级,最怕新旧客户端同时在线

216·2 分钟阅读
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,旧客户端只认识 PaidCancelledFailed,解析时可能直接报错。

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 服务端可以很快演进,但客户端世界不会跟着你同时发版。