《Java 服务端通信工程》12.1 API 版本策略

围绕 linkd-v3 的 Library 服务,拆解 proto 包名、服务名、字段号三种版本载体,给出 v1/v2 并存、x-api-version 校验与只加不改不删的兼容矩阵,并附可编译的拦截器、网关路由代码与旧版本安全下线与观测口径。

承接的问题

前十一章把一条 gRPC 调用从建立连接到可观测都走了一遍,但有一个问题一直悬着:服务上线之后,契约怎么改?Book 少一个字段、多一个字段、某个字段从 int32 换成枚举,哪一种客户端会立刻崩?如果新旧两个版本必须同时在线,流量又该按什么规则分流?

版本策略的本质不是「加一个 v2 后缀」,而是把变更分成两类:线格式兼容的演进和必须并存的破坏性变更。前者应该在一个服务里悄悄完成,后者才值得付出双份代码、双份测试的代价。判断标准只有一条:旧客户端在不重新编译、不重新发版的前提下,还能不能正确解析新服务端的响应。

本节先拆开三种版本载体,再给出 linkd-v3 的兼容矩阵,最后落到两段代码:服务端如何拒绝「声明版本与实际方法不一致」的调用,网关如何按 x-api-version 选择 stub。

核心机制一:版本到底写在哪里

gRPC 的线格式由 Protobuf 决定,而 Protobuf 的兼容性由字段号 + 类型决定,和字段名、包名、服务名都没有关系。于是「版本」可以出现在三个完全不同的层次上,混用就会出问题。

载体一:proto package。 例如 plume.linkd.v3.catalog.v1 与 plume.linkd.v3.catalog.v2。包名进入 method 的完整路径,/plume.linkd.v3.catalog.v2.Library/GetBook 与 v1 是两个完全不同的方法,服务端可以同时注册。它适合破坏性变更,代价是消息类型也要跟着复制一份,或者写转换层。

载体二:service 名。 同一个 package 下放 Library 和 LibraryV2。method 路径同样不同,服务端同样能并存,但消息可以复用。它适合「只换一两个 RPC 的返回结构」的场景,缺点是同一个 package 里长期躺着两套服务定义,读 proto 的人容易分不清哪套是主线。

载体三:字段号。 在同一个 message 里追加字段、给旧字段标 reserved,不新增 service。它适合绝大多数日常演进:加一个 cover_url、加一个 page_count、加一个枚举值。只要不改字段号和类型,旧客户端会忽略不认识的字段,新客户端对旧服务端缺失的字段拿到默认值。

linkd-v3 的选择是:能用字段号解决的,绝不升 service;必须改语义或改类型时,才在同一 package 下新增 service,并把旧 service 标 deprecated。 包名整体升级留到「协议族换代」这种级别,不轻易使用。

x-api-version 是第四样东西,但它不是线格式的一部分。它只是调用方对「我以为自己在调哪个版本」的声明,供网关路由、指标分组和 mismatch 检测使用。服务端判断版本时,必须以实际 method 路径为准,不能拿 header 当契约。

核心机制二:只加不改不删

把兼容规则写成矩阵,评审时逐条对照,比口头约定可靠。

变更线格式兼容说明
追加字段(新字段号)是旧端忽略,新端拿默认值
删除字段否必须先 reserved 字段号和字段名
改字段号否等同于删旧加新
改字段类型否即使宽度相同也不行,int32 与 sint32 的编码就不同
改字段名是线格式不变,但 JSON 映射、反射和代码可读性会受影响
追加枚举值是旧端收到未知值会保留在 unknown fields
删除枚举值否必须 reserved,且旧值不得复用
追加 RPC是旧客户端不调用即可
删除 RPC否先标 deprecated,观察调用量归零再下线
改 RPC 的请求/响应类型否需要新 service 或新 method

两个容易踩的细节。

第一,删除字段必须同时保留字段号和字段名。只写 reserved 6; 挡不住后来的人复用 6;只写 reserved "price"; 挡不住别人把 price 换个类型放回来。两个都写,才能让编译器在有人犯错时报错:

message Book {
  reserved 6 to 9;
  reserved "price", "currency";

  string isbn = 1;
  string title = 2;
  string author = 3;
  int32 stock = 4;
  string cover_url = 5;
}

第二,枚举的 0 值必须留给 UNSPECIFIED,并且永远不赋予业务含义。proto3 的枚举第一个值必须是 0;如果 0 表示「平装」,那么旧客户端不传这个字段时,服务端会把「没填」误判成「平装」。新增枚举值只能追加在末尾,不能插在中间,也不能复用已删除的数值。

enum BookFormat {
  BOOK_FORMAT_UNSPECIFIED = 0;
  BOOK_FORMAT_PAPERBACK = 1;
  BOOK_FORMAT_HARDCOVER = 2;
  BOOK_FORMAT_EBOOK = 3;
}

