4. gRPC 服务端流代码实战(Go):定义 proto 文件、生成 Go 代码、实现流式服务端、编写客户端接收流

本章将带领你从零开始,使用 Go 语言实现一个完整的 gRPC 服务端流模式。我们将依次完成 Proto 文件定义、Go 代码生成、流式服务端实现 以及 客户端流接收 四个核心步骤。通过本实战,你将掌握服务端流的核心开发流程与关键细节。

前置准备:确保已安装 protoc 编译器、protoc-gen-go 和 protoc-gen-go-grpc 插件,并已初始化 Go 模块。

4.1 定义 Proto 文件:声明服务端流 RPC

服务端流 RPC 的关键在于 响应消息前使用 stream 关键字。以下示例定义了一个 StockService,客户端发送一个股票代码请求,服务端持续返回实时价格流。

syntax = "proto3";

package stock;

option go_package = "github.com/your-project/stockpb";

// 请求:单个股票代码
message StockRequest {
  string symbol = 1;
}

// 响应:实时价格数据
message StockPrice {
  string symbol = 1;
  double price = 2;
  int64 timestamp = 3;
}

// 服务定义:服务端流 RPC
service StockService {
  // 客户端发送一个请求,服务端返回价格流
  rpc SubscribeStock(StockRequest) returns (stream StockPrice);
}
元素 说明
stream StockPrice 在响应类型前加 stream,表示服务端将发送多条消息
StockRequest 客户端只发送一次请求,包含股票代码
StockPrice 每条消息包含价格和时间戳,服务端可连续发送

4.2 生成 Go 代码:protoc 编译与产物结构

在项目根目录执行以下命令,生成 Go 代码:

protoc --go_out=. --go-grpc_out=. \
  --go_opt=paths=source_relative \
  --go-grpc_opt=paths=source_relative \
  proto/stock.proto

执行后,proto/ 目录下会生成两个文件:

  • stock.pb.go:消息类型的 Go 结构体(StockRequest、StockPrice)及序列化方法。
  • stock_grpc.pb.go:服务端接口、客户端桩代码及注册函数。
关键接口:生成的 StockServiceServer 接口中,SubscribeStock 方法的签名如下:
SubscribeStock(*StockRequest, StockService_SubscribeStockServer) error
第二个参数 StockService_SubscribeStockServer 提供了 Send(*StockPrice) error 方法,用于向客户端发送流数据。

4.3 实现流式服务端:发送数据流

服务端需要实现 SubscribeStock 方法,在循环中通过 stream.Send() 持续推送数据。以下是一个模拟实时股票价格的实现:

type stockServer struct {
  stockpb.UnimplementedStockServiceServer
}

func (s *stockServer) SubscribeStock(
  req *stockpb.StockRequest,
  stream stockpb.StockService_SubscribeStockServer,
) error {
  symbol := req.GetSymbol()
  log.Printf("订阅股票: %s", symbol)

  // 模拟持续发送价格数据
  for i := 0; i < 10; i++ {
    price := &stockpb.StockPrice{
      Symbol:    symbol,
      Price:     100.0 + float64(i)*0.5,
      Timestamp: time.Now().UnixMilli(),
    }
    // 发送消息到流
    if err := stream.Send(price); err != nil {
      log.Printf("发送失败: %v", err)
      return err
    }
    time.Sleep(1 * time.Second) // 模拟实时间隔
  }
  return nil
}
注意:stream.Send() 是阻塞调用,受 gRPC 流控和网络影响。如果客户端接收缓慢,服务端可能因背压(backpressure)而阻塞。生产环境中应合理控制发送速率。

4.4 编写客户端接收流:逐条处理

客户端通过 Recv() 方法循环读取服务端推送的每条消息,直到流结束或发生错误。

func main() {
  conn, _ := grpc.Dial("localhost:50051", grpc.WithInsecure())
  defer conn.Close()

  client := stockpb.NewStockServiceClient(conn)
  req := &stockpb.StockRequest{Symbol: "GOOG"}

  // 调用服务端流 RPC,返回一个流对象
  stream, err := client.SubscribeStock(context.Background(), req)
  if err != nil {
    log.Fatalf("调用失败: %v", err)
  }

  // 循环接收流消息
  for {
    price, err := stream.Recv()
    if err == io.EOF {
      log.Println("流结束")
      break
    }
    if err != nil {
      log.Fatalf("接收错误: %v", err)
    }
    log.Printf("收到价格: %s = %.2f @ %d",
      price.GetSymbol(), price.GetPrice(), price.GetTimestamp())
  }
}

4.5 服务端流通信流程总览

下图展示了服务端流 RPC 的完整通信流程,从客户端发起请求到流结束:

服务端流 RPC 通信流程 客户端 服务端 1. SubscribeStock(StockRequest) 2. stream.Send(StockPrice) × N 3. stream.Send(StockPrice) × N 4. 方法返回 → 流结束 (io.EOF) for { Recv() } for { Send() } 客户端 Recv() 返回 io.EOF 时表示流正常结束

4.6 代码结构对比:一元 RPC vs 服务端流 RPC

对比维度 一元 RPC 服务端流 RPC
Proto 定义 rpc Foo(Request) returns (Response) rpc Foo(Request) returns (stream Response)
服务端实现 返回单个响应 循环调用 stream.Send()
客户端接收 一次 client.Foo() 调用 循环调用 stream.Recv() 直到 io.EOF
适用场景 查询、写入、简单请求-应答 实时推送、日志流、监控数据、事件通知

4.7 常见错误与避坑指南

  • 忘记处理 io.EOF:客户端 Recv() 返回 io.EOF 是正常结束信号,不要当作错误处理。
  • 服务端未关闭流:方法返回后,gRPC 框架会自动关闭流。不要在 Send() 后手动调用 CloseSend()(那是客户端用的)。
  • 阻塞问题:如果服务端发送速度远快于客户端处理速度,Send() 会阻塞。建议在服务端加入速率限制或背压感知逻辑。
  • Context 取消:客户端取消请求时,服务端应通过 stream.Context().Done() 检测并退出循环,避免资源泄漏。
生产建议:在服务端流中,始终监听 stream.Context().Done() 通道,配合 select 实现优雅退出。示例:
select {
case <-stream.Context().Done():
    return stream.Context().Err()
default:
    // 继续发送
}