深入理解 gRPC 四种 RPC 通信模式:一元、服务端流、客户端流与双向流

大家好,我是长林啊!一个全栈开发者和 AI 探索者;致力于终身学习和技术分享。

本文首发在我的微信公众号【长林啊】,欢迎大家关注、分享、点赞。

在上一篇文章《Protocol Buffers 语法和类型介绍》中,我们详细探讨了 Protocol Buffers 的语法规范、数据类型系统以及消息定义的最佳实践。我们学会了如何定义 message、使用各种基本数据类型、处理复杂的嵌套结构,以及利用 enumoneofrepeated 等高级特性来构建灵活且高效的数据结构。

然而,仅仅掌握数据结构的定义还远远不够。在实际的分布式系统开发中,如何让这些精心设计的消息在客户端和服务端之间高效、可靠地传输才是关键所在。这就涉及到 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 是指客户端发送一个请求消息给服务端,服务端处理后返回一个响应消息的通信模式。整个过程是同步的,客户端会等待服务端的响应。

主要特点

  1. 简单直观:类似于传统的函数调用,输入参数,获得返回值
  2. 同步阻塞:客户端发送请求后会阻塞等待,直到收到服务端响应或超时
  3. 一对一映射:一个请求对应一个响应,不会有多个响应
  4. 无状态:每次调用都是独立的,不维护连接状态

与 HTTP 的相似性: 一元 RPC 的工作方式与 HTTP 请求-响应模式非常相似,但在性能和功能上有所提升:

  • 性能优势:基于 HTTP/2,支持多路复用和头部压缩
  • 类型安全:通过 Protocol Buffers 提供强类型约束
  • 跨语言:自动生成客户端和服务端代码,支持多种编程语言

适用场景(包含但不限于)

  • 用户认证:登录验证、权限检查
  • 数据查询:根据ID查询用户信息、订单详情等
  • 简单操作:创建、更新、删除单个资源
  • 配置获取:获取系统配置、应用设置
  • 状态检查:健康检查、服务状态查询

1.2 实战案例:用户信息查询服务

下面通过一个简单而实用的用户信息查询服务来深入理解一元 RPC 的使用方法。

案例背景

在微服务架构中,用户信息查询是一个非常常见的基础服务。当系统的各个模块需要获取用户的详细信息时,都会调用用户服务来获取相关数据。这种场景完美地展示了一元 RPC 的典型用法。

业务场景

  1. 客户端请求:其他服务(如订单服务、商品服务)需要根据用户ID获取用户的详细信息
  2. 服务端处理
    • 接收用户ID参数
    • 从数据库或缓存中查询用户信息
    • 验证用户是否存在
    • 返回用户的完整信息
  3. 响应返回:返回包含用户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_DIRROOT_DIRGO_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 是指客户端发送一个请求消息给服务端,服务端返回一个数据流,在这个流中可以包含多个响应消息。客户端从流中读取消息直到没有更多消息为止。

主要特点

  1. 一对多通信:一个请求可以对应多个响应消息
  2. 服务端主动推送:服务端可以在适当的时机主动向客户端发送数据
  3. 流式传输:数据以流的形式持续传输,而不是一次性返回
  4. 单向数据流:数据流向是单向的,从服务端流向客户端
  5. 实时性强:支持实时或近实时的数据推送

与传统轮询的对比: 相比于客户端不断轮询获取数据的方式,服务端流式 RPC 具有明显优势:

  • 减少网络开销:避免了频繁的轮询请求
  • 实时性更好:服务端可以立即推送新数据
  • 服务端控制:由服务端决定何时发送数据,更加灵活
  • 连接复用:一个长连接可以传输多次数据

适用场景(包含但不限于)

  • 实时数据推送:股票价格、汇率变化、传感器数据
  • 大数据集传输:分批返回大量查询结果,避免内存溢出
  • 监控和告警:系统监控数据、日志流、告警信息推送
  • 进度通知:文件处理进度、任务执行状态更新
  • 实况更新:体育比赛比分、新闻推送、社交媒体动态

2.2 实战案例:股票价格实时推送服务

下面通过一个股票价格实时推送服务来深入理解服务端流式 RPC 的使用方法。

案例介绍

