大家好,我是长林啊!一个全栈开发者和 AI 探索者;致力于终身学习和技术分享。
本文首发在我的微信公众号【长林啊】,欢迎大家关注、分享、点赞。
在上一篇文章《Protocol Buffers 语法和类型介绍》中,我们详细探讨了 Protocol Buffers 的语法规范、数据类型系统以及消息定义的最佳实践。我们学会了如何定义 message
、使用各种基本数据类型、处理复杂的嵌套结构,以及利用 enum
、oneof
、repeated
等高级特性来构建灵活且高效的数据结构。
然而,仅仅掌握数据结构的定义还远远不够。在实际的分布式系统开发中,如何让这些精心设计的消息在客户端和服务端之间高效、可靠地传输才是关键所在。这就涉及到 gRPC 的核心价值------它不仅提供了统一的接口定义语言(IDL),更重要的是提供了四种不同的服务调用模式,让我们能够根据不同的业务场景选择最适合的通信方式。
想象一下这样的场景:
- 当用户登录时,我们需要一次性验证用户名和密码,然后返回认证结果------这是典型的一元 RPC场景
- 当用户订阅股票价格时,服务端需要持续推送最新的价格信息------这就需要服务端流式 RPC
- 当用户上传大文件时,需要将文件分块逐步传输给服务端------这正是客户端流式 RPC的用武之地
- 而在实时聊天应用中,用户既要发送消息,也要接收其他用户的消息------这种双向实时交互就需要双向流式 RPC
这四种通信模式各有特色,适用于不同的业务场景,掌握它们的特点和使用方法,是构建高效微服务架构的必备技能。
温馨提示:如果你对 Protocol Buffers 的语法规范、数据类型及 Message 等概念还不够熟悉,建议先阅读上一篇文章《Protobuf 语法及类型介绍》。
在进入正文之前,先了解一下本文示例的目录结构:
txt
06service-types
├── go
│ ├── bidirectional-streaming
│ │ ├── client
│ │ └── server
│ ├── client-streaming
│ │ ├── client
│ │ └── server
│ ├── rpc
│ │ ├── chat_grpc.pb.go
│ │ ├── chat.pb.go
│ │ ├── file_grpc.pb.go
│ │ ├── file.pb.go
│ │ ├── stock_grpc.pb.go
│ │ ├── stock.pb.go
│ │ ├── user_grpc.pb.go
│ │ └── user.pb.go
│ ├── server-streaming
│ │ ├── client
│ │ └── server
│ └── unary
│ ├── client
│ └── server
├── go.mod
├── go.sum
├── Makefile
└── proto
├── chat.proto
├── file.proto
├── stock.proto
└── user.proto
一、一元 RPC(Unary RPC)
1.1 一元 RPC 概念与特点
一元 RPC(Unary RPC)是 gRPC 中最简单、最常用的通信模式,也是最容易理解的服务调用方式。
核心定义:一元 RPC 是指客户端发送一个请求消息给服务端,服务端处理后返回一个响应消息的通信模式。整个过程是同步的,客户端会等待服务端的响应。
主要特点:
- 简单直观:类似于传统的函数调用,输入参数,获得返回值
- 同步阻塞:客户端发送请求后会阻塞等待,直到收到服务端响应或超时
- 一对一映射:一个请求对应一个响应,不会有多个响应
- 无状态:每次调用都是独立的,不维护连接状态
与 HTTP 的相似性: 一元 RPC 的工作方式与 HTTP 请求-响应模式非常相似,但在性能和功能上有所提升:
- 性能优势:基于 HTTP/2,支持多路复用和头部压缩
- 类型安全:通过 Protocol Buffers 提供强类型约束
- 跨语言:自动生成客户端和服务端代码,支持多种编程语言
适用场景(包含但不限于):
- 用户认证:登录验证、权限检查
- 数据查询:根据ID查询用户信息、订单详情等
- 简单操作:创建、更新、删除单个资源
- 配置获取:获取系统配置、应用设置
- 状态检查:健康检查、服务状态查询
1.2 实战案例:用户信息查询服务
下面通过一个简单而实用的用户信息查询服务来深入理解一元 RPC 的使用方法。
案例背景
在微服务架构中,用户信息查询是一个非常常见的基础服务。当系统的各个模块需要获取用户的详细信息时,都会调用用户服务来获取相关数据。这种场景完美地展示了一元 RPC 的典型用法。
业务场景
- 客户端请求:其他服务(如订单服务、商品服务)需要根据用户ID获取用户的详细信息
- 服务端处理 :
- 接收用户ID参数
- 从数据库或缓存中查询用户信息
- 验证用户是否存在
- 返回用户的完整信息
- 响应返回:返回包含用户ID、用户名、邮箱、年龄等信息的响应
Proto 文件定义
我们的 user.proto
文件定义了一个简洁而完整的用户查询服务:
proto
syntax = "proto3";
package user;
option go_package = "github.com/clin211/grpc/service-types;stv1";
// 请求消息
message UserRequest {
string user_id = 1;
}
// 响应消息
message UserResponse {
string user_id = 1;
string username = 2;
string email = 3;
int32 age = 4;
}
// 用户服务定义
service UserService {
// 获取用户信息
rpc GetUserInfo (UserRequest) returns (UserResponse) {}
}
服务定义解析
proto
rpc GetUserInfo (UserRequest) returns (UserResponse) {}
这个服务定义完美体现了一元 RPC 的特点:
- 方法名称 :
GetUserInfo
清晰表达了操作意图 - "获取用户信息" - 输入消息 :
UserRequest
包含查询所需的用户ID - 输出消息 :
UserResponse
包含完整的用户信息 - 调用模式:一个请求对应一个响应,同步调用
通过这个简单而实用的案例,我们可以看到一元 RPC 非常适合实现基础的数据查询服务。它的请求-响应模式简单直观,性能稳定可靠,是构建微服务架构中基础服务的理想选择。
生成Go语言的代码
项目开发过程中会有很多 Protobuf 的文件,每次都新增 proto 文件都需要使用命令生成对应语言的代码,我们这里为了避免这种重复的劳动,就是用 Makefile 来管理这个复杂的命令,Makefile 配置如下:
makefile
COMMON_SELF_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
# 项目根目录
ROOT_DIR := $(abspath $(shell cd $(COMMON_SELF_DIR)/ && pwd -P))
# go output dir
GO_OUT_DIR := $(ROOT_DIR)/go/rpc
# Protobuf 文件存放路径
APIROOT=$(ROOT_DIR)/proto
.PHONY: echo
echo:
@echo $(APIROOT)
@echo $(ROOT_DIR)
@echo $(shell find $(APIROOT) -name "*.proto")
.PHONY: go-protoc
go-protoc:
@protoc -I$(APIROOT) --go_out=$(GO_OUT_DIR) --go_opt=paths=source_relative \
--go-grpc_out=$(GO_OUT_DIR) --go-grpc_opt=paths=source_relative \
$(shell find $(APIROOT) -name "*.proto")
这段 Makefile 是一个自动化 Protocol Buffers 代码生成的工具脚本,主要用来简化和标准化 gRPC 项目中 .proto
文件到 Go 语言代码的编译过程。其中由三个部分构成:
- 变量定义------
COMMON_SELF_DIR
、ROOT_DIR
、GO_OUT_DIR
以及APIROOT
- 打印各种路径信息,用于调试配置是否正确的
echo
- 自动编译所有 proto 文件生成 Go 代码的
go-protoc
在终端中执行 make go-protoc
命令后就会生成 Go 语言的代码,如下图:
如果报
xxx/go/rpc/: No such file or directory
的错误,说明没有创建go/rpc
目录,创建go/rpc
目录后重新运行就好了!

