4、gRPC通信模式(一):一元RPC(Unary RPC)详解、请求与响应处理、超时与取消

好,咱们正式开始聊gRPC的通信模式。说实话,一元RPC是最基础、最常用的模式。你想想看,平时调HTTP接口,发一个请求等一个响应,那就是一元RPC的翻版。只不过gRPC把它包装得更规范、更高效。

我个人习惯把一元RPC叫做「一问一答」模式。客户端问一句,服务端答一句,完事。简单、直接、没有花里胡哨的东西。但越简单的东西,越容易在细节上翻车。今天我就把这块掰开揉碎了讲清楚。

4.1 一元RPC的本质

一元RPC,说白了就是客户端发送一个请求消息,服务端返回一个响应消息。这是gRPC四种通信模式里最像传统RPC的一种。

它的工作流程是这样的:

  1. 客户端发起调用,序列化请求数据
  2. 通过HTTP/2发送到服务端
  3. 服务端反序列化,处理业务逻辑
  4. 序列化响应数据,返回给客户端
  5. 客户端反序列化,拿到结果

嗯,这里要注意:虽然看起来像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好不好用

个人经验:我建议请求消息里尽量用optional字段,或者用包装类型(google.protobuf.StringValue等)。为什么?因为proto3默认是零值,你没法区分「用户没传这个字段」和「用户传了零值」。这个坑我踩过,后来全改了。

服务端实现代码大概长这样:

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()
    }
    
    // 继续处理...
}
避坑指南:我曾经见过有人只在客户端设超时,服务端完全不检查ctx.Done()。结果客户端超时取消了,服务端还在傻傻地处理。浪费CPU不说,还可能导致资源泄漏。记住:超时是双向的,两端都要处理

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 超过设定的时间
核心要点:一元RPC虽然简单,但超时、取消、错误处理这三个点,是区分「能用」和「好用」的关键。我见过太多项目,proto定义得漂漂亮亮,结果线上全是超时和资源泄漏。别让这种事发生在你身上。

4.6 实战建议

最后,给几个我踩坑踩出来的建议:

  • 客户端一定要设超时,默认是无限等待,线上必出事
  • 服务端要检查ctx.Done(),别傻等
  • 错误码要用对,方便客户端做差异化处理
  • 请求消息设计要预留扩展,用optional或包装类型
  • 日志要打全,请求ID、耗时、错误信息,一个都不能少

嗯,一元RPC就聊这么多。下一章咱们讲服务端流式RPC,那个更有意思。到时候我会分享一个我在实时数据推送项目里的实战经验,保证让你少走弯路。