在金融交易系统中,实时获取股票价格变化是至关重要的功能。投资者需要实时监控感兴趣的股票价格,以便及时做出交易决策。传统的轮询方式会产生大量无效请求,而服务端流式 RPC 可以实现真正的实时推送。

  1. 客户端订阅:客户端发送订阅请求,指定要监控的股票代码列表
  2. 服务端处理
    • 验证股票代码的有效性
    • 建立长连接,维护客户端订阅关系
    • 监听股票价格变化(从交易所、数据提供商等获取)
    • 当价格发生变化时,立即推送给订阅的客户端
  3. 持续推送:服务端持续监控价格变化,实时推送更新数据
  4. 连接管理:处理客户端断开连接,清理订阅关系

定义 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) {}
}

关键要素解析

  1. stream 关键字:这是服务端流式 RPC 的标志,表示返回值是一个数据流
  2. 订阅模式:客户端发送一次订阅请求,服务端持续推送更新
  3. 实时性:价格变化时立即推送,无需客户端轮询
  4. 多数据:可以推送多只股票的价格信息

消息设计解析

  • 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 命令后,在终端中就会出现类似于下面的效果:

    txt 复制代码
    2025/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 命令后,在终端中就会出现类似于下面的效果:

    txt 复制代码
    2025/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 是指客户端发送一个数据流给服务端,在这个流中可以包含多个请求消息,服务端接收完整个流后返回一个响应消息。

主要特点

  1. 多对一通信:多个请求消息对应一个响应
  2. 客户端主导:由客户端决定何时发送数据、发送多少数据
  3. 流式上传:数据以流的形式分批传输,避免内存溢出
  4. 单向数据流:数据流向是单向的,从客户端流向服务端
  5. 累积处理:服务端通常需要接收完所有数据后才进行处理

与传统方式的对比: 相比于将所有数据打包成一个大请求的方式,客户端流式 RPC 具有明显优势:

  • 内存友好:数据分批传输,不会占用大量内存
  • 网络高效:避免了超大请求可能导致的网络超时
  • 进度可控:可以实现传输进度监控和断点续传
  • 错误恢复:部分失败时可以只重传失败的部分

适用场景(包含但不限于)

  • 文件上传:大文件分块上传、图片/视频上传
  • 数据导入:批量数据导入数据库、CSV/Excel文件处理
  • 日志收集:将大量日志数据流式发送到中央日志系统
  • 传感器数据:物联网设备持续上传传感器数据
  • 实时分析:将数据流发送给分析引擎进行实时处理

3.2 实战案例:分块文件上传服务

下面通过一个分块文件上传服务来深入理解客户端流式 RPC 的使用方法。

案例介绍

在现代 Web 应用中,文件上传是一个非常常见的功能。然而,当文件较大时(如视频文件、高清图片、文档等),传统的一次性上传方式会面临诸多问题:超时、内存占用过高、网络中断导致重传整个文件等。分块上传技术可以很好地解决这些问题。

业务场景

  1. 客户端处理

    • 选择要上传的文件
    • 将文件分割成固定大小的块(如 1MB 每块)
    • 为每个文件块添加元数据(文件ID、块序号、块大小等)
    • 逐块发送给服务端
  2. 服务端处理

    • 接收文件块流
    • 验证块的完整性和顺序
    • 将块数据写入临时文件或缓存
    • 当接收到最后一块时,合并所有块
    • 保存完整文件并返回上传结果
  3. 优势体现

    • 支持大文件上传(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) {}
}

关键要素解析

  1. stream 关键字:这是客户端流式 RPC 的标志,表示输入参数是一个数据流
  2. 分块设计:将大文件分解为小块,便于传输和处理
  3. 元数据丰富:包含文件ID、块序号、总块数等信息,便于服务端重组
  4. 完整性检查:通过块序号、总块数、文件哈希等确保数据完整性

消息设计解析

  • 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 是指客户端和服务端都可以独立地发送数据流,两个数据流是独立操作的,可以按任意顺序进行读写。

主要特点

  1. 全双工通信:客户端和服务端可以同时发送和接收数据
  2. 独立数据流:两个方向的数据流是完全独立的,互不影响
  3. 异步处理:发送和接收可以异步进行,无需等待对方响应
  4. 实时交互:支持真正的实时双向交互
  5. 最大灵活性:结合了前三种模式的所有优势

