3. gRPC 服务定义:service 与 rpc 方法、四种通信模式

聊到 gRPC 的服务定义,说白了就是怎么用 Protocol Buffers 描述一个远程接口。我个人习惯把 service 当作 API 的第一层抽象,里面写的每个 rpc 方法,都对应着一次远程调用。很多刚接触 gRPC 的朋友会问:是不是只有一种调用方式?其实不是,gRPC 给了我们四种通信模式,每种模式解决不同的场景痛点。

我先带着你把这四种模式挨个过一遍。你得记住一个关键句:模式的选择,取决于数据流的方向和数量。

3.1 先说说 service 和 rpc 的基本结构

在 .proto 文件里,service 的定义非常直观。下面是一个典型的例子,它包含了四种通信模式的声明:

service UserService {
  // 一元模式
  rpc GetUser (GetUserRequest) returns (GetUserResponse);
  
  // 服务端流模式
  rpc ListUsers (ListUsersRequest) returns (stream User);
  
  // 客户端流模式
  rpc CreateUsers (stream CreateUserRequest) returns (CreateUsersResponse);
  
  // 双向流模式
  rpc ChatUsers (stream ChatRequest) returns (stream ChatResponse);
}

你看,区别就在于 stream 关键字的位置。放在请求前,就是客户端流;放在响应前,就是服务端流;两边都加,就是双向流。什么都不加,就是最基础的一元模式。我在项目中遇到过不少团队,把通用查询接口也设计成双向流,结果客户端代码写得很痛苦。选对模式,比写对代码更重要。

3.2 四种通信模式详解

3.2.1 一元模式(Unary RPC)

我记得第一次用 gRPC,写的就是一元 rpc。说白了就是客户端发一个请求,服务端回一个响应,完事。

  • 数据流:客户端→服务端→客户端,一次往返
  • 适用场景:强一致性查询、简单的 CRUD 操作
  • 错误处理:所有错误通过 gRPC 状态码后返回,不像流模式需要在流中间做特殊处理

举个例子,用户详情查询几乎都是用一元模式。客户端传一个 user_id,服务端返回 Profile 对象。你想想看,这种场景为什么用流?没必要,费劲。下面是一个简单的客户端调用示例(Go 伪代码):

resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: 123})
if err != nil {
    // 处理 gRPC 错误
}
// 使用 resp.User

3.2.2 服务端流模式(Server Streaming)

有一次我在做一个监控大屏项目,需要后台推送大量实时事件给前端。如果用一元模式,客户端要不断轮询,代价太大。这时服务端流模式就派上了用场。

  • 数据流:客户端发送一个请求,服务端持续返回多个响应
  • 适用场景:日志推送、事件通知、大数据集的分页下载
  • 关键点:服务端通过 responseObserver.OnNext() 多次发送消息;注意 流关闭前必须调用 OnCompleted() 或 OnError()

这里的核心思维是“一次查询,多次推送”。服务端可以批量返回数据,客户端也可以边收边处理,不用等待全量数据。嗯,这里要注意:服务端流的取消信号只能由客户端发起,如果服务端想中断,必须通过崩溃或返回错误,这在实际项目里不太优雅。

3.2.3 客户端流模式(Client Streaming)

客户端流和服务端流正好相反。我曾在处理批量数据导入时用到它。客户端分多次发送数据,服务端在收到所有请求后才返回整体结果。

  • 数据流:客户端分片发送请求,服务端在客户端完成后汇总响应
  • 适用场景:文件上传分片、批量订单提交、日志采集
  • 注意:服务端不会在收到第一条消息时立刻回复;它要等到客户端发送半闭信号,才会真正开始整合处理

举个例子,批量创建用户。客户端可以一个个地发送用户数据,服务端等全部收齐再做批量插入。期间如果客户端发送了不合法数据,服务端只能在客户端关闭流后返回错误,无法在中间打断,这点和一元模式不同,容易踩坑。

3.2.4 双向流模式(Bidirectional Streaming)

这四种模式里,双向流最灵活,也最容易写崩。我在一个实时 AI 识别项目中踩过一个坑:双向流里,客户端和服务端必须各自独立控制关闭信号。

  • 数据流:客户端和服务端可以同时收发数据
  • 适用场景:实时语音识别、聊天室、协作编辑
  • 关键原则:流的写入和读取是独立协程,不要假设对方会在你写入后立即读取

很多人以为双向流里必须像对话一样一问一答,其实不是。你可以连续发 10 条消息,服务端也可能一次性回复 5 条。顺序由应用层逻辑保证,gRPC 只保证流内消息的到达顺序。我曾经踩过坑——为了实现“一问一答”的语义,我强行在客户端等待服务端回复后才发下一条,结果把双向流退化成了串行的一元调用。我建议你,如果用双向流,就把流当作一个独立的管道,而不是一个函数调用。

3.3 四种模式的知识结构图

下面这张图总结了四种模式的流方向和数据特点。你一眼就能看出它们的核心区别:

gRPC 四种通信模式 Service / RPC 定义 一元模式 服务端流模式 客户端流模式 双向流模式 特点 请求: 1 次 响应: 1 次 适合: 简单查询 特点 请求: 1 次 响应: 多次 适合: 事件推送 特点 请求: 多次 响应: 1 次 适合: 批量提交 特点 请求: 多次 响应: 多次 适合: 实时通信 核心原则 选择模式时,先问自己:数据是单向还是双向?需要几次交互? 用 stream 关键字减少不必要的复杂度,别让双向流变成累赘。

3.4 四种模式的对比清单

下面这个表格列出了每种模式的关键参数对比,我建议你可以打印出来当速查卡:

特性 一元模式 服务端流 客户端流 双向流
请求次数 1 1 多次 多次
响应次数 1 多次 1 多次
错误返回时机 响应时 流中任意点 流关闭后 流中任意点
取消机制 客户端取消 客户端取消 客户端取消 任一方取消
gRPC 默认 type Unary Server Streaming Client Streaming Bidirectional

3.5 我的一点实战建议

经验之谈:大部分业务接口只需一元模式。不要觉得流模式“高级”就该用。我曾见过一个团队把简单查询也做成双向流,结果连接池管理变得极其复杂。我给你的建议是:

  • 如果数据总量少且不需要分批发送,一律用一元模式。
  • 如果只有服务端需要持续推送,用服务端流。
  • 如果只有客户端需要分批上传,用客户端流。
  • 如果双方都需要持续交互,再考虑双向流。

常见陷阱:流模式下的死锁。特别是双向流,客户端和服务端要是互相等待对方先发送数据,程序就卡死了。我最开始写双向流时,客户端一直在等服务端回复后才发第二条,服务端又在等客户端发完才回复,结果两边都在等。解决办法是把读取和写入放在不同的协程里,不要让它们彼此依赖。

总结一下,gRPC 的 service 定义和四种模式其实不复杂。记住那个表格,区分好数据流方向,选择时脑子里过一遍问题:数据是单向还是双向?需要几次交互?答案就很清楚了。写代码时留意一下取消信号和死锁问题,基本就不会出大错。