115 lines
5.7 KiB
Markdown
115 lines
5.7 KiB
Markdown
---
|
||
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 传递。
|