与其他模式的对比: 双向流式 RPC 集合了所有其他模式的优势,同时提供了最大的灵活性:

  • 相比一元 RPC:支持持续的双向交互,而不仅仅是单次请求-响应
  • 相比服务端流:客户端也可以持续发送数据,不仅仅是接收
  • 相比客户端流:服务端可以在接收过程中就开始响应,无需等待全部接收完成
  • 独特优势:两个数据流完全独立,可以实现复杂的交互模式

适用场景(包含但不限于)

  • 实时聊天:用户既要发送消息,也要接收其他用户的消息
  • 在线游戏:玩家操作上传,游戏状态实时同步
  • 协作编辑:多用户实时编辑文档,操作同步和冲突解决
  • 实时数据分析:客户端持续上传数据,服务端实时返回分析结果
  • 视频会议:音视频数据的双向实时传输
  • 交易系统:实时报价推送和交易指令提交

4.2 实战案例:实时聊天室服务

下面通过一个实时聊天室服务来深入理解双向流式 RPC 的使用方法。

案例介绍

实时聊天是双向流式 RPC 最典型的应用场景之一。在聊天室中,用户需要能够:发送消息给其他用户,同时实时接收其他用户发送的消息。这种双向实时交互正是双向流式 RPC 的强项。

业务场景

  1. 用户连接

    • 用户加入聊天室,建立双向流连接
    • 服务端为用户分配唯一标识
    • 向其他用户广播新用户加入消息
  2. 消息发送

    • 用户输入消息并发送到服务端
    • 服务端接收消息并验证
    • 服务端将消息广播给所有在线用户
  3. 消息接收

    • 服务端实时推送其他用户的消息
    • 客户端接收并显示消息
    • 支持不同类型的消息(文本、系统通知等)
  4. 用户离开

    • 用户断开连接时清理资源
    • 向其他用户广播用户离开消息

定义 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) {}
}

关键要素解析

  1. 双向 stream :输入和输出都使用 stream 关键字,表示双向数据流
  2. 消息类型 :通过 MessageType 枚举区分不同类型的消息
  3. 用户标识:包含用户ID和用户名,便于消息归属和显示
  4. 时间戳:确保消息的时序性和历史记录

消息设计解析

  • 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 的四种服务类型。每种类型都有其独特的优势和适用场景:

  1. 一元 RPC 提供了简单可靠的请求-响应通信,是构建微服务的基础
  2. 服务端流式 RPC 解决了实时数据推送的难题,大大提升了用户体验
  3. 客户端流式 RPC 完美处理了大数据量上传的挑战,实现了高效的数据传输
  4. 双向流式 RPC 为复杂的实时交互应用提供了强大的技术支撑

通过这四个案例的对比,我们可以看到 gRPC 的设计哲学:为不同的业务场景提供最适合的通信模式。这种设计让我们能够:

  • 在简单场景中保持代码的简洁性
  • 在复杂场景中获得必要的灵活性
  • 在性能要求高的场景中实现优化
  • 在实时交互场景中提供流畅的用户体验

掌握这四种 gRPC 服务类型,不仅能够帮助我们构建当前的微服务系统,更能为未来的技术挑战做好准备。希望这篇文章能够成为你深入理解和应用 gRPC 的重要参考,在实际项目中发挥价值!

相关推荐
调试人生的显微镜7 小时前
iOS 代上架实战指南,从账号管理到使用 开心上架 上传IPA的完整流程
后端
本就一无所有 何惧重新开始7 小时前
Redis技术应用
java·数据库·spring boot·redis·后端·缓存
低音钢琴7 小时前
【SpringBoot从初学者到专家的成长11】Spring Boot中的application.properties与application.yml详解
java·spring boot·后端
越千年7 小时前
用Go实现类似WinGet风格彩色进度条
后端
淳朴小学生7 小时前
静态代理和动态代理
后端
渣哥8 小时前
数据一致性全靠它!Spring 事务传播行为没搞懂=大坑
javascript·后端·面试
Mgx8 小时前
高性能 Go 语言带 TTL 的内存缓存实现:精确过期、自动刷新、并发安全
go
三七互娱后端团队8 小时前
Serena语义检索在AI CodeReview中的应用
后端
Java水解8 小时前
Nginx平滑升级与location配置案例详解
后端·nginx