字段号和枚举值是 wire 上的地址,一旦发布就属于公共契约。改它们不是「重构」,是换了一套协议。

核心机制三:v1 与 v2 并存的形态

假设业务要求把 Book.stock 从「一个整数」升级为「可售 + 预订」的结构。这是类型变更,字段号 4 不能直接换类型,只能新增 message 和 service。下面是 v2 的最小定义,放在同一个 package 里,复用请求类型:

syntax = "proto3";
package plume.linkd.v3;
option java_package = "com.plume.linkd.v3.api";
option java_multiple_files = true;

message StockLevel {
  int32 available = 1;
  bool backordered = 2;
}

message BookV2 {
  string isbn = 1;
  string title = 2;
  string author = 3;
  StockLevel stock = 4;
  string cover_url = 5;
}

service LibraryV2 {
  rpc GetBook(GetBookRequest) returns (BookV2);
}

注意 BookV2.stock 仍然占 4,但它在另一个 message 里,所以不违反「字段号不可改类型」。Book 保持原样,v1 客户端继续工作;v2 客户端调用 /plume.linkd.v3.LibraryV2/GetBook。两个 service 共享 GetBookRequest,因为请求没有变化。

服务端实现两个 BindableService,内部都委托给同一个领域对象,避免两套业务逻辑:

package com.plume.linkd.v3.server;

import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.BookV2;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import com.plume.linkd.v3.api.LibraryV2Grpc;
import com.plume.linkd.v3.api.StockLevel;
import io.grpc.stub.StreamObserver;

public final class CatalogService
    extends LibraryGrpc.LibraryImplBase {

  private final Catalog catalog;

  public CatalogService(Catalog catalog) {
    this.catalog = catalog;
  }

  @Override
  public void getBook(GetBookRequest request,
                      StreamObserver<Book> observer) {
    try {
      CatalogItem item = catalog.find(request.getIsbn());
      observer.onNext(Book.newBuilder()
          .setIsbn(item.isbn())
          .setTitle(item.title())
          .setAuthor(item.author())
          .setStock(item.available())
          .build());
      observer.onCompleted();
    } catch (NotFoundException e) {
      observer.onError(ErrorReasons.bookNotFound(request.getIsbn()));
    }
  }
}

final class CatalogServiceV2 extends LibraryV2Grpc.LibraryV2ImplBase {

  private final Catalog catalog;

  CatalogServiceV2(Catalog catalog) {
    this.catalog = catalog;
  }

  @Override
  public void getBook(GetBookRequest request,
                      StreamObserver<BookV2> observer) {
    try {
      CatalogItem item = catalog.find(request.getIsbn());
      observer.onNext(BookV2.newBuilder()
          .setIsbn(item.isbn())
          .setTitle(item.title())
          .setAuthor(item.author())
          .setStock(StockLevel.newBuilder()
              .setAvailable(item.available())
              .setBackordered(item.backordered())
              .build())
          .setCoverUrl(item.coverUrl())
          .build());
      observer.onCompleted();
    } catch (NotFoundException e) {
      observer.onError(ErrorReasons.bookNotFound(request.getIsbn()));
    }
  }
}

两个实现都只做「领域对象到 proto」的映射,库存扣减、缓存、权限校验留在 Catalog 里。这样 v1 下线时删掉一个类即可,不会牵动业务代码。

注册时把两个 service 都挂到同一个端口:

Server server = NettyServerBuilder.forPort(9093)
    .addService(new CatalogService(catalog))
    .addService(new CatalogServiceV2(catalog))
    .intercept(new ApiVersionInterceptor())
    .build()
    .start();

在 linkd-v3 里落地:声明必须与方法一致

网关会在 metadata 里带 x-api-version,但服务端不能盲信它。拦截器读取实际 method 的完整路径,推导出版本,再和 header 比对;不一致就立即拒绝,避免「header 写 v1、实际调了 v2」导致指标和计费错位。

package com.plume.linkd.v3.server;

import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;

public final class ApiVersionInterceptor implements ServerInterceptor {

  private static final Metadata.Key<String> API_VERSION =
      Metadata.Key.of("x-api-version", Metadata.ASCII_STRING_MARSHALLER);

  @Override
  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
      ServerCall<ReqT, RespT> call,
      Metadata headers,
      ServerCallHandler<ReqT, RespT> next) {

    String declared = headers.get(API_VERSION);
    String method = call.getMethodDescriptor().getFullMethodName();
    String actual = method.startsWith("plume.linkd.v3.LibraryV2/")
        ? "v2" : "v1";

    if (declared != null && !declared.equals(actual)) {
      call.close(Status.INVALID_ARGUMENT.withDescription(
          "x-api-version=" + declared + " conflicts with " + method),
          new Metadata());
      return new ServerCall.Listener<ReqT>() {};
    }
    return next.startCall(call, headers);
  }
}

