4、gRPC通信模式(一):一元RPC(Unary RPC)详解、请求与响应处理、超时与取消
好,咱们正式开始聊gRPC的通信模式。说实话,一元RPC是最基础、最常用的模式。你想想看,平时调HTTP接口,发一个请求等一个响应,那就是一元RPC的翻版。只不过gRPC把它包装得更规范、更高效。
我个人习惯把一元RPC叫做「一问一答」模式。客户端问一句,服务端答一句,完事。简单、直接、没有花里胡哨的东西。但越简单的东西,越容易在细节上翻车。今天我就把这块掰开揉碎了讲清楚。
4.1 一元RPC的本质
一元RPC,说白了就是客户端发送一个请求消息,服务端返回一个响应消息。这是gRPC四种通信模式里最像传统RPC的一种。
它的工作流程是这样的:
- 客户端发起调用,序列化请求数据
- 通过HTTP/2发送到服务端
- 服务端反序列化,处理业务逻辑
- 序列化响应数据,返回给客户端
- 客户端反序列化,拿到结果
嗯,这里要注意:虽然看起来像HTTP请求,但底层走的是HTTP/2的stream,性能比传统HTTP/1.1高不少。我在项目中遇到过,同样的业务逻辑,从RESTful迁移到gRPC一元RPC后,延迟降低了30%左右。
4.2 定义proto与服务实现
先看一个典型的proto定义。假设我们要做一个用户查询服务:
syntax = "proto3";
package user;
service UserService {
rpc GetUser (GetUserRequest) returns (GetUserResponse);
}
message GetUserRequest {
int32 user_id = 1;
}
message GetUserResponse {
int32 user_id = 1;
string name = 2;
string email = 3;
int32 age = 4;
}
这段代码没什么好说的,就是定义了一个GetUser方法,接收一个请求,返回一个响应。但我想强调一点:请求和响应消息的设计,直接决定了你的API好不好用。
服务端实现代码大概长这样:
type userServer struct {
pb.UnimplementedUserServiceServer
}
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
// 模拟查询数据库
user, err := queryUserFromDB(req.UserId)
if err != nil {
return nil, status.Errorf(codes.NotFound, "user not found: %v", err)
}
return &pb.GetUserResponse{
UserId: user.ID,
Name: user.Name,
Email: user.Email,
Age: user.Age,
}, nil
}
客户端调用就更简单了:
conn, _ := grpc.Dial("localhost:50051", grpc.WithInsecure())
defer conn.Close()
client := pb.NewUserServiceClient(conn)
resp, err := client.GetUser(context.Background(), &pb.GetUserRequest{UserId: 123})
if err != nil {
log.Fatalf("调用失败: %v", err)
}
log.Printf("用户信息: %+v", resp)
你看,代码量很少。但越简单的东西,越容易忽略关键点。接下来咱们重点聊两个核心问题:超时和取消。
4.3 超时控制:别让请求永远挂在那
我曾经接手过一个线上事故。某个服务依赖的外部接口挂了,但客户端没设超时,结果所有goroutine都卡在等待响应上,内存暴涨,服务直接OOM。嗯,从那以后,我写gRPC客户端第一件事就是设超时。
gRPC的超时是通过Context传递的。服务端和客户端都能感知到超时。看代码:
// 客户端设置超时
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: 123})
if err != nil {
// 判断是否是超时错误
if status.Code(err) == codes.DeadlineExceeded {
log.Println("请求超时了,别等了")
}
}
服务端也能感知到超时:
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
// 检查context是否已经超时
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// 模拟耗时操作
time.Sleep(3 * time.Second)
// 再次检查
if ctx.Err() != nil {
return nil, ctx.Err()
}
// 继续处理...
}
4.4 取消机制:优雅地停止
超时是自动取消的一种。但有时候我们需要手动取消。比如用户点了取消按钮,或者上游服务已经不需要这个结果了。
取消也是通过Context实现的:
// 手动取消
ctx, cancel := context.WithCancel(context.Background())
// 在另一个goroutine里,如果收到取消信号
go func() {
time.Sleep(2 * time.Second)
cancel() // 手动取消
}()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: 123})
服务端处理取消的方式和超时一样,都是监听ctx.Done()通道。我个人习惯在服务端每个可能阻塞的地方都检查一下ctx:
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
result := make(chan *user, 1)
go func() {
// 模拟数据库查询
time.Sleep(1 * time.Second)
result <- &user{ID: req.UserId, Name: "张三"}
}()
select {
case u := <-result:
return &pb.GetUserResponse{UserId: u.ID, Name: u.Name}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
这种模式叫「select + channel」,能保证在取消时及时返回,不浪费资源。
4.5 请求与响应的处理细节
聊完超时和取消,咱们再回头看看请求和响应处理的一些细节。
请求验证:服务端一定要做参数校验。别信客户端传过来的数据。我习惯在服务端方法开头加一段校验逻辑:
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
if req.UserId <= 0 {
return nil, status.Errorf(codes.InvalidArgument, "user_id must be positive")
}
// ...
}
错误处理:gRPC有标准的错误码体系。别什么都返回codes.Unknown。我整理了一个常用对照表:
| 场景 | 推荐错误码 | 说明 |
|---|---|---|
| 参数错误 | InvalidArgument | 客户端传了非法参数 |
| 资源不存在 | NotFound | 查不到数据 |
| 权限不足 | PermissionDenied | 没权限访问 |
| 服务端内部错误 | Internal | 数据库挂了、panic了等 |
| 超时 | DeadlineExceeded | 超过设定的时间 |
4.6 实战建议
最后,给几个我踩坑踩出来的建议:
- 客户端一定要设超时,默认是无限等待,线上必出事
- 服务端要检查ctx.Done(),别傻等
- 错误码要用对,方便客户端做差异化处理
- 请求消息设计要预留扩展,用optional或包装类型
- 日志要打全,请求ID、耗时、错误信息,一个都不能少
嗯,一元RPC就聊这么多。下一章咱们讲服务端流式RPC,那个更有意思。到时候我会分享一个我在实时数据推送项目里的实战经验,保证让你少走弯路。