--- id: backend.grpc-microservices.grpc-proto title: gRPC 和 Protobuf:IDL、四种调用方式、错误码、超时、拦截器 type: lesson domain: backend stack: [grpc-microservices, go] tags: [gRPC, Protobuf, HTTP/2, 拦截器, deadline] level: intermediate prerequisites: [backend.go.errors-concurrency] est_hours: 3 related: [backend.grpc-microservices.governance, backend.grpc-microservices.interview] week: [14] order: 1 --- # gRPC 和 Protobuf:IDL、四种调用方式、错误码、超时、拦截器 ## 解释说明 - **gRPC**:基于 HTTP/2 的 RPC 框架。先用 **Protobuf** 写接口定义(`.proto`),再由工具生成各语言的客户端和服务端代码,调用远程方法像调用本地函数。 - **Protobuf**:二进制序列化格式。比 JSON 体积小、解析快,有强类型约束;缺点是不可读,浏览器不能直接调用(需要 gRPC-Web 或网关转 HTTP/JSON)。 - **字段编号**:序列化时用编号而不是字段名。**已发布的编号不能改、不能复用**;删除字段时用 `reserved` 保留编号和名字。新增字段是向后兼容的。 - **四种调用方式**:一元(一问一答)、服务端流(一次请求、多次返回,例如推送进度)、客户端流(多次上传、一次返回,例如上传分片)、双向流(聊天、实时协作)。 - **错误码**:gRPC 有自己的状态码,常用 `InvalidArgument`、`NotFound`、`AlreadyExists`、`PermissionDenied`、`Unauthenticated`、`DeadlineExceeded`、`Unavailable`、`Internal`。可重试的一般只有 `Unavailable`(以及部分 `DeadlineExceeded`、`ResourceExhausted`),由业务判断。 - **Deadline(截止时间)**:客户端每次调用都要设置;截止时间会随请求传到服务端,服务端的 `ctx` 到期后应该停止工作,并继续传给下游调用。 - **拦截器(interceptor)**:gRPC 的中间件,用来做日志、鉴权、指标、恢复 panic。元数据(metadata)相当于 HTTP 头,用来传 token、请求 ID。 **和 REST 怎么选**:对外开放的接口、浏览器直接调用用 REST/JSON;内部服务之间、对性能和强类型契约有要求时用 gRPC。 ## 案例 订单服务需要查用户信息:定义 `UserService.GetUser`,用户服务实现,订单服务通过生成的客户端调用,每次调用带 300ms 截止时间;用户不存在返回 `NotFound`,订单服务据此返回业务错误,而不是 500。 ## 代码示例 接口定义([examples/grpc-user/user.proto](examples/grpc-user/user.proto)): ```proto syntax = "proto3"; package user.v1; service UserService { rpc GetUser(GetUserRequest) returns (User); rpc ListUsers(ListUsersRequest) returns (stream User); // 服务端流 } message GetUserRequest { int64 id = 1; } message ListUsersRequest { int32 page_size = 1; } message User { int64 id = 1; string name = 2; string email = 3; reserved 4; // 已删除字段的编号,不能复用 reserved "phone"; } ``` Go 服务端和客户端的关键写法(需要先用 `protoc --go_out --go-grpc_out` 生成 `userv1` 包;安装和生成命令以官方 Go 快速开始为准:https://grpc.io/docs/languages/go/quickstart/ ): ```go // 服务端:实现生成的接口 type userServer struct { userv1.UnimplementedUserServiceServer // 嵌入它,接口新增方法时不会编译失败 repo UserRepo } func (s *userServer) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.User, error) { if req.GetId() <= 0 { return nil, status.Error(codes.InvalidArgument, "id 必须大于 0") } u, err := s.repo.Find(ctx, req.GetId()) // 把 ctx 继续往下传,截止时间才能生效 if errors.Is(err, ErrNotFound) { return nil, status.Errorf(codes.NotFound, "用户 %d 不存在", req.GetId()) } if err != nil { return nil, status.Error(codes.Internal, "查询失败") // 不把内部错误细节暴露给调用方 } return &userv1.User{Id: u.ID, Name: u.Name, Email: u.Email}, nil } // 一元拦截器:记录方法、耗时和状态码 func logging(ctx context.Context, req any, info *grpc.UnaryServerInfo, h grpc.UnaryHandler) (any, error) { start := time.Now() resp, err := h(ctx, req) log.Printf("%s %s %v", info.FullMethod, status.Code(err), time.Since(start)) return resp, err } // 客户端:每次调用都带截止时间 func getUser(cli userv1.UserServiceClient, id int64) (*userv1.User, error) { ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) defer cancel() u, err := cli.GetUser(ctx, &userv1.GetUserRequest{Id: id}) if status.Code(err) == codes.NotFound { return nil, ErrUserNotFound } return u, err } ``` 可直接运行的完整演示用 Python(不需要单独安装 protoc):[examples/grpc-user/demo.py](examples/grpc-user/demo.py),覆盖一元调用、服务端流、`NOT_FOUND` 和 `DEADLINE_EXCEEDED`。 ## 易错点 - **修改或复用字段编号**:老客户端解析出错乱的数据,而且不会报错。 - **客户端不设截止时间**:下游卡住时请求无限等待,连接和 goroutine 堆积。 - **服务端不把 `ctx` 传给下游**:客户端已经放弃了,服务端还在查数据库。 - **所有错误都返回 `Unknown` / `Internal`**:调用方无法区分“参数错”和“服务挂了”,也就无法决定是否重试。 - **每次调用都新建连接**:gRPC 连接(`grpc.ClientConn`)应该复用,它内部会管理 HTTP/2 连接。 - **把 proto 文件复制到各个仓库**:容易不一致,放在统一的仓库或用 Buf 等工具管理版本。 ## 面试常问 见 [gRPC 与微服务面试题](interview.md):gRPC 和 REST 的区别、为什么快、四种调用方式、字段编号规则、deadline 传递。