深入理解 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 的重要参考,在实际项目中发挥价值!

相关推荐
brzhang6 分钟前
颠覆你对代码的认知:当程序和数据只剩下一棵树,能读懂这篇文章的人估计全球也不到 100 个人
前端·后端·架构
躲在云朵里`18 分钟前
SpringBoot的介绍和项目搭建
java·spring boot·后端
喵个咪42 分钟前
Golang微服框架Kratos与它的小伙伴系列 - 分布式事务框架 - DTM
后端·微服务·go
brzhang1 小时前
我见过了太多做智能音箱做成智障音箱的例子了,今天我就来说说如何做意图识别
前端·后端·架构
晴空月明2 小时前
结构型模式-架构解耦与扩展实践
后端
WanderInk2 小时前
在递归中为什么用 `int[]` 而不是 `int`?——揭秘 Java 参数传递的秘密
java·后端·算法
why技术3 小时前
哎,我糊涂啊!这个需求居然没想到用时间轮来解决。
java·后端·面试
寻月隐君3 小时前
Rust 核心概念解析:引用、借用与内部可变性
后端·rust·github
万粉变现经纪人3 小时前
如何解决pip安装报错ModuleNotFoundError: No module named ‘django’问题
后端·python·pycharm·django·numpy·pandas·pip
ai小鬼头3 小时前
创业心态崩了?熊哥教你用缺德哲学活得更爽
前端·后端·算法