proto 文件发布后即成为服务间契约,调用方通常不会与发布方同步升级。一旦删除 RPC 或复用已发布的字段编号,问题往往在数月后才显现:灰度发布中的新旧服务互相解析出错误数据,且解析过程不产生任何报错。REST 接口改错通常当场暴露,proto 的兼容性错误则全程静默,因此 proto 的变更纪律必须比 REST 严格得多。

本文确立两条原则:其一,gRPC 仅作为传输层使用,业务逻辑保留在 Go 代码中,边界由拦截器与明确的 status code 划分;其二,工具链选用 buf 而非裸 protoc,把依赖管理、lint、破坏性变更检查、代码生成统一收敛到两个 YAML 配置,由 CI 强制把关。

proto 设计

proto 目录应按领域拆分并在目录名中携带版本,例如 proto/user/v1/;message 与 service 分文件存放(user.proto、user_service.proto)。package 应使用反向域名加版本,如 mycompany.user.v1,不应使用裸 user。v2 若需调整结构,应新建 proto/user/v2/ 与 v1 并存,不应在原文件上直接改写。

每个 RPC 的入参和返回值都应包一层 Request/Response message,禁止裸类型。 裸 string 或裸 User 没有任何位置可以追加字段,将来增加参数只能新增方法;包装 message 可以随时加字段,老客户端不受影响,保持向后兼容。分页应统一采用 page_size + page_token 请求、next_page_token 响应的游标模式。

// ❌ 裸类型签名:新增任何参数都只能加新方法
rpc GetUser(string) returns (User);

// ✅ 包装 message:加字段不改签名,向后兼容
rpc GetUser(GetUserRequest) returns (GetUserResponse);

message GetUserRequest {
  string user_id = 1;
}

字段编号一经发布即永久占用:删除字段时应用 reserved 同时封存编号与字段名,绝不复用。 其原理很直接:旧代码解析新数据时,会按旧编号把新字段的值解析进旧字段;类型兼容时解析静默通过,数据已损坏却没有报警,这是 protobuf 中最隐蔽的兼容性问题。同时不应使用 required(proto3 已移除该语义):required 字段一旦发布,便永远无法降级为可选。

命名与破坏性变更检查应交给 CI:buf lint 采用 STANDARD 规则集,buf breaking 针对 main 分支运行,违规的 proto 在合并前被拒绝。由工具强制执行规范,比人工审查可靠。

大数据量场景应优先使用流式接口,避免单条消息过大。服务端推序列、客户端批量上传、双向实时同步分别对应服务端流、客户端流与双向流三种模式;流式同时绕开了单条消息的体积上限,内存压力也被分摊到多条消息上。

代码生成

buf.gen.yaml 与 buf.yaml 均应纳入版本控制,插件使用 remote 版本,团队成员无需本地安装。buf.yaml 中声明 lint: STANDARD 与 breaking: FILE,配置本身即策略文档:

# buf.gen.yaml:纳入版本控制,各环境产出一致
version: v2
plugins:
  - remote: buf.build/protocolbuffers/go
    out: gen
    opt: paths=source_relative
  - remote: buf.build/grpc/go
    out: gen
    opt: paths=source_relative

go_package 应写成 "import/path;alias" 格式,别名统一用小写版本后缀:userv1、orderv1,多版本共存时 Go 侧 import 不会混淆。

生成命令应固化进 Makefile,开发者只需执行 make proto,无需记忆命令行参数:

.PHONY: proto proto-lint proto-breaking
proto:
	buf generate
proto-lint:
	buf lint
proto-breaking:                             # CI 中执行,检查兼容性
	buf breaking --against '.git#branch=main'

生成代码应提交进仓库,放在独立的 gen/ 目录;CI 中重新生成一次,用 git diff --exit-code 校验。提交生成代码的实际收益:不安装 buf 工具链也能编译,IDE 可以直接跳转;diff 校验保证产物与 proto 始终同步,同时防止手改 gen/ 产物。

服务端实现

服务实现体必须嵌入 UnimplementedXxxServer,不应使用 Unsafe 版本。 嵌入之后,proto 新增 RPC 时服务端照常编译,可以按自身节奏补齐实现;Unsafe 把"升级 proto"和"补齐全部方法"绑定在同一时刻,编译立即失败。官方文档同样推荐嵌入写法。

type userServer struct {
	userv1.UnimplementedUserServiceServer // 前向兼容:新增 RPC 不破坏编译
	svc *service.UserService
}