开发服务端逻辑
上面已经定义 proto 文件和生成 Go 语言代码之后,我们就可以着手开发服务端的逻辑,实现如下:
go
// 06service-types/go/unary/server/main.go
package main
import (
"context"
"log"
"net"
pb "github.com/clin211/grpc/service-types/go/rpc"
"google.golang.org/grpc"
)
// UserService 实现
type userService struct {
pb.UnimplementedUserServiceServer
}
// GetUserInfo 实现 Unary RPC
func (s *userService) GetUserInfo(ctx context.Context, req *pb.UserRequest) (*pb.UserResponse, error) {
log.Printf("Received request for user ID: %s", req.UserId)
// 模拟数据库查询
user := &pb.UserResponse{
UserId: req.UserId,
Username: "clin",
Email: "7674254@qq.com",
Age: 18,
}
return user, nil
}
func main() {
// 创建 gRPC 服务器
server := grpc.NewServer()
// 注册服务
pb.RegisterUserServiceServer(server, &userService{})
// 监听端口
lis, err := net.Listen("tcp", ":6001")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
log.Println("Server started on :6001")
// 启动服务
if err := server.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
在项目的根目录打开终端,运行 go run go/unary/server/main.go
后,可以看到类似于 2025/07/02 21:32:20 Server started on :6001
的内容时,说明启动成功了。
开发客户端
上面完成了服务端的开发,接下来就是着手开发客户端的代码了,如下如下:
go
package main
import (
"context"
"log"
"time"
pb "github.com/clin211/grpc/service-types/go/rpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
// 建立连接
conn, err := grpc.NewClient("localhost:6001",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
// 创建客户端
client := pb.NewUserServiceClient(conn)
// 设置超时
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// 发起 Unary RPC 调用
resp, err := client.GetUserInfo(ctx, &pb.UserRequest{
UserId: "123",
})
if err != nil {
log.Fatalf("GetUserInfo failed: %v", err)
}
log.Printf("User Info: %+v", resp)
}
在项目的根目录打开终端,运行 go run go/unary/client/main.go
后,可以看到类似于 2025/07/02 21:42:24 User Info: user_id:"123" username:"clin" email:"7674254@qq.com" age:18
的内容时,说明服务跑通了。
二、服务端流式 RPC(Server Streaming RPC)
2.1 服务端流式 RPC 概念与特点
服务端流式 RPC(Server Streaming RPC)是 gRPC 提供的第二种通信模式,它允许服务端向客户端持续推送数据流,非常适合实时数据传输场景。
核心定义:服务端流式 RPC 是指客户端发送一个请求消息给服务端,服务端返回一个数据流,在这个流中可以包含多个响应消息。客户端从流中读取消息直到没有更多消息为止。
主要特点:
- 一对多通信:一个请求可以对应多个响应消息
- 服务端主动推送:服务端可以在适当的时机主动向客户端发送数据
- 流式传输:数据以流的形式持续传输,而不是一次性返回
- 单向数据流:数据流向是单向的,从服务端流向客户端
- 实时性强:支持实时或近实时的数据推送
与传统轮询的对比: 相比于客户端不断轮询获取数据的方式,服务端流式 RPC 具有明显优势:
- 减少网络开销:避免了频繁的轮询请求
- 实时性更好:服务端可以立即推送新数据
- 服务端控制:由服务端决定何时发送数据,更加灵活
- 连接复用:一个长连接可以传输多次数据
适用场景(包含但不限于):
- 实时数据推送:股票价格、汇率变化、传感器数据
- 大数据集传输:分批返回大量查询结果,避免内存溢出
- 监控和告警:系统监控数据、日志流、告警信息推送
- 进度通知:文件处理进度、任务执行状态更新
- 实况更新:体育比赛比分、新闻推送、社交媒体动态
2.2 实战案例:股票价格实时推送服务
下面通过一个股票价格实时推送服务来深入理解服务端流式 RPC 的使用方法。
案例介绍
在金融交易系统中,实时获取股票价格变化是至关重要的功能。投资者需要实时监控感兴趣的股票价格,以便及时做出交易决策。传统的轮询方式会产生大量无效请求,而服务端流式 RPC 可以实现真正的实时推送。
- 客户端订阅:客户端发送订阅请求,指定要监控的股票代码列表
- 服务端处理 :
- 验证股票代码的有效性
- 建立长连接,维护客户端订阅关系
- 监听股票价格变化(从交易所、数据提供商等获取)
- 当价格发生变化时,立即推送给订阅的客户端
- 持续推送:服务端持续监控价格变化,实时推送更新数据
- 连接管理:处理客户端断开连接,清理订阅关系
定义 Proto 文件
我们定义一个完整的股票价格推送服务:
proto
syntax = "proto3";
package stock;
option go_package = "github.com/clin211/grpc/service-types;stv1";
// 股票订阅请求
message StockSubscribeRequest {
repeated string symbols = 1; // 股票代码列表,如 ["AAPL", "GOOGL", "TSLA"]
string client_id = 2; // 客户端标识
}
// 股票价格更新消息
message StockPriceUpdate {
string symbol = 1; // 股票代码
double current_price = 2; // 当前价格
double change_amount = 3; // 变化金额
double change_percent = 4; // 变化百分比
int64 timestamp = 5; // 时间戳(Unix时间)
int64 volume = 6; // 成交量
}
// 股票服务定义
service StockService {
// 订阅股票价格推送(服务端流式 RPC)
rpc SubscribeStockPrice(StockSubscribeRequest) returns (stream StockPriceUpdate) {}
}
关键要素解析:
stream
关键字:这是服务端流式 RPC 的标志,表示返回值是一个数据流- 订阅模式:客户端发送一次订阅请求,服务端持续推送更新
- 实时性:价格变化时立即推送,无需客户端轮询
- 多数据:可以推送多只股票的价格信息
消息设计解析:
symbols
: 使用repeated string
支持订阅多只股票client_id
: 客户端标识,便于服务端管理订阅关系symbol
: 股票代码,标识这条更新属于哪只股票current_price
: 当前价格,核心数据change_amount/change_percent
: 涨跌信息,便于客户端展示timestamp
: 时间戳,确保数据的时效性volume
: 成交量,提供更丰富的市场信息
通过这个实战案例,我们可以看到服务端流式 RPC 非常适合实现实时数据推送服务。它提供了高效的一对多通信能力,能够满足金融、物联网、监控等领域的实时数据传输需求。
生成 Go 语言的代码
使用之前定义的 Makefile,在 proto 目录添加 stock.proto
文件后,运行:
bash
make go-protoc
这将生成对应的 Go 语言代码,包括 StockServiceServer
接口和 StockServiceClient
客户端代码,为后续的服务端和客户端实现提供基础。
服务端开发
go
package main
import (
"log"
"math/rand"
"net"
"sync"
"time"
pb "github.com/clin211/grpc/service-types/go/rpc"
"google.golang.org/grpc"
)
// StockService 实现
type stockService struct {
pb.UnimplementedStockServiceServer
// 存储股票的基础价格,用于模拟价格变化
stockPrices map[string]float64
mutex sync.RWMutex
}
// 初始化股票基础价格
func newStockService() *stockService {
return &stockService{
stockPrices: map[string]float64{
"AAPL": 150.00, // 苹果
"GOOGL": 2800.00, // 谷歌
"TSLA": 250.00, // 特斯拉
"MSFT": 300.00, // 微软
"AMZN": 3200.00, // 亚马逊
"META": 280.00, // Meta
"NVDA": 400.00, // 英伟达
},
}
}
// SubscribeStockPrice 实现服务端流式 RPC
func (s *stockService) SubscribeStockPrice(req *pb.StockSubscribeRequest, stream pb.StockService_SubscribeStockPriceServer) error {
log.Printf("Client %s subscribed to stocks: %v", req.ClientId, req.Symbols)
// 验证股票代码
validSymbols := make([]string, 0)
for _, symbol := range req.Symbols {
s.mutex.RLock()
if _, exists := s.stockPrices[symbol]; exists {
validSymbols = append(validSymbols, symbol)
} else {
log.Printf("Invalid symbol: %s", symbol)
}
s.mutex.RUnlock()
}
if len(validSymbols) == 0 {
log.Printf("No valid symbols for client %s", req.ClientId)
return nil
}
// 为每个有效的股票代码发送初始价格
for _, symbol := range validSymbols {
s.mutex.RLock()
basePrice := s.stockPrices[symbol]
s.mutex.RUnlock()
update := &pb.StockPriceUpdate{
Symbol: symbol,
CurrentPrice: basePrice,
ChangeAmount: 0.0,
ChangePercent: 0.0,
Timestamp: time.Now().Unix(),
Volume: rand.Int63n(1000000) + 100000, // 随机成交量 100K-1.1M
}
if err := stream.Send(update); err != nil {
log.Printf("Failed to send initial price for %s: %v", symbol, err)
return err
}
}
// 持续推送价格更新
ticker := time.NewTicker(2 * time.Second) // 每2秒更新一次
defer ticker.Stop()
for {
select {
case <-ticker.C:
// 为每个订阅的股票生成价格更新
for _, symbol := range validSymbols {
update := s.generatePriceUpdate(symbol)
if err := stream.Send(update); err != nil {
log.Printf("Failed to send price update for %s: %v", symbol, err)
return err
}
}
case <-stream.Context().Done():
// 客户端断开连接
log.Printf("Client %s disconnected", req.ClientId)
return nil
}
}
}
// generatePriceUpdate 生成模拟的股票价格更新
func (s *stockService) generatePriceUpdate(symbol string) *pb.StockPriceUpdate {
s.mutex.Lock()
defer s.mutex.Unlock()
basePrice := s.stockPrices[symbol]
// 生成 -5% 到 +5% 的随机价格变化
changePercent := (rand.Float64() - 0.5) * 10 // -5% 到 +5%
changeAmount := basePrice * changePercent / 100
newPrice := basePrice + changeAmount
// 更新存储的价格(模拟真实价格变化)
s.stockPrices[symbol] = newPrice
return &pb.StockPriceUpdate{
Symbol: symbol,
CurrentPrice: newPrice,
ChangeAmount: changeAmount,
ChangePercent: changePercent,
Timestamp: time.Now().Unix(),
Volume: rand.Int63n(1000000) + 100000, // 随机成交量
}
}
func main() {
// 创建 gRPC 服务器
server := grpc.NewServer()
// 注册股票服务
stockSvc := newStockService()
pb.RegisterStockServiceServer(server, stockSvc)
// 监听端口
lis, err := net.Listen("tcp", ":6002")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
log.Println("Stock Service Server started on :6002")
log.Println("Available stocks: AAPL, GOOGL, TSLA, MSFT, AMZN, META, NVDA")
// 启动服务
if err := server.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
客户端开发
go
package main
import (
"context"
"fmt"
"io"
"log"
"strings"
"time"
pb "github.com/clin211/grpc/service-types/go/rpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
// 建立连接
conn, err := grpc.NewClient("localhost:6002",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
// 创建股票服务客户端
client := pb.NewStockServiceClient(conn)
// 准备订阅请求
subscribeReq := &pb.StockSubscribeRequest{
Symbols: []string{"AAPL", "GOOGL", "TSLA"}, // 订阅苹果、谷歌、特斯拉的股票
ClientId: "client_001",
}
log.Printf("Subscribing to stocks: %v", subscribeReq.Symbols)
// 发起服务端流式 RPC 调用
ctx := context.Background()
stream, err := client.SubscribeStockPrice(ctx, subscribeReq)
if err != nil {
log.Fatalf("Failed to subscribe: %v", err)
}
// 接收价格更新流
fmt.Println("\n📈 Stock Price Updates (Press Ctrl+C to exit):")
fmt.Println(strings.Repeat("=", 70))
updateCount := 0
for {
// 从流中接收价格更新
update, err := stream.Recv()
if err == io.EOF {
// 服务端关闭了流
log.Println("Stream ended by server")
break
}
if err != nil {
log.Fatalf("Failed to receive update: %v", err)
}
// 处理和显示价格更新
updateCount++
displayPriceUpdate(update, updateCount)
}
log.Println("Stock price subscription ended")
}
// displayPriceUpdate 格式化显示股票价格更新
func displayPriceUpdate(update *pb.StockPriceUpdate, count int) {
// 格式化时间戳
timestamp := time.Unix(update.Timestamp, 0).Format("15:04:05")
// 格式化价格变化显示
var changeSymbol string
var changeColor string
if update.ChangePercent > 0 {
changeSymbol = "📈"
changeColor = "+" // 涨
} else if update.ChangePercent < 0 {
changeSymbol = "📉"
changeColor = "" // 跌(负号已包含)
} else {
changeSymbol = "➡️"
changeColor = " " // 平
}
// 格式化成交量(以K为单位显示)
volumeK := float64(update.Volume) / 1000
// 计算股票代码的填充空格(保证对齐)
padding := ""
symbolLen := len(update.Symbol)
if symbolLen < 6 {
padding = strings.Repeat(" ", 6-symbolLen)
}
// 显示格式化的价格信息
fmt.Printf("[%d] %s %s%s | $%.2f | %s%.2f%% ($%.2f) | Vol: %.0fK | %s\n",
count,
changeSymbol,
update.Symbol,
padding, // 对齐
update.CurrentPrice,
changeColor,
update.ChangePercent,
update.ChangeAmount,
volumeK,
timestamp,
)
}
验证效果
在开发完整之后,肯定是要验证一下效果的,我们分别来启动服务端和客户端的服务:
-
启动服务端
在项目的根目录中打开终端并运行
go run go/server-streaming/server/main.go
命令后,在终端中就会出现类似于下面的效果:txt2025/07/02 22:25:28 Stock Service Server started on :6002 2025/07/02 22:25:28 Available stocks: AAPL, GOOGL, TSLA, MSFT, AMZN, META, NVDA 2025/07/02 22:29:41 Client client_001 subscribed to stocks: [AAPL GOOGL TSLA]
-
启动客户端
在项目的根目录中打开终端并运行
go run go/server-streaming/client/main.go
命令后,在终端中就会出现类似于下面的效果:txt2025/07/02 22:29:41 Subscribing to stocks: [AAPL GOOGL TSLA] 📈 Stock Price Updates (Press Ctrl+C to exit): ====================================================================== [1] ➡️ AAPL | $150.00 | 0.00% ($0.00) | Vol: 200K | 22:29:41 [2] ➡️ GOOGL | $2800.00 | 0.00% ($0.00) | Vol: 924K | 22:29:41 [3] ➡️ TSLA | $250.00 | 0.00% ($0.00) | Vol: 826K | 22:29:41 [4] 📉 AAPL | $143.74 | -4.17% ($-6.26) | Vol: 838K | 22:29:43 [5] 📈 GOOGL | $2863.64 | +2.27% ($63.64) | Vol: 198K | 22:29:43 [6] 📈 TSLA | $256.45 | +2.58% ($6.45) | Vol: 636K | 22:29:43 [7] 📈 AAPL | $150.33 | +4.58% ($6.58) | Vol: 183K | 22:29:45 [8] 📈 GOOGL | $2979.41 | +4.04% ($115.77) | Vol: 213K | 22:29:45 [9] 📉 TSLA | $255.51 | -0.36% ($-0.94) | Vol: 585K | 22:29:45 [10] 📉 AAPL | $147.51 | -1.87% ($-2.81) | Vol: 169K | 22:29:47 [11] 📉 GOOGL | $2891.20 | -2.96% ($-88.22) | Vol: 964K | 22:29:47 [12] 📈 TSLA | $255.66 | +0.06% ($0.15) | Vol: 243K | 22:29:47 [13] 📉 AAPL | $142.27 | -3.56% ($-5.25) | Vol: 703K | 22:29:49 [14] 📈 GOOGL | $2901.31 | +0.35% ($10.11) | Vol: 163K | 22:29:49 [15] 📉 TSLA | $246.52 | -3.58% ($-9.14) | Vol: 1021K | 22:29:49 [16] 📈 AAPL | $146.86 | +3.23% ($4.60) | Vol: 907K | 22:29:51 [17] 📈 GOOGL | $2908.81 | +0.26% ($7.50) | Vol: 1049K | 22:29:51 [18] 📈 TSLA | $256.94 | +4.23% ($10.42) | Vol: 1098K | 22:29:51 [19] 📈 AAPL | $149.05 | +1.49% ($2.19) | Vol: 147K | 22:29:53 [20] 📈 GOOGL | $2958.57 | +1.71% ($49.77) | Vol: 999K | 22:29:53 [21] 📉 TSLA | $246.53 | -4.05% ($-10.40) | Vol: 384K | 22:29:53
三、客户端流式 RPC(Client Streaming RPC)
3.1 客户端流式 RPC 概念与特点
客户端流式 RPC(Client Streaming RPC)是 gRPC 提供的第三种通信模式,它允许客户端向服务端持续发送数据流,非常适合大数据量传输和批量处理场景。
核心定义:客户端流式 RPC 是指客户端发送一个数据流给服务端,在这个流中可以包含多个请求消息,服务端接收完整个流后返回一个响应消息。
主要特点:
- 多对一通信:多个请求消息对应一个响应
- 客户端主导:由客户端决定何时发送数据、发送多少数据
- 流式上传:数据以流的形式分批传输,避免内存溢出
- 单向数据流:数据流向是单向的,从客户端流向服务端
- 累积处理:服务端通常需要接收完所有数据后才进行处理
与传统方式的对比: 相比于将所有数据打包成一个大请求的方式,客户端流式 RPC 具有明显优势:
- 内存友好:数据分批传输,不会占用大量内存
- 网络高效:避免了超大请求可能导致的网络超时
- 进度可控:可以实现传输进度监控和断点续传
- 错误恢复:部分失败时可以只重传失败的部分
适用场景(包含但不限于):
- 文件上传:大文件分块上传、图片/视频上传
- 数据导入:批量数据导入数据库、CSV/Excel文件处理
- 日志收集:将大量日志数据流式发送到中央日志系统
- 传感器数据:物联网设备持续上传传感器数据
- 实时分析:将数据流发送给分析引擎进行实时处理
3.2 实战案例:分块文件上传服务
下面通过一个分块文件上传服务来深入理解客户端流式 RPC 的使用方法。
案例介绍
在现代 Web 应用中,文件上传是一个非常常见的功能。然而,当文件较大时(如视频文件、高清图片、文档等),传统的一次性上传方式会面临诸多问题:超时、内存占用过高、网络中断导致重传整个文件等。分块上传技术可以很好地解决这些问题。
业务场景
-
客户端处理:
- 选择要上传的文件
- 将文件分割成固定大小的块(如 1MB 每块)
- 为每个文件块添加元数据(文件ID、块序号、块大小等)
- 逐块发送给服务端
-
服务端处理:
- 接收文件块流
- 验证块的完整性和顺序
- 将块数据写入临时文件或缓存
- 当接收到最后一块时,合并所有块
- 保存完整文件并返回上传结果
-
优势体现:
- 支持大文件上传(GB级别)
- 网络中断时只需重传失败的块
- 内存占用低,服务端压力小
- 可以实现上传进度显示
定义 Proto 文件
我们定义一个完整的文件上传服务:
proto
syntax = "proto3";
package file;
option go_package = "github.com/clin211/grpc/service-types;stv1";
// 文件块消息
message FileChunk {
string file_id = 1; // 文件唯一标识
string filename = 2; // 原始文件名
int32 chunk_number = 3; // 块序号(从0开始)
int32 total_chunks = 4; // 总块数
bytes data = 5; // 块数据
int32 chunk_size = 6; // 当前块大小
bool is_last = 7; // 是否为最后一块
string file_hash = 8; // 文件MD5哈希(可选,用于校验)
}
// 文件上传响应
message FileUploadResponse {
bool success = 1; // 上传是否成功
string message = 2; // 响应消息
string file_path = 3; // 服务端文件路径
int64 file_size = 4; // 文件总大小
string file_id = 5; // 文件ID
int32 chunks_received = 6; // 实际接收的块数
double upload_time_seconds = 7; // 上传耗时(秒)
}
// 文件服务定义
service FileService {
// 分块文件上传(客户端流式 RPC)
rpc UploadFile(stream FileChunk) returns (FileUploadResponse) {}
}
关键要素解析:
stream
关键字:这是客户端流式 RPC 的标志,表示输入参数是一个数据流- 分块设计:将大文件分解为小块,便于传输和处理
- 元数据丰富:包含文件ID、块序号、总块数等信息,便于服务端重组
- 完整性检查:通过块序号、总块数、文件哈希等确保数据完整性
消息设计解析:
file_id
: 文件的唯一标识,便于服务端关联同一文件的所有块filename
: 原始文件名,用于服务端保存文件chunk_number/total_chunks
: 块的序号和总数,确保正确重组data
: 实际的文件块数据,使用bytes
类型is_last
: 标识最后一块,便于服务端知道何时开始合并file_hash
: 可选的文件校验值,确保传输完整性
通过这个实战案例,我们可以看到客户端流式 RPC 非常适合实现大数据量的上传场景。它提供了高效的多对一通信能力,能够很好地解决大文件传输中的内存和网络问题。
生成 Go 语言的代码
将 file.proto
文件放到 proto 目录后,使用之前定义的 Makefile:
bash
make go-protoc
这将生成对应的 Go 语言代码,包括 FileServiceServer
接口和 FileServiceClient
客户端代码。
服务端开发
go
package main
import (
"crypto/md5"
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
"time"
pb "github.com/clin211/grpc/service-types/go/rpc"
"google.golang.org/grpc"
)
// fileService 实现
type fileService struct {
pb.UnimplementedFileServiceServer
uploadDir string // 文件上传目录
}
// newFileService 创建文件服务实例
func newFileService() *fileService {
uploadDir := "./uploads"
// 确保上传目录存在
if err := os.MkdirAll(uploadDir, 0755); err != nil {
log.Printf("Failed to create upload directory: %v", err)
}
return &fileService{
uploadDir: uploadDir,
}
}
// UploadFile 实现客户端流式 RPC
func (s *fileService) UploadFile(stream pb.FileService_UploadFileServer) error {
startTime := time.Now()
var fileID string
var filename string
var totalChunks int32
var receivedChunks int32
var totalSize int64
var fileHash string
// 用于存储接收到的文件块
var fileData []byte
log.Println("Starting file upload...")
for {
// 从流中接收文件块
chunk, err := stream.Recv()
if err == io.EOF {
// 客户端已发送完所有块
log.Printf("File upload completed for %s", filename)
break
}
if err != nil {
log.Printf("Failed to receive chunk: %v", err)
return err
}
// 处理第一个块,获取文件基本信息
if receivedChunks == 0 {
fileID = chunk.FileId
filename = chunk.Filename
totalChunks = chunk.TotalChunks
fileHash = chunk.FileHash
log.Printf("Receiving file: %s (ID: %s, Total chunks: %d)",
filename, fileID, totalChunks)
}
// 验证文件块信息
if chunk.FileId != fileID {
return fmt.Errorf("file ID mismatch: expected %s, got %s",
fileID, chunk.FileId)
}
if chunk.ChunkNumber != receivedChunks {
return fmt.Errorf("chunk number mismatch: expected %d, got %d",
receivedChunks, chunk.ChunkNumber)
}
// 将块数据添加到文件数据中
fileData = append(fileData, chunk.Data...)
totalSize += int64(chunk.ChunkSize)
receivedChunks++
log.Printf("Received chunk %d/%d (size: %d bytes)",
chunk.ChunkNumber+1, totalChunks, chunk.ChunkSize)
// 如果是最后一块,验证总数
if chunk.IsLast {
if receivedChunks != totalChunks {
log.Printf("Warning: received chunks (%d) != total chunks (%d)",
receivedChunks, totalChunks)
}
break
}
}
// 验证文件完整性(如果提供了哈希值)
if fileHash != "" {
actualHash := fmt.Sprintf("%x", md5.Sum(fileData))
if actualHash != fileHash {
return fmt.Errorf("file hash mismatch: expected %s, got %s",
fileHash, actualHash)
}
log.Printf("File hash verification passed: %s", actualHash)
}
// 保存文件到磁盘
filePath := filepath.Join(s.uploadDir, filename)
if err := os.WriteFile(filePath, fileData, 0644); err != nil {
log.Printf("Failed to save file: %v", err)
return err
}
uploadDuration := time.Since(startTime)
// 发送响应
response := &pb.FileUploadResponse{
Success: true,
Message: fmt.Sprintf("File '%s' uploaded successfully", filename),
FilePath: filePath,
FileSize: totalSize,
FileId: fileID,
ChunksReceived: receivedChunks,
UploadTimeSeconds: uploadDuration.Seconds(),
}
log.Printf("File saved to: %s (Size: %d bytes, Duration: %.2fs)",
filePath, totalSize, uploadDuration.Seconds())
return stream.SendAndClose(response)
}
func main() {
// 创建 gRPC 服务器
server := grpc.NewServer()
// 注册文件服务
fileSvc := newFileService()
pb.RegisterFileServiceServer(server, fileSvc)
// 监听端口
lis, err := net.Listen("tcp", ":6003")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
log.Println("File Upload Service Server started on :6003")
log.Printf("Upload directory: %s", fileSvc.uploadDir)
// 启动服务
if err := server.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
客户端开发
go
package main
import (
"context"
"crypto/md5"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
pb "github.com/clin211/grpc/service-types/go/rpc"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
chunkSize = 1024 * 1024 // 1MB 每块
)
func main() {
// 建立连接
conn, err := grpc.NewClient("localhost:6003",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
// 创建文件服务客户端
client := pb.NewFileServiceClient(conn)
// 模拟上传一个测试文件
testFilename := "test_upload.txt"
if err := createTestFile(testFilename); err != nil {
log.Fatalf("Failed to create test file: %v", err)
}
defer os.Remove(testFilename) // 清理测试文件
log.Printf("Uploading file: %s", testFilename)
// 上传文件
if err := uploadFile(client, testFilename); err != nil {
log.Fatalf("Upload failed: %v", err)
}
log.Println("File upload completed successfully!")
}
// createTestFile 创建一个测试文件
func createTestFile(filename string) error {
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
// 写入测试数据(约 3MB)
testData := "Hello, this is a test file for gRPC client streaming upload demo.\n"
for i := 0; i < 50000; i++ {
file.WriteString(fmt.Sprintf("[%d] %s", i, testData))
}
return nil
}
// uploadFile 上传文件的主要逻辑
func uploadFile(client pb.FileServiceClient, filename string) error {
// 打开文件
file, err := os.Open(filename)
if err != nil {
return fmt.Errorf("failed to open file: %v", err)
}
defer file.Close()
// 获取文件信息
fileInfo, err := file.Stat()
if err != nil {
return fmt.Errorf("failed to get file info: %v", err)
}
fileSize := fileInfo.Size()
totalChunks := int32((fileSize + chunkSize - 1) / chunkSize) // 向上取整
log.Printf("File size: %d bytes, Chunk size: %d bytes, Total chunks: %d",
fileSize, chunkSize, totalChunks)
// 计算文件哈希值
fileHash, err := calculateFileHash(filename)
if err != nil {
log.Printf("Failed to calculate file hash: %v", err)
fileHash = "" // 继续上传,不验证哈希
}
// 生成文件ID
fileID := uuid.New().String()
// 创建客户端流
ctx := context.Background()
stream, err := client.UploadFile(ctx)
if err != nil {
return fmt.Errorf("failed to create upload stream: %v", err)
}
// 分块发送文件
buffer := make([]byte, chunkSize)
chunkNumber := int32(0)
log.Println("Starting file upload...")
startTime := time.Now()
for {
// 读取文件块
bytesRead, err := file.Read(buffer)
if err != nil && err != io.EOF {
return fmt.Errorf("failed to read file: %v", err)
}
if bytesRead == 0 {
break // 文件读取完毕
}
// 判断是否为最后一块
isLast := (chunkNumber == totalChunks-1) || err == io.EOF
// 创建文件块消息
chunk := &pb.FileChunk{
FileId: fileID,
Filename: filepath.Base(filename),
ChunkNumber: chunkNumber,
TotalChunks: totalChunks,
Data: buffer[:bytesRead],
ChunkSize: int32(bytesRead),
IsLast: isLast,
FileHash: fileHash,
}
// 发送文件块
if err := stream.Send(chunk); err != nil {
return fmt.Errorf("failed to send chunk %d: %v", chunkNumber, err)
}
chunkNumber++
log.Printf("Sent chunk %d/%d (size: %d bytes)", chunkNumber, totalChunks, bytesRead)
// 如果是最后一块,结束循环
if isLast {
break
}
}
// 关闭发送流并接收响应
response, err := stream.CloseAndRecv()
if err != nil {
return fmt.Errorf("failed to receive upload response: %v", err)
}
uploadDuration := time.Since(startTime)
// 显示上传结果
displayUploadResult(response, uploadDuration)
return nil
}
// calculateFileHash 计算文件的MD5哈希值
func calculateFileHash(filename string) (string, error) {
file, err := os.Open(filename)
if err != nil {
return "", err
}
defer file.Close()
hash := md5.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
// displayUploadResult 显示上传结果
func displayUploadResult(response *pb.FileUploadResponse, uploadDuration time.Duration) {
fmt.Println("\n" + strings.Repeat("=", 60))
fmt.Println("📁 File Upload Result")
fmt.Println(strings.Repeat("=", 60))
if response.Success {
fmt.Printf("✅ Status: %s\n", response.Message)
fmt.Printf("📄 File ID: %s\n", response.FileId)
fmt.Printf("💾 Server Path: %s\n", response.FilePath)
fmt.Printf("📊 File Size: %d bytes (%.2f MB)\n",
response.FileSize, float64(response.FileSize)/(1024*1024))
fmt.Printf("📦 Chunks Received: %d\n", response.ChunksReceived)
fmt.Printf("⏱️ Server Process Time: %.2f seconds\n", response.UploadTimeSeconds)
fmt.Printf("🚀 Client Total Time: %.2f seconds\n", uploadDuration.Seconds())
// 计算传输速度
speedMBps := float64(response.FileSize) / (1024 * 1024) / uploadDuration.Seconds()
fmt.Printf("📈 Transfer Speed: %.2f MB/s\n", speedMBps)
} else {
fmt.Printf("❌ Upload failed: %s\n", response.Message)
}
fmt.Println(strings.Repeat("=", 60))
}
验证效果
让我们分别启动服务端和客户端来验证文件上传功能:
启动服务端:
在项目根目录运行 go run go/client-streaming/server/main.go
:
txt
2025/07/02 23:15:30 File Upload Service Server started on :6003
2025/07/02 23:15:30 Upload directory: ./uploads
2025/07/02 23:16:45 Starting file upload...
2025/07/02 23:16:45 Receiving file: test_upload.txt (ID: 12345678-1234-1234-1234-123456789abc, Total chunks: 4)
2025/07/02 23:16:45 Received chunk 1/4 (size: 1048576 bytes)
2025/07/02 23:16:45 Received chunk 2/4 (size: 1048576 bytes)
2025/07/02 23:16:45 Received chunk 3/4 (size: 1048576 bytes)
2025/07/02 23:16:45 Received chunk 4/4 (size: 654321 bytes)
2025/07/02 23:16:45 File hash verification passed: 2c779d533de7558797dbcdb69bd6209a
2025/07/02 23:16:45 File saved to: ./uploads/test_upload.txt (Size: 3688890 bytes, Duration: 0.15s)
启动客户端:
在项目根目录运行 go run go/client-streaming/client/main.go
:
txt
2025/07/02 23:16:45 Uploading file: test_upload.txt
2025/07/02 23:16:45 File size: 3688890 bytes, Chunk size: 1048576 bytes, Total chunks: 4
2025/07/02 23:16:45 Starting file upload...
2025/07/02 23:16:45 Sent chunk 1/4 (size: 1048576 bytes)
2025/07/02 23:16:45 Sent chunk 2/4 (size: 1048576 bytes)
2025/07/02 23:16:45 Sent chunk 3/4 (size: 1048576 bytes)
2025/07/02 23:16:45 Sent chunk 4/4 (size: 654321 bytes)
============================================================
📁 File Upload Result
============================================================
✅ Status: File 'test_upload.txt' uploaded successfully
📄 File ID: 88e7f20e-fc2e-4f8f-8a60-e188df3e557f
💾 Server Path: uploads/test_upload.txt
📊 File Size: 3688890 bytes (3.52 MB)
📦 Chunks Received: 4
⏱️ Server Process Time: 0.15 seconds
🚀 Client Total Time: 0.15 seconds
📈 Transfer Speed: 22.99 MB/s
============================================================
2025/07/02 23:16:45 File upload completed successfully!
这个完整的实现展示了客户端流式 RPC 的强大功能:
- 内存高效:大文件被分成小块传输,避免内存溢出
- 进度可见:实时显示上传进度
- 完整性验证:通过MD5哈希值确保文件完整性
- 错误处理:完善的错误检查和恢复机制
- 性能统计:提供详细的传输性能数据
客户端流式 RPC 非常适合需要向服务端发送大量数据的场景,它提供了高效、可靠的数据传输能力。
四、双向流式 RPC(Bidirectional Streaming RPC)
4.1 双向流式 RPC 概念与特点
双向流式 RPC(Bidirectional Streaming RPC)是 gRPC 提供的最强大、最灵活的通信模式,它允许客户端和服务端同时向对方持续发送数据流,实现真正的全双工通信。
核心定义:双向流式 RPC 是指客户端和服务端都可以独立地发送数据流,两个数据流是独立操作的,可以按任意顺序进行读写。
主要特点:
- 全双工通信:客户端和服务端可以同时发送和接收数据
- 独立数据流:两个方向的数据流是完全独立的,互不影响
- 异步处理:发送和接收可以异步进行,无需等待对方响应
- 实时交互:支持真正的实时双向交互
- 最大灵活性:结合了前三种模式的所有优势
与其他模式的对比: 双向流式 RPC 集合了所有其他模式的优势,同时提供了最大的灵活性:
- 相比一元 RPC:支持持续的双向交互,而不仅仅是单次请求-响应
- 相比服务端流:客户端也可以持续发送数据,不仅仅是接收
- 相比客户端流:服务端可以在接收过程中就开始响应,无需等待全部接收完成
- 独特优势:两个数据流完全独立,可以实现复杂的交互模式
适用场景(包含但不限于):
- 实时聊天:用户既要发送消息,也要接收其他用户的消息
- 在线游戏:玩家操作上传,游戏状态实时同步
- 协作编辑:多用户实时编辑文档,操作同步和冲突解决
- 实时数据分析:客户端持续上传数据,服务端实时返回分析结果
- 视频会议:音视频数据的双向实时传输
- 交易系统:实时报价推送和交易指令提交
4.2 实战案例:实时聊天室服务
下面通过一个实时聊天室服务来深入理解双向流式 RPC 的使用方法。
案例介绍
实时聊天是双向流式 RPC 最典型的应用场景之一。在聊天室中,用户需要能够:发送消息给其他用户,同时实时接收其他用户发送的消息。这种双向实时交互正是双向流式 RPC 的强项。
业务场景
-
用户连接:
- 用户加入聊天室,建立双向流连接
- 服务端为用户分配唯一标识
- 向其他用户广播新用户加入消息
-
消息发送:
- 用户输入消息并发送到服务端
- 服务端接收消息并验证
- 服务端将消息广播给所有在线用户
-
消息接收:
- 服务端实时推送其他用户的消息
- 客户端接收并显示消息
- 支持不同类型的消息(文本、系统通知等)
-
用户离开:
- 用户断开连接时清理资源
- 向其他用户广播用户离开消息
定义 Proto 文件
我们定义一个完整的聊天室服务:
proto
syntax = "proto3";
package chat;
option go_package = "github.com/clin211/grpc/service-types;stv1";
// 聊天消息
message ChatMessage {
string message_id = 1; // 消息唯一标识
string user_id = 2; // 发送用户ID
string username = 3; // 用户名
string content = 4; // 消息内容
int64 timestamp = 5; // 时间戳
MessageType type = 6; // 消息类型
string room_id = 7; // 聊天室ID(可选,支持多房间)
}
// 消息类型枚举
enum MessageType {
TEXT = 0; // 普通文本消息
USER_JOIN = 1; // 用户加入通知
USER_LEAVE = 2; // 用户离开通知
SYSTEM = 3; // 系统消息
}
// 聊天服务定义
service ChatService {
// 聊天室双向流式通信
rpc Chat(stream ChatMessage) returns (stream ChatMessage) {}
}
关键要素解析:
- 双向
stream
:输入和输出都使用stream
关键字,表示双向数据流 - 消息类型 :通过
MessageType
枚举区分不同类型的消息 - 用户标识:包含用户ID和用户名,便于消息归属和显示
- 时间戳:确保消息的时序性和历史记录
消息设计解析:
message_id
: 消息的唯一标识,用于去重和确认user_id/username
: 发送者信息,用于消息归属和显示content
: 消息的实际内容timestamp
: 消息发送的时间戳,用于排序和显示type
: 消息类型,支持文本消息、系统通知等不同类型room_id
: 房间标识,支持多房间聊天(可选功能)
服务定义解析:
proto
rpc Chat(stream ChatMessage) returns (stream ChatMessage) {}
这个服务定义完美体现了双向流式 RPC 的特点:
- 输入流 :
stream ChatMessage
- 客户端可以持续发送消息 - 输出流 :
stream ChatMessage
- 服务端可以持续推送消息 - 独立性:两个流是独立的,客户端发送和接收可以异步进行
- 实时性:消息可以实时双向传输,无需等待
通过这个实战案例,我们可以看到双向流式 RPC 非常适合实现复杂的实时交互场景。它提供了最大的灵活性,能够满足各种复杂的双向通信需求。
生成 Go 语言的代码
将 chat.proto
文件放到 proto 目录后,使用之前定义的 Makefile:
bash
make go-protoc
这将生成对应的 Go 语言代码,包括 ChatServiceServer
接口和 ChatServiceClient
客户端代码。
服务端开发
双向流式 RPC 的服务端实现相对复杂,需要管理多个并发的客户端连接:
go
package main
import (
"fmt"
"io"
"log"
"net"
"sync"
"time"
pb "github.com/clin211/grpc/service-types/go/rpc"
"github.com/google/uuid"
"google.golang.org/grpc"
)
// chatService 实现
type chatService struct {
pb.UnimplementedChatServiceServer
// 存储所有连接的客户端
clients map[string]*clientConnection
// 用于保护clients map的互斥锁
clientsMutex sync.RWMutex
// 消息广播通道
broadcast chan *pb.ChatMessage
}
// clientConnection 表示一个客户端连接
type clientConnection struct {
userID string
username string
stream pb.ChatService_ChatServer
send chan *pb.ChatMessage
}
// newChatService 创建聊天服务实例
func newChatService() *chatService {
service := &chatService{
clients: make(map[string]*clientConnection),
broadcast: make(chan *pb.ChatMessage, 100),
}
// 启动消息广播处理器
go service.handleBroadcast()
return service
}
// Chat 实现双向流式 RPC
func (s *chatService) Chat(stream pb.ChatService_ChatServer) error {
var client *clientConnection
defer func() {
// 清理客户端连接
if client != nil {
s.removeClient(client)
// 发送用户离开通知
leaveMsg := &pb.ChatMessage{
MessageId: uuid.New().String(),
UserId: "system",
Username: "系统",
Content: fmt.Sprintf("用户 %s 离开了聊天室", client.username),
Timestamp: time.Now().Unix(),
Type: pb.MessageType_USER_LEAVE,
RoomId: "general",
}
s.broadcast <- leaveMsg
}
}()
// 处理客户端消息
for {
// 接收客户端发送的消息
msg, err := stream.Recv()
if err == io.EOF {
log.Printf("Client %s disconnected", getClientID(client))
break
}
if err != nil {
log.Printf("Error receiving message: %v", err)
break
}
// 处理第一条消息(用户加入)
if client == nil {
client = &clientConnection{
userID: msg.UserId,
username: msg.Username,
stream: stream,
send: make(chan *pb.ChatMessage, 10),
}
// 添加客户端到连接池
s.addClient(client)
// 启动消息发送处理器
go s.handleClientSend(client)
log.Printf("User %s (%s) joined the chat room", client.username, client.userID)
// 发送用户加入通知
joinMsg := &pb.ChatMessage{
MessageId: uuid.New().String(),
UserId: "system",
Username: "系统",
Content: fmt.Sprintf("欢迎 %s 加入聊天室!", client.username),
Timestamp: time.Now().Unix(),
Type: pb.MessageType_USER_JOIN,
RoomId: "general",
}
s.broadcast <- joinMsg
// 如果第一条消息就是文本消息,也要处理
if msg.Type == pb.MessageType_TEXT {
s.handleTextMessage(msg, client)
}
} else {
// 处理后续消息
s.handleMessage(msg, client)
}
}
return nil
}
// handleMessage 处理接收到的消息
func (s *chatService) handleMessage(msg *pb.ChatMessage, client *clientConnection) {
switch msg.Type {
case pb.MessageType_TEXT:
s.handleTextMessage(msg, client)
default:
log.Printf("Unknown message type: %v", msg.Type)
}
}
// handleTextMessage 处理文本消息
func (s *chatService) handleTextMessage(msg *pb.ChatMessage, client *clientConnection) {
// 设置消息元数据
msg.MessageId = uuid.New().String()
msg.UserId = client.userID
msg.Username = client.username
msg.Timestamp = time.Now().Unix()
msg.RoomId = "general"
log.Printf("Message from %s: %s", client.username, msg.Content)
// 广播消息给所有客户端
s.broadcast <- msg
}
// addClient 添加客户端到连接池
func (s *chatService) addClient(client *clientConnection) {
s.clientsMutex.Lock()
defer s.clientsMutex.Unlock()
s.clients[client.userID] = client
log.Printf("Added client %s, total clients: %d", client.username, len(s.clients))
}
// removeClient 从连接池移除客户端
func (s *chatService) removeClient(client *clientConnection) {
s.clientsMutex.Lock()
defer s.clientsMutex.Unlock()
if _, exists := s.clients[client.userID]; exists {
delete(s.clients, client.userID)
close(client.send)
log.Printf("Removed client %s, total clients: %d", client.username, len(s.clients))
}
}
// handleBroadcast 处理消息广播
func (s *chatService) handleBroadcast() {
for msg := range s.broadcast {
s.clientsMutex.RLock()
for _, client := range s.clients {
select {
case client.send <- msg:
// 消息发送成功
default:
// 客户端发送通道已满,跳过该客户端
log.Printf("Client %s send channel is full, skipping message", client.username)
}
}
s.clientsMutex.RUnlock()
}
}
// handleClientSend 处理单个客户端的消息发送
func (s *chatService) handleClientSend(client *clientConnection) {
for msg := range client.send {
if err := client.stream.Send(msg); err != nil {
log.Printf("Error sending message to client %s: %v", client.username, err)
break
}
}
}
// getClientID 获取客户端标识(用于日志)
func getClientID(client *clientConnection) string {
if client == nil {
return "unknown"
}
return fmt.Sprintf("%s(%s)", client.username, client.userID)
}
func main() {
// 创建 gRPC 服务器
server := grpc.NewServer()
// 注册聊天服务
chatSvc := newChatService()
pb.RegisterChatServiceServer(server, chatSvc)
// 监听端口
lis, err := net.Listen("tcp", ":6004")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
log.Println("💬 Chat Room Server started on :6004")
log.Println("Waiting for users to join the chat room...")
// 启动服务
if err := server.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
客户端开发
双向流式 RPC 的客户端需要同时处理消息发送和接收:
go
package main
import (
"bufio"
"context"
"fmt"
"io"
"log"
"os"
"strings"
"time"
pb "github.com/clin211/grpc/service-types/go/rpc"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
// 建立连接
conn, err := grpc.NewClient("localhost:6004",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
// 创建聊天服务客户端
client := pb.NewChatServiceClient(conn)
// 获取用户名
fmt.Print("请输入您的用户名: ")
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
username := strings.TrimSpace(scanner.Text())
if username == "" {
username = "匿名用户"
}
// 生成用户ID
userID := uuid.New().String()
log.Printf("正在以用户名 '%s' 加入聊天室...", username)
// 创建双向流
ctx := context.Background()
stream, err := client.Chat(ctx)
if err != nil {
log.Fatalf("Failed to create chat stream: %v", err)
}
// 发送加入消息
joinMsg := &pb.ChatMessage{
UserId: userID,
Username: username,
Content: "", // 加入消息内容为空
Type: pb.MessageType_USER_JOIN,
RoomId: "general",
}
if err := stream.Send(joinMsg); err != nil {
log.Fatalf("Failed to send join message: %v", err)
}
// 启动消息接收处理器
go handleReceiveMessages(stream)
// 显示聊天界面
displayChatInterface(username)
// 处理用户输入
handleUserInput(stream, userID, username, scanner)
}
// handleReceiveMessages 处理接收消息
func handleReceiveMessages(stream pb.ChatService_ChatClient) {
for {
msg, err := stream.Recv()
if err == io.EOF {
fmt.Println("\n💔 与服务器的连接已断开")
os.Exit(0)
}
if err != nil {
log.Printf("Error receiving message: %v", err)
return
}
displayMessage(msg)
}
}
// handleUserInput 处理用户输入
func handleUserInput(stream pb.ChatService_ChatClient, userID, username string, scanner *bufio.Scanner) {
for {
fmt.Print("💬 ")
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
// 检查退出命令
if input == "/quit" || input == "/exit" {
fmt.Println("👋 再见!")
break
}
// 检查帮助命令
if input == "/help" {
displayHelp()
continue
}
// 跳过空消息
if input == "" {
continue
}
// 发送文本消息
msg := &pb.ChatMessage{
UserId: userID,
Username: username,
Content: input,
Type: pb.MessageType_TEXT,
RoomId: "general",
}
if err := stream.Send(msg); err != nil {
log.Printf("Failed to send message: %v", err)
break
}
}
}
// displayMessage 格式化显示消息
func displayMessage(msg *pb.ChatMessage) {
timestamp := time.Unix(msg.Timestamp, 0).Format("15:04:05")
switch msg.Type {
case pb.MessageType_TEXT:
fmt.Printf("\r[%s] %s: %s\n💬 ", timestamp, msg.Username, msg.Content)
case pb.MessageType_USER_JOIN:
fmt.Printf("\r🎉 [%s] %s\n💬 ", timestamp, msg.Content)
case pb.MessageType_USER_LEAVE:
fmt.Printf("\r👋 [%s] %s\n💬 ", timestamp, msg.Content)
case pb.MessageType_SYSTEM:
fmt.Printf("\r📢 [%s] %s\n💬 ", timestamp, msg.Content)
default:
fmt.Printf("\r❓ [%s] %s: %s\n💬 ", timestamp, msg.Username, msg.Content)
}
}
// displayChatInterface 显示聊天界面
func displayChatInterface(username string) {
fmt.Println(strings.Repeat("=", 60))
fmt.Printf("💬 欢迎来到 gRPC 聊天室,%s!\n", username)
fmt.Println(strings.Repeat("=", 60))
fmt.Println("📝 输入消息后按回车发送")
fmt.Println("💡 输入 /help 查看帮助,输入 /quit 退出聊天室")
fmt.Println(strings.Repeat("=", 60))
fmt.Println()
}
// displayHelp 显示帮助信息
func displayHelp() {
fmt.Println("\r" + strings.Repeat("=", 50))
fmt.Println("📖 聊天室帮助")
fmt.Println(strings.Repeat("=", 50))
fmt.Println("💬 直接输入文字发送消息")
fmt.Println("❓ /help - 显示此帮助信息")
fmt.Println("👋 /quit - 退出聊天室")
fmt.Println("👋 /exit - 退出聊天室")
fmt.Println(strings.Repeat("=", 50))
}
验证效果
让我们通过启动多个客户端来验证聊天室的双向通信功能:
启动服务端:
在项目根目录运行 go run go/bidirectional-streaming/server/main.go
:
txt
2025/07/02 23:11:57 💬 Chat Room Server started on :6004
2025/07/02 23:11:57 Waiting for users to join the chat room...
2025/07/02 23:12:39 Added client clin001, total clients: 1
2025/07/02 23:12:39 User clin001 (c9af6449-7ce8-44f1-8c08-771dfda75423) joined the chat room
2025/07/02 23:14:07 Message from clin001: hello
2025/07/02 23:14:21 Message from clin001: 大家好!我是clin001
2025/07/02 23:16:04 Added client clin002, total clients: 2
2025/07/02 23:16:04 User clin002 (caccd9e0-ba2c-4bc5-a26a-6ea423d1157a) joined the chat room
2025/07/02 23:16:14 Message from clin002: 大家好!我是clin002
启动客户端 1 (clin001):
在项目根目录运行 go run go/bidirectional-streaming/client/main.go
:
txt
请输入您的用户名: clin001
2025/07/02 23:12:39 正在以用户名 'clin001' 加入聊天室...
============================================================
💬 欢迎来到 gRPC 聊天室,clin001!
============================================================
📝 输入消息后按回车发送
💡 输入 /help 查看帮助,输入 /quit 退出聊天室
============================================================
🎉 [23:12:39] 欢迎 clin001 加入聊天室!
启动客户端 2 (clin002):
txt
请输入您的用户名: clin002
2025/07/02 23:16:04 正在以用户名 'clin002' 加入聊天室...
============================================================
💬 欢迎来到 gRPC 聊天室,clin002!
============================================================
📝 输入消息后按回车发送
💡 输入 /help 查看帮助,输入 /quit 退出聊天室
============================================================
🎉 [23:16:04] 欢迎 clin002 加入聊天室!
💬 大家好!我是clin002
[23:16:14] clin002: 大家好!我是clin002
这个完整的实现展示了双向流式 RPC 的强大功能:
- 真正的全双工通信:客户端可以同时发送和接收消息
- 实时交互体验:消息立即在所有客户端之间同步
- 并发连接管理:服务端能够处理多个并发的客户端连接
- 优雅的连接处理:用户加入和离开时的通知机制
- 丰富的用户界面:包含帮助命令、时间戳、消息类型图标等
双向流式 RPC 是四种通信模式中最复杂但也最强大的,它为构建实时互动应用提供了完美的技术基础。通过这个聊天室示例,我们可以看到它在处理复杂实时交互场景时的巨大优势。
五、四种服务类型对比分析
通过前面四个实战案例的深入分析,我们可以从多个维度对比这四种 gRPC 服务类型:
特性维度 | 一元 RPC | 服务端流式 RPC | 客户端流式 RPC | 双向流式 RPC |
---|---|---|---|---|
通信模式 | 1:1 (请求:响应) | 1:N (请求:多响应) | N:1 (多请求:响应) | N:M (多请求:多响应) |
数据流向 | 客户端 → 服务端 → 客户端 | 客户端 → 服务端 ⇒ 客户端 | 客户端 ⇒ 服务端 → 客户端 | 客户端 ⇔ 服务端 |
实时性 | 同步阻塞 | 实时推送 | 批量处理 | 实时双向交互 |
内存占用 | 低 | 中等 | 中等 | 高 |
开发复杂度 | 简单 | 中等 | 中等 | 复杂 |
并发处理 | 简单 | 需要连接管理 | 需要状态管理 | 需要复杂连接管理 |
典型应用 | 用户查询 | 实时监控 | 文件上传 | 实时聊天 |
适用场景总结
一元 RPC:最适合传统的请求-响应场景,如用户认证、数据查询、配置获取等。简单直观,性能稳定,是微服务架构中最常用的通信方式。
服务端流式 RPC:完美解决了实时数据推送的问题,如股票行情、监控告警、实况更新等。避免了客户端轮询的开销,提供了优秀的实时性。
客户端流式 RPC:专门为大数据量上传而设计,如文件上传、数据导入、日志收集等。通过分块传输有效解决了大文件传输的内存和网络问题。
双向流式 RPC:提供了最强大的实时交互能力,如在线聊天、协作编辑、在线游戏等。虽然实现复杂,但为复杂的实时交互场景提供了完美的技术基础。
所有的代码都可以在 github.com/clin211/grp... 中!
六、总结
好了,上面我们也通过四个完整的实战案例,深入探讨了 gRPC 的四种服务类型。每种类型都有其独特的优势和适用场景:
- 一元 RPC 提供了简单可靠的请求-响应通信,是构建微服务的基础
- 服务端流式 RPC 解决了实时数据推送的难题,大大提升了用户体验
- 客户端流式 RPC 完美处理了大数据量上传的挑战,实现了高效的数据传输
- 双向流式 RPC 为复杂的实时交互应用提供了强大的技术支撑
通过这四个案例的对比,我们可以看到 gRPC 的设计哲学:为不同的业务场景提供最适合的通信模式。这种设计让我们能够:
- 在简单场景中保持代码的简洁性
- 在复杂场景中获得必要的灵活性
- 在性能要求高的场景中实现优化
- 在实时交互场景中提供流畅的用户体验
掌握这四种 gRPC 服务类型,不仅能够帮助我们构建当前的微服务系统,更能为未来的技术挑战做好准备。希望这篇文章能够成为你深入理解和应用 gRPC 的重要参考,在实际项目中发挥价值!