header 缺省时按实际 method 处理,不拒绝,因为老客户端不一定带这个键。只有「显式声明了错误版本」才是调用方的 bug。拒绝用 INVALID_ARGUMENT 而不是 FAILED_PRECONDITION:这是请求参数错误,重试同一个请求没有意义。

客户端侧,网关按 header 选择 stub。版本只在一次调用的作用域内有效,用 Context 传递,避免把版本塞进每个方法签名:

package com.plume.linkd.v3.gateway;

import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.Context;
import io.grpc.ForwardingClientCall;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;

public final class ApiVersionClientInterceptor implements ClientInterceptor {

  private static final Metadata.Key<String> API_VERSION =
      Metadata.Key.of("x-api-version", Metadata.ASCII_STRING_MARSHALLER);

  private static final Context.Key<String> CURRENT =
      Context.key("linkd.api-version");

  public static Context withVersion(String version) {
    return Context.current().withValue(CURRENT, version);
  }

  @Override
  public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
      MethodDescriptor<ReqT, RespT> method,
      CallOptions options,
      Channel next) {

    String version = CURRENT.get();
    return new ForwardingClientCall.SimpleForwardingClientCall<>(
        next.newCall(method, options)) {
      @Override
      public void start(Listener<RespT> listener, Metadata headers) {
        if (version != null) {
          headers.put(API_VERSION, version);
        }
        super.start(listener, headers);
      }
    };
  }
}

网关调用时显式限定作用域:

public BookView getBook(String isbn, String version) {
  if (!version.equals("v1") && !version.equals("v2")) {
    throw new IllegalArgumentException("unsupported api version: " + version);
  }
  Context previous = ApiVersionClientInterceptor.withVersion(version).attach();
  try {
    if (version.equals("v2")) {
      BookV2 book = v2Stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS)
          .getBook(GetBookRequest.newBuilder().setIsbn(isbn).build());
      return BookView.from(book);
    }
    Book book = v1Stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS)
        .getBook(GetBookRequest.newBuilder().setIsbn(isbn).build());
    return BookView.from(book);
  } finally {
    previous.attach();
  }
}

Context 是线程绑定的。如果中间切换到线程池再调用 stub,attach() 的作用域不会自动过去,需要显式传播。gRPC 的 stub 调用本身会捕获当前 Context,所以只要在同一个线程里 attach 后立即发起调用,就能带上 header。

常见错误与边界

错误一:把 x-api-version 当契约。 客户端声明 v2,实际调用的 method 却是 v1,服务端如果只看 header 就会按 v2 的语义处理 v1 的请求。header 只能做路由提示和观测维度,method 路径才是事实。

错误二:改字段类型但保留字段号。 有人觉得 int32 改成 bool 只影响语义,实际上 wire type 从 varint 变成 varint 也一样会解析出错误值,更不用说 int32 与 string 的 wire type 不同。类型变更必须换字段号,旧号 reserved。

错误三:删除 proto 字段后直接复用字段号。 旧客户端可能还在发这个字段,新服务端会把旧数据解析成新含义。删除必须 reserved 号与名,至少保留到确认没有旧版本客户端在线。

错误四:v2 上线就删 v1。 移动端、合作方、内部脚本的升级节奏不可控。v1 要先标 deprecated,用指标观察每个 method 的调用量,归零并保留一个发布周期后再下线。

边界一:字段名兼容不等于 JSON 兼容。 protojson 默认用字段名做键,改名会改变 JSON 输出;如果网关直接把 proto 转 JSON 暴露给浏览器,改名就是破坏性变更。此时应使用 json_name 固定外部键名。

边界二:unknown fields 不是无限保险。 proto3 会保留未知字段,但经过某些 JSON 转换、手工拷贝或动态消息处理后会丢失。跨版本传递消息时,不要依赖 unknown fields 做业务判断。

边界三:一个 service 同时承载太多版本会拖慢启动。 每个 BindableService 都要注册 method、生成 descriptor,版本数量应有上限。超过三个大版本时,考虑拆成独立的 proto 模块和独立端口。

小结

  • 版本有三种载体:package、service 名、字段号。日常演进用字段号,破坏性变更才新增 service,package 整体升级留给协议族换代。
  • 兼容性的硬约束是字段号和类型:只加不改不删,删除必须 reserved 号和名,枚举 0 值留给 UNSPECIFIED。
  • v1/v2 并存时,服务端注册两个 service、共享领域逻辑;x-api-version 只做声明,实际 method 路径才是版本事实,不一致就拒绝。
  • 下线旧版本靠指标判断,不靠日期;字段名、JSON 映射、unknown fields 都有各自边界。

下一节把「少量流量切到 v2」落实到路由:按百分比、租户或请求 ID 哈希选版本,并给出回滚开关与指标对比口径。

阅读导航:上一节 11.3 追踪、采样与 exemplar | 下一节 12.2 灰度路由与流量切分

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收