日志、recover、认证等横切关注点应全部放入拦截器,handler 只保留业务逻辑。grpc.ChainUnaryInterceptor 的参数顺序即执行顺序,recovery 应放在最外层,才能捕获内层拦截器与 handler 的 panic:

srv := grpc.NewServer(
	grpc.ChainUnaryInterceptor(recoveryInterceptor, authInterceptor, loggingInterceptor),
)
pb.RegisterUserServiceServer(srv, userSrv)

应注册 grpc_health_v1 健康检查,Kubernetes readiness 探针依赖该服务。缺少健康检查时,滚动发布期间探针只能探测端口,无法判断服务是否就绪,部署期间容易出现不可用窗口。日志、指标与 trace 在拦截器中的接入方式,见可观测性规范。

关闭流程应由两个动作配合:先 GracefulStop 排空在途 RPC,再以超时作为最终防线强制 Stop。只调用 Stop 会直接断开在途请求;只调用 GracefulStop,遇到不退出的流可能永远无法返回:

go srv.Serve(lis)
// 收到退出信号后:
stopped := make(chan struct{})
go func() { srv.GracefulStop(); close(stopped) }()
select {
case <-stopped:
case <-time.After(15 * time.Second): // 排空超时,超时后强制退出
	srv.Stop()
}

生产环境应关闭 gRPC reflection:它把完整 API 面暴露给任何能连接端口的调用方,枚举可用方法往往是攻击的第一步。开发和预发环境可保留以方便调试。

错误处理:status code 也是契约

status code 是契约的一部分,客户端依据它决定下一步:Unavailable 应触发重试,InvalidArgument 重试没有意义。code 使用错误,客户端的重试策略随之失真。因此必须用 status.Errorf 返回具体 code,裸 error 会一律变成 codes.Unknown。

// ❌ 调用方拿到 codes.Unknown,无法判断该重试还是该报错
return nil, fmt.Errorf("user not found")

// ✅ 领域错误翻译成明确的 status code
if errors.Is(err, domain.ErrNotFound) {
	return nil, status.Errorf(codes.NotFound, "user %q not found", req.GetUserId())
}
return nil, status.Errorf(codes.Internal, "lookup failed: %v", err)

常用的 code 语义:

Code使用场景
InvalidArgument入参缺字段、格式错误
NotFound实体不存在
AlreadyExists创建时实体已存在
PermissionDenied / Unauthenticated无权限 / 缺失或无效 token
FailedPrecondition系统不在所需状态
ResourceExhausted限流、配额耗尽
Unavailable瞬时故障,可安全重试
Internal / DeadlineExceeded未预期的 bug / 超时

本地 error 到 status 的映射应只在 transport 层做一次:领域层定义 sentinel error,gRPC handler 统一翻译成 status。策略集中一处,避免散落到每个 service 方法。分层与 sentinel error 的完整规范见错误处理规范。

字段级校验错误应用 status.WithDetails 附加 errdetails.BadRequest,客户端能精确到字段渲染错误提示,避免把多段文本塞进 message 由调用方猜测。

客户端

ClientConn 应创建一次、全局复用,禁止每请求新建。 gRPC 在一条 HTTP/2 连接上多路复用全部 RPC,每请求新建连接意味着重复付出 TCP/TLS 握手开销:

// ❌ 每个请求新建连接再关闭
func (c *Client) GetUser(ctx context.Context, id string) (*userv1.User, error) {
	conn, _ := grpc.NewClient("dns:///user-service:50051",
		grpc.WithTransportCredentials(creds))
	defer conn.Close()
	return userv1.NewUserServiceClient(conn).GetUser(ctx, &userv1.GetUserRequest{UserId: id})
}

// ✅ 初始化时创建一次,进程生命周期内复用;多实例场景配 round_robin
c.conn, err = grpc.NewClient("dns:///user-service:50051",
	grpc.WithTransportCredentials(creds),
	grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
)

每次调用必须设置 deadline,截止时间会自动跨网络传播。 服务端拿到的 ctx 已继承同一截止时间,无需手动透传任何字段;调用方使用 context.WithTimeout 即可。不设置 deadline,慢上游会把 goroutine 无限期阻塞。超时的调用最终以 codes.DeadlineExceeded 返回,与上述错误码语义衔接;服务端执行长操作时应检查 ctx.Err(),调用方已放弃时不应继续消耗资源。context 的完整规则见 context 使用规范。

