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 四种模式的知识结构图
下面这张图总结了四种模式的流方向和数据特点。你一眼就能看出它们的核心区别:
3.4 四种模式的对比清单
下面这个表格列出了每种模式的关键参数对比,我建议你可以打印出来当速查卡:
| 特性 | 一元模式 | 服务端流 | 客户端流 | 双向流 |
|---|---|---|---|---|
| 请求次数 | 1 | 1 | 多次 | 多次 |
| 响应次数 | 1 | 多次 | 1 | 多次 |
| 错误返回时机 | 响应时 | 流中任意点 | 流关闭后 | 流中任意点 |
| 取消机制 | 客户端取消 | 客户端取消 | 客户端取消 | 任一方取消 |
| gRPC 默认 type | Unary | Server Streaming | Client Streaming | Bidirectional |
3.5 我的一点实战建议
经验之谈:大部分业务接口只需一元模式。不要觉得流模式“高级”就该用。我曾见过一个团队把简单查询也做成双向流,结果连接池管理变得极其复杂。我给你的建议是:
- 如果数据总量少且不需要分批发送,一律用一元模式。
- 如果只有服务端需要持续推送,用服务端流。
- 如果只有客户端需要分批上传,用客户端流。
- 如果双方都需要持续交互,再考虑双向流。
常见陷阱:流模式下的死锁。特别是双向流,客户端和服务端要是互相等待对方先发送数据,程序就卡死了。我最开始写双向流时,客户端一直在等服务端回复后才发第二条,服务端又在等客户端发完才回复,结果两边都在等。解决办法是把读取和写入放在不同的协程里,不要让它们彼此依赖。
总结一下,gRPC 的 service 定义和四种模式其实不复杂。记住那个表格,区分好数据流方向,选择时脑子里过一遍问题:数据是单向还是双向?需要几次交互?答案就很清楚了。写代码时留意一下取消信号和死锁问题,基本就不会出大错。