ctx, cancel := context.WithTimeout(ctx, 3*time.Second) // 每次调用都设截止时间
defer cancel()
resp, err := client.GetUser(ctx, &userv1.GetUserRequest{UserId: id})

认证 token、trace ID 应通过 metadata.NewOutgoingContext 携带,服务端在拦截器中统一校验。生产环境必须开启 TLS:metadata 以明文传输,缺少 TLS 时对链路上的任何一方可见。服务间认证优先 mTLS,或交给 Istio、Linkerd 这类服务网格;用户认证实现 credentials.PerRPCCredentials。

服务端应配置 keepalive 探测空闲连接,keepalive.ServerParameters 的 Time 30s、Timeout 10s 是常用起点,可避免负载均衡器静默断开空闲连接后,该连接上的首个请求超时。大消息场景再显式调大 MaxRecvMsgSize,默认 4MB 收包上限不够用时才调整。多数服务不需要连接池,应先 profile,再决定是否引入这份复杂度。

测试

单元测试首选 bufconn 内存连接:序列化、拦截器、metadata 全部走真实 gRPC 栈,只是没有网络开销。

func setupTest(t *testing.T) userv1.UserServiceClient {
	t.Helper()
	lis := bufconn.Listen(1024 * 1024)
	srv := grpc.NewServer()
	userv1.RegisterUserServiceServer(srv, newTestService())
	go srv.Serve(lis)
	t.Cleanup(srv.Stop)

	// 自定义 Dialer:连接不经过网络,直通 bufconn
	conn, err := grpc.NewClient("passthrough:///bufconn",
		grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
			return lis.DialContext(ctx)
		}),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
	if err != nil { t.Fatalf("dial: %v", err) }
	t.Cleanup(func() { conn.Close() })
	return userv1.NewUserServiceClient(conn)
}

错误路径的断言必须落到 status code 上,仅断言 err != nil 不够。code 断错时测试仍然通过,线上客户端的重试策略随之失真:

_, err := client.GetUser(ctx, &userv1.GetUserRequest{UserId: "ghost"})
st, ok := status.FromError(err)
if !ok || st.Code() != codes.NotFound { // 断言 code,而非仅断言出错
	t.Errorf("want NotFound, got %v: %s", st.Code(), st.Message())
}

业务逻辑的 mock 应建立在自己定义的窄接口上,不应 mock generated client——generated client 是具体结构体,mock 它只会增加维护成本。service 依赖自定义接口,测试时注入 fake 实现即可。集成测试用 //go:build integration 标签隔离,go test -tags=integration ./... 单独运行。本地启动服务后,最快的冒烟手段是 buf curl,直接对 proto 发起调用:

buf curl --schema proto -d '{"user_id": "1"}' \
  http://localhost:50051/mycompany.user.v1.UserService/GetUser

速查清单

  • proto 组织:按领域拆分 proto/领域/v1/,message 与 service 分文件;package 用反向域名加版本;go_package 带 ;userv1 别名
  • message:RPC 一律 Request/Response 包装,禁用裸类型;分页采用 page_size + page_token 游标
  • 字段:发布即永久占用,删除即 reserved,绝不复用;不使用 required
  • 兼容:buf lint(STANDARD)+ buf breaking 对 main,合并前拦截
  • 生成:buf 优先于裸 protoc;两个 YAML 进版本控制;Makefile 固化 make proto
  • 产物:提交到 gen/;CI 重新生成后 git diff --exit-code 校验
  • 服务端:嵌 UnimplementedXxxServer(不用 Unsafe);注册 grpc_health_v1;生产关闭 reflection
  • 拦截器:日志、recovery、认证走 ChainUnaryInterceptor,recovery 在最外层;handler 只留业务
  • 关闭:GracefulStop 排空在途 RPC,15s 超时后强制 Stop()
  • 错误:status.Errorf 携带具体 code;本地 error 在 transport 层统一翻译;字段级错误附 errdetails
  • 超时:每次调用必设 WithTimeout;截止时间自动跨网络传播;长操作检查 ctx.Err()
  • 连接:ClientConn 全局复用,禁止每请求新建;dns:/// 配 round_robin;metadata 传 token/trace ID
  • 安全:生产开 TLS、关 reflection;服务间 mTLS 或服务网格;认证在拦截器统一处理
  • 性能:keepalive Time 30s / Timeout 10s;大消息调大 MaxRecvMsgSize;大数据量优先流式;连接池先 profile
  • 测试:bufconn 全栈单测;断言 status code;mock 自定义接口;集成测试加 build tag;buf curl 冒烟