go-grpc使用

环境搭建和grpc程序

一、安装三件套

1. protoc 编译器(C++ 写的,负责解析 .proto)

Windows 下载二进制:

Go 复制代码
https://ghfast.top/https://github.com/protocolbuffers/protobuf/releases
# 选 protoc-<版本>-win64.zip,解压后把 bin/protoc.exe 所在目录加入 PATH

验证:

Go 复制代码
protoc --version   # libprotoc 29.x 以上即可

2. Go 代码生成插件(纯 Go,go install 拉取)

Go 复制代码
# 国内先配模块代理
go env -w GOPROXY=https://goproxy.cn,direct

# 生成消息结构体(.pb.go)的插件
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

# 生成 gRPC 服务代码(_grpc.pb.go)的插件
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

插件二进制装在 $GOPATH/bin(通常是 C:\Users\<你>\go\bin),这个目录也必须在 PATH 里 ,否则 protoc 报 protoc-gen-go: program not found。

3. 项目依赖

Go 复制代码
mkdir demo && cd demo
go mod init demo
go get google.golang.org/grpc
go get google.golang.org/protobuf

二、四步流水线总览

Go 复制代码
┌──────────┐   protoc    ┌────────────────┐   实现/调用   ┌─────────────┐
│ xxx.proto │ ─────────► │ xxx.pb.go       │ ───────────► │ server/main │
│ (契约)    │            │ xxx_grpc.pb.go  │              │ client/main │
└──────────┘            └────────────────┘              └─────────────┘
   你写的                  工具生成的                        你写的

proto 文件是契约:客户端和服务端可以不同语言,只要都从同一份 proto 生成代码,就能互通------这就是 gRPC 跨语言的本质。

三、第一步:写 proto

Go 复制代码
syntax = "proto3";                                          // 必须是第一行有效语句

option go_package = "demo/proto;pb";                        // 生成路径;Go包名
package helloworld;                                         // 命名空间,防止消息重名

service Greeter {                                           // 服务 = 一组 RPC 方法
  rpc SayHello (HelloRequest) returns (HelloReply) {}       // 一元调用
}

message HelloRequest {                                      // 消息 = 结构体
  string name = 1;                                          // 每个字段必须有编号(1~15 最省字节)
}

message HelloReply {
  string message = 1;
}

proto/helloworld.proto(对照官方示例 examples/helloworld/helloworld/helloworld.proto):

四、第二步:生成代码

Go 复制代码
protoc --proto_path=proto `
       --go_out=proto --go_opt=paths=source_relative `
       --go-grpc_out=proto --go-grpc_opt=paths=source_relative `
       helloworld.proto

生成两个文件:

|-------------------------|-----------------------------------------------------------------------|--------------------|
| 文件 | 内容 | 谁负责 |
| helloworld.pb.go | HelloRequest/HelloReply 结构体、序列化、getter | protoc-gen-go |
| helloworld_grpc.pb.go | GreeterClient(客户端存根)、GreeterServer(服务端接口)、RegisterGreeterServer | protoc-gen-go-grpc |

五、第三步:服务端(3 个固定动作)

server/main.go:

Go 复制代码
package main

import (
    "context"
    "fmt"
    "net"

    "google.golang.org/grpc"
    pb "demo/proto"   // 按你的 go_package 调整
)

// 1. 定义结构体,嵌入 UnimplementedGreeterServer
//    (必须嵌入:它提供了所有方法的默认实现------返回 Unimplemented,
//     这是"向前兼容"的关键:proto 加了新方法,旧服务不会编译失败)
type server struct {
    pb.UnimplementedGreeterServer
}

// 2. 实现业务方法,签名由生成的接口规定
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
    return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
    lis, err := net.Listen("tcp", ":50051")   // 监听端口(还是普通 TCP)
    if err != nil {
        panic(err)
    }
    s := grpc.NewServer()                     // 创建 gRPC 服务器
    pb.RegisterGreeterServer(s, &server{})    // 注册服务实现
    fmt.Println("listening on :50051")
    if err := s.Serve(lis); err != nil {      // 开始服务(阻塞)
        panic(err)
    }
}

对照官方示例:examples/helloworld/greeter_server/main.go:50-55(net.Listen → grpc.NewServer() → RegisterGreeterServer,三步一模一样)。

六、第四步:客户端

client/main.go:

Go 复制代码
package main

import (
    "context"
    "fmt"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    pb "demo/proto"
)

func main() {
    // ① 创建"通道"------注意:NewClient 不做任何 I/O,不会连接!
    //    第一次 RPC 时才自动建连(懒连接)
    conn, err := grpc.NewClient("localhost:50051",
        grpc.WithTransportCredentials(insecure.NewCredentials())) // 本地调试免 TLS
    if err != nil {
        panic(err)
    }
    defer conn.Close()

    c := pb.NewGreeterClient(conn)          // ② 拿到服务客户端存根

    ctx, cancel := context.WithTimeout(context.Background(), time.Second) // ③ 超时控制
    defer cancel()

    r, err := c.SayHello(ctx, &pb.HelloRequest{Name: "world"})           // ④ 发起调用
    if err != nil {
        panic(err)                          // 错误永远是 gRPC status,见 06 篇
    }
    fmt.Println(r.GetMessage())
}

对照官方:examples/helloworld/greeter_client/main.go:45-55,五步完全一致。

七、跑起来

Go 复制代码
# 终端 1
go run server/main.go
# 终端 2
go run client/main.go
# 输出: Hello world

八、必知的 5 个坑

  1. grpc.Dial/ grpc.DialContext已过时 ------新代码一律用 grpc.NewClient(clientconn.go:157 起)。它创建时不联网 ,所以 err 几乎永远为 nil,连接失败会在第一次 RPC 时才暴露。
  1. insecure凭证不能省 ------没有 WithTransportCredentials 会直接报错。生产环境换成 credentials.NewClientTLSFromFile(...)。
  1. 忘嵌 UnimplementedGreeterServer → 编译报错。它不是为了偷懒,是跨语言 RPC 的向前兼容契约。
  1. protoc 找不到插件 → $GOPATH/bin 不在 PATH。
  1. Windows 防火墙弹窗 → 第一次 go run server 时放行,否则 client 连不上。

Protobuf与代码生成

一、Protobuf 是什么,为什么用它

Protobuf = 接口描述语言(IDL)+ 二进制编码格式 + 各语言运行时 三合一。

与 JSON 对比:

|--------|------------|--------------------------|
| 维度 | JSON | Protobuf |
| 编码 | 文本 | 二进制(varint 变长整数等技巧) |
| 体积 | 大(字段名重复出现) | 小(字段名变成 1~2 字节的编号) |
| 速度 | 反射 + 字符串解析 | 编译期生成代码,顺序读写 |
| 可读性 | 人直接可读 | 必须 protoc --decode 或工具 |
| Schema | 无强制 | proto 文件即强契约 |

gRPC 选它做默认序列化(编码器注册表见 08 篇 encoding 包),但 gRPC 本身不绑死 Protobuf------理论上任何编解码器都能用(encoding/codec.go)。

二、proto3 核心语法速览

Go 复制代码
syntax = "proto3";                     // 第 3 版语法(还有新出的 edition,见到不慌)

package shop.order;                    // 包名:防止跨项目的消息重名

option go_package = "demo/gen;pb";     // 生成 Go 代码的 import 路径;包名

import "google/protobuf/timestamp.proto";   // 引用其他 proto(如 Timestamp 等常用类型)

// ===== 消息定义 =====
message Order {
  string        id       = 1;          // 标量字段
  int32         amount   = 2;
  double        price    = 3;
  bool          paid     = 4;
  repeated string tags   = 5;          // 数组 → Go 的 []string
  map<string,int32> attrs = 6;         // 映射 → Go 的 map[string]int32
  Status       status   = 7;           // 枚举
  Address      addr     = 8;           // 嵌套消息 → Go 的指针 *Address
  google.protobuf.Timestamp created_at = 9;  // Well-Known Types

  reserved 10, 11 to 15;               // 保留编号:曾用后删除的字段号不许复用!
  reserved "legacy_field";             // 保留字段名

  enum Status {                        // 枚举必须有个 0 值
    STATUS_UNKNOWN = 0;                // proto3 规定 0 = 默认值
    STATUS_PENDING = 1;
    STATUS_PAID    = 2;
  }
}

message Address {
  string city = 1;
  string road = 2;
}

字段编号规则(最重要的一条)

  • 编号 1~15:编码只占 1 字节(含类型信息)→ 高频字段用这些;
  • 16~2047:占 2 字节;
  • 编号一旦发布就不能改、不能复用 ------旧客户端的 1 号字段会被新服务端解释成完全不同的字段,这是线上事故的常见来源。删字段必须 reserved 留痕。

proto3 的"零值即缺省"哲学

字段没有 required/optional(proto2 有,proto3 删了)。没有设置的字段不参与编码 ,读取时得到 Go 零值(""/0/false)。所以:

  • 判断"字段有没有被设置"不能靠零值------需要用 wrapper 类型(google.protobuf.Int32Value)或 optional 关键字(proto3 后期加回);
  • 消息字段是 nil 还是空对象,语义不同。

三、gRPC 服务定义------stream 关键字决定通信模式

Go 复制代码
service RouteGuide {                                     // 4 种模式全在这
  rpc GetFeature (Point) returns (Feature) {}            // ① 一元
  rpc ListFeatures (Rectangle) returns (stream Feature) {}      // ② 服务端流
  rpc RecordRoute (stream Point) returns (RouteSummary) {}      // ③ 客户端流
  rpc RouteChat (stream RouteNote) returns (stream RouteNote) {} // ④ 双向流
}

stream写在哪边,哪边就能多次收/发 。规则只有一条:参数或返回值前加了 stream,该方法就是流式方法(详见 03 篇)。来源:examples/route_guide/routeguide/route_guide.proto:26-53。

四、protoc 的工作原理

Go 复制代码
你执行:
  protoc --go_out=. --go-grpc_out=. order.proto

内部发生:
┌─────────┐  解析成 AST   ┌──────────────┐  CodeGeneratorRequest  ┌────────────────┐
│ .proto  │ ───────────► │ protoc(C++)  │ ────────protobuf──────► │ protoc-gen-go  │
└─────────┘              │  语法/语义检查 │        (stdin,二进制)    │ (Go插件进程)    │
                         └──────────────┘                         └───────┬────────┘
                          插件发现:两个插件都请求生成    CodeGeneratorResponse  │
                          (每个 --xx_out 起一个子进程)  (stdout,二进制)        │
                                                                      ▼
                                                            写出 .pb.go / _grpc.pb.go

protoc 本身不懂 Go------它把解析好的语法树通过 stdin 以二进制协议 递给插件进程(protoc-gen-go),插件生成代码文本再从 stdout 递回来。这是个通用插件框架:protoc-gen-java、protoc-gen-python 都这么工作。

五、生成物解剖(以 helloworld 为例)

helloworld.pb.go ------ 消息层

打开 examples/helloworld/helloworld/helloworld.pb.go,重点看四样东西:

Go 复制代码
// 1. 结构体:字段全是私有的 + 导出的 Getter(零值安全)
type HelloRequest struct {
    state protoimpl.MessageState
    Name  string `protobuf:"bytes,1,opt,name=name,json=name,proto3" json:"name,omitempty"`
    ...
}
func (x *HelloRequest) GetName() string { if x != nil { return x.Name }; return "" }

// 2. 实现 proto.Message 接口(Reset/String/ProtoReflect)
//    → 这就是 grpc-go 眼里"消息"的全部要求(08 篇的 codec 只认这个接口)
var _ protoreflect.ProtoMessage = (*HelloRequest)(nil)

// 3. 序列化/反序列化:手写的 Marshal/Unmarshal,逐字段顺序读写,零反射
//    (新版用了 protoimpl.MessageState + lazy 深度优化,思路不变)

// 4. init() 里向全局类型注册表登记自己

helloworld_grpc.pb.go ------ 服务层(gRPC 的关键)

Go 复制代码
// ① 服务端接口 ------ 你在 server/main.go 里实现的就是它
type GreeterServer interface {
    SayHello(context.Context, *HelloRequest) (*HelloReply, error)
    mustEmbedUnimplementedGreeterServer()      // 强制嵌入,保证向前兼容
}

// ② 客户端存根 ------ 持有一个 ClientConn,方法即 RPC
type greeterClient struct { grpc.ClientConnInterface }
func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest,
    opts ...grpc.CallOption) (*HelloReply, error) {
    // 核心就一行:把一切交给 ClientConn.Invoke(见 08 篇调用链第一站)
    out := new(HelloReply)
    err := c.cc.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out, opts...)
    return out, err
}

// ③ 注册函数 ------ 建立 "/包名.服务名/方法名" → 处理函数 的映射
func RegisterGreeterServer(s grpc.ServiceRegistrar, srv GreeterServer) { ... }

// ④ Greeter_ServiceDesc ------ 服务描述符:方法名、处理函数、流模式的元信息
//    服务端 handleStream 靠它分发(09 篇)

注意 ② 里的方法全名 /helloworld.Greeter/SayHello------它就是你抓包时在 HTTP/2 HEADERS 帧里看到的 :path(11 篇画报文图会用到)。

六、Makefile 模板(写进项目根目录一劳永逸)

Go 复制代码
PROTO_DIR := proto
GEN_DIR   := gen

protoc:
    protoc --proto_path=$(PROTO_DIR) \
           --go_out=$(GEN_DIR) --go_opt=paths=source_relative \
           --go-grpc_out=$(GEN_DIR) --go-grpc_opt=paths=source_relative \
           $(PROTO_DIR)/*.proto

四种通信模式

一、为什么恰好是四种

流式与否由 proto 里 stream 关键字决定,组合只有四种:

|-------------------------|-----------------------------------------|-----|-----|--------------|
| 模式 | proto 写法 | 请求 | 响应 | 典型场景 |
| ① 一元 Unary | rpc M (A) returns (B) | 1 条 | 1 条 | 普通接口调用(增删改查) |
| ② 服务端流 Server-streaming | rpc M (A) returns (stream B) | 1 条 | N 条 | 下发列表、推送、订阅 |
| ③ 客户端流 Client-streaming | rpc M (stream A) returns (B) | N 条 | 1 条 | 上传、批量提交、打点聚合 |
| ④ 双向流 Bidi-streaming | rpc M (stream A) returns (stream A/B) | N 条 | N 条 | 聊天、实时同步、代理管道 |

底层视角(重要) :gRPC 里没有"非流",一切都是 HTTP/2 stream 。一元调用 = 只发一条消息就 CloseSend、只收一条就结束的"退化的流"。源码证据:客户端一元调用用的描述符是 unaryStreamDesc = &StreamDesc{ServerStreams: false, ClientStreams: false}(call.go:67),走的和流式完全相同的 newClientStream 路径(08 篇)。

二、proto 定义(route_guide 实拍)

examples/route_guide/routeguide/route_guide.proto:26-53:

Go 复制代码
service RouteGuide {
  rpc GetFeature(Point) returns (Feature) {}                    // ① 一元
  rpc ListFeatures(Rectangle) returns (stream Feature) {}       // ② 服务端流
  rpc RecordRoute(stream Point) returns (RouteSummary) {}       // ③ 客户端流
  rpc RouteChat(stream RouteNote) returns (stream RouteNote) {} // ④ 双向流
}

三、每种模式的客户端 + 服务端写法

① 一元:和调本地函数一样

Go 复制代码
// client ------ 直接调用,返回 (响应, error)
feature, err := client.GetFeature(ctx, &pb.Point{Latitude: 409146138, Longitude: -746188906})

// server ------ 实现 proto 生成的接口
func (s *routeGuideServer) GetFeature(ctx context.Context, p *pb.Point) (*pb.Feature, error) {
    for _, f := range s.savedFeatures {
        if f.Location.Latitude == p.Latitude && f.Location.Longitude == p.Longitude {
            return f, nil
        }
    }
    return nil, status.Errorf(codes.NotFound, "no feature at %v", p)   // 错误规范见 06 篇
}

② 服务端流:服务端发 N 条,客户端循环收

Go 复制代码
// server ------ 返回值是 RouteGuide_ListFeaturesServer(一种流句柄),
//           for 循环里多次 Send,函数 return = 流结束
func (s *routeGuideServer) ListFeatures(rect *pb.Rectangle,
    stream pb.RouteGuide_ListFeaturesServer) error {
    for _, f := range s.savedFeatures {
        if inRange(f.Location, rect) {
            if err := stream.Send(f); err != nil {
                return err          // 任何一条失败,整条流终止
            }
        }
    }
    return nil                     // nil = 正常结束(客户端收到 io.EOF)
}

// client ------ 调用立刻返回流对象,for + Recv 读取,Recv 返回 io.EOF 即收完
stream, err := client.ListFeatures(ctx, rect)
for {
    feature, err := stream.Recv()
    if err == io.EOF {
        break                       // 正常结束
    }
    if err != nil {
        return err                  // 中途出错(含服务端 status 错误)
    }
    fmt.Println(feature)
}

③ 客户端流:客户端发 N 条,服务端最后回 1 条

Go 复制代码
// server ------ 参数是流,循环 Recv 收完(err==io.EOF),然后返回汇总
func (s *routeGuideServer) RecordRoute(stream pb.RouteGuide_RecordRouteServer) error {
    var pointCount, distance int32
    var last *pb.Point
    startTime := time.Now()
    for {
        point, err := stream.Recv()
        if err == io.EOF {
            return stream.SendAndClose(&pb.RouteSummary{   // 收尾时发唯一的响应
                PointCount: pointCount, Distance: distance,
                ElapsedTime: int32(time.Since(startTime).Seconds()),
            })
        }
        if err != nil {
            return err
        }
        pointCount++
        if last != nil {
            distance += calcDistance(last, point)
        }
        last = point
    }
}

// client ------ 拿到流对象后多次 Send,SendAndClose 表示"我说完了"并收响应
stream, err := client.RecordRoute(ctx)
for _, p := range points {
    if err := stream.Send(p); err != nil {
        return err
    }
}
summary, err := stream.CloseAndRecv()   // 关闭发送 + 收取唯一响应

④ 双向流:两边都能随时收发(读和发完全独立)

Go 复制代码
// server ------ 收发可以放在不同 goroutine,互不阻塞
func (s *routeGuideServer) RouteChat(stream pb.RouteGuide_RouteChatServer) error {
    for {
        in, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        key := serialize(in.Location)
        if notes, ok := s.routeNotes[key]; ok {   // 收到一条,把历史消息推回去
            for _, note := range notes {
                if err := stream.Send(note); err != nil {
                    return err
                }
            }
        }
        s.routeNotes[key] = append(s.routeNotes[key], in)
    }
}

// client ------ 标准姿势:一个 goroutine 专职 Recv,主 goroutine 随时 Send
stream, err := client.RouteChat(ctx)
waitc := make(chan struct{})
go func() {                       // 读协程
    for {
        in, err := stream.Recv()
        if err == io.EOF {
            close(waitc)
            return
        }
        if err != nil {
            panic(err)
        }
        fmt.Println("收到:", in.Message)
    }
}()
for _, note := range myNotes {    // 主协程写
    if err := stream.Send(note); err != nil {
        panic(err)
    }
}
stream.CloseSend()                // 告诉服务端:我不再发了(服务端 Recv 将得到 EOF)
<-waitc

四、双向流的三条铁律(生产事故高发)

  1. 读必须放独立 goroutine ------Send 和 Recv 可以并发,但同一个流上两个 goroutine 同时 Send(或同时 Recv)是数据竞争,会直接 panic;
  1. CloseSend()只关闭发送方向 ------之后服务端 Recv 得到 io.EOF,但你仍可以继续 Recv 收对方的消息;
  1. 流有生命周期------客户端 ctx 取消 / 任一侧出错 / 服务端 handler return,整条流(含 HTTP/2 stream)都会关闭,别在 handler 里起"比流活得久"的 goroutine 还去写流。

五、流式 RPC 的错误与状态

流式调用中任何一侧出错:

  • 错误会沿着流传播,Recv/Send 返回该错误;
  • 服务端的最终状态(grpc-status)在流结束时的 trailer 里送达(11 篇报文图);
  • 判断"正常结束"永远用 err == io.EOF,其余 err 都应视为失败并检查 status.Code(err)。

拦截器Interceptor

一、四种拦截器(2 客户端 × 2 服务端 × 一元/流)

类型定义全部在 interceptor.go(本仓库根目录,仅 108 行,值得全文读一遍):

Go 复制代码
// interceptor.go:43 ------ 客户端一元
type UnaryClientInterceptor func(ctx, method string, req, reply any,
    cc *ClientConn, invoker UnaryInvoker, opts ...CallOption) error

// interceptor.go:63 ------ 客户端流
type StreamClientInterceptor func(ctx, desc *StreamDesc, cc *ClientConn,
    method string, streamer Streamer, opts ...CallOption) (ClientStream, error)

// interceptor.go:87 ------ 服务端一元
type UnaryServerInterceptor func(ctx, req any, info *UnaryServerInfo,
    handler UnaryHandler) (resp any, err error)

// interceptor.go:108 ------ 服务端流
type StreamServerInterceptor func(srv any, ss ServerStream,
    info *StreamServerInfo, handler StreamHandler) error

与 Gin 中间件对照(概念完全同构):

Go 复制代码
Gin:          func(c *gin.Context) { 前置逻辑; c.Next(); 后置逻辑 }
grpc 一元:     func(ctx, req, info, handler) { 前置; resp, err := handler(ctx, req); 后置 }
                                          └── handler 就是 c.Next()------不调用它,链就断了

二、注册方式

服务端(grpc.NewServer 时挂载)

Go 复制代码
s := grpc.NewServer(
    grpc.ChainUnaryInterceptor(          // 链式:按参数顺序依次包裹
        loggingInterceptor,              // ① 最外层
        authInterceptor,                 // ②
        recoveryInterceptor,             // ③ 最内层(贴着业务)
    ),
    grpc.ChainStreamInterceptor(
        streamLogging, streamRecovery,
    ),
)

链的执行顺序(与洋葱模型一致):

Go 复制代码
请求 → logging(前) → auth(前) → recovery(前) → 业务handler
     ← logging(后) ← auth(后) ← recovery(后) ← 响应

客户端(grpc.NewClient 时挂载)

Go 复制代码
conn, err := grpc.NewClient(target,
    grpc.WithTransportCredentials(creds),
    grpc.WithChainUnaryInterceptor(otelInterceptor, retryInterceptor),
    grpc.WithChainStreamInterceptor(...),
)

三、实战 1:耗时统计拦截器(目标检验②,服务端一元版)

Go 复制代码
package interceptors

import (
    "context"
    "log"
    "time"

    "google.golang.org/grpc"
)

func TimingUnaryServerInterceptor() grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req any,
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (any, error) {
        start := time.Now()

        // ---- 前置:调用真正的业务 handler ----
        resp, err := handler(ctx, req)

        // ---- 后置:记录耗时 ----
        // info.FullMethod 形如 "/helloworld.Greeter/SayHello"
        log.Printf("[grpc] %s 耗时 %v err=%v",
            info.FullMethod, time.Since(start), err)
        return resp, err   // 错误必须原样返回,不能吞
    }
}

注册:

Go 复制代码
s := grpc.NewServer(grpc.ChainUnaryInterceptor(TimingUnaryServerInterceptor()))

进阶:把耗时打到 Prometheus 直方图(namespace="grpc"、label=method),这就是监控系统的 RPC 耗时指标来源。

四、实战 2:耗时拦截器(客户端版,流式也覆盖)

Go 复制代码
func TimingClientInterceptor() grpc.UnaryClientInterceptor {
    return func(ctx context.Context, method string, req, reply any,
        cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {

        start := time.Now()
        err := invoker(ctx, method, req, reply, cc, opts...)  // 真正发出 RPC
        log.Printf("[client] %s 耗时 %v err=%v", method, time.Since(start), err)
        return err
    }
}

// 流式客户端拦截器:包一层 ClientStream,才能测到"整条流"的耗时
func TimingStreamClientInterceptor() grpc.StreamClientInterceptor {
    return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn,
        method string, streamer grpc.Streamer, opts ...grpc.CallOption,
    ) (grpc.ClientStream, error) {

        cs, err := streamer(ctx, desc, cc, method, opts...)
        if err != nil {
            return nil, err
        }
        return &timingClientStream{ClientStream: cs, method: method, start: time.Now()}, nil
    }
}

type timingClientStream struct {
    grpc.ClientStream
    method string
    start  time.Time
}

func (s *timingClientStream) CloseSend() error {
    err := s.ClientStream.CloseSend()
    log.Printf("[client-stream] %s 发送阶段耗时 %v", s.method, time.Since(s.start))
    return err
}

流式拦截器的关键心法 :拦截器本身只包住"建流"这一刻;要拦截流上的每一条消息,必须再包一层 ClientStream/ServerStream (装饰器),在 SendMsg/RecvMsg 里做文章。

五、实战 3:生产三件套(识别即可,不必背)

Go 复制代码
// ① panic 恢复 ------ 业务 panic 不该打死整个 server 进程
func RecoveryUnary() grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any,
        info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
        defer func() {
            if r := recover(); r != nil {
                err = status.Errorf(codes.Internal, "panic: %v", r)
            }
        }()
        return handler(ctx, req)
    }
}

// ② 从 metadata 取 token 鉴权(-metadata 用法见 06 篇)
func AuthUnary(validTokens map[string]bool) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any,
        info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        md, ok := metadata.FromIncomingContext(ctx)
        if !ok {
            return nil, status.Error(codes.Unauthenticated, "missing metadata")
        }
        if !validTokens[md.Get("authorization")[0]] {
            return nil, status.Error(codes.Unauthenticated, "invalid token")
        }
        return handler(ctx, req)
    }
}

// ③ context 超时传播检查:客户端没设 deadline 就拒绝(防雪崩)
func EnsureDeadline() grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any,
        info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        if _, ok := ctx.Deadline(); !ok {
            return nil, status.Error(codes.InvalidArgument, "deadline required")
        }
        return handler(ctx, req)
    }
}

超时与keepalive连接管理

一、超时控制(Deadline)

1. 超时不是参数,是 context 的一部分

gRPC 没有独立的 timeout 参数------超时附着在 ctx 上,随调用自动传播:

Go 复制代码
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()   // 永远要 defer,否则泄漏
resp, err := c.SayHello(ctx, req)

2. 跨服务传播(微服务的救命机制)

Go 复制代码
A ──(3s 超时)──► B ──(剩下 2.5s)──► C ──(剩下 1.8s)──► D(数据库)

A 发出的请求头里带 grpc-timeout: 3000m;B 收到后它的 ctx 继承剩余时间 而不是重新计时;链路上任何一环超时,整条链路同时收到 codes.DeadlineExceeded。这就是超时预算 ------不会出现"A 已超时放弃,C 还在傻算"的资源浪费。(grpc-timeout 头的编解码在 encoding/timeout.go,源码走读见 08 篇。)

3. 超时相关的错误码

|--------------------|-------|--------------------|
| codes | 含义 | 典型原因 |
| DeadlineExceeded | 截止时间到 | 下游真的太慢,或链路某环卡住 |
| Canceled | 主动取消 | 上游放弃 / 用户关页面 |
| Unavailable | 连不上 | 服务挂了、网络分区(重试的主要对象) |

判断错误类别用 status.Code(err),永远不要字符串匹配错误内容。

4. 生产建议

  • 客户端每次调用都必须有 deadline(可配合 04 篇的 EnsureDeadline 拦截器强制);
  • 服务端读消息尺寸限制、连接超时等用 grpc.ServerOption 配置(maxConnectionIdle 等见下节);
  • 区分"业务超时"和"连接建立超时":grpc.WithContextDialer / grpc.WithConnectTimeout 管后者。

二、keepalive:让"死连接"尽快暴露

问题:TCP 半开连接

客户端到服务端的 NAT/防火墙/交换机静默断开后,TCP 不通知两端。没有 keepalive 时,客户端会在一条早已死掉的连接上发请求,直到超时才发现------平均要等几十秒。

客户端参数(keepalive/keepalive.go:33-60,权威注释实拍)

Go 复制代码
conn, _ := grpc.NewClient(target,
    grpc.WithTransportCredentials(creds),
    grpc.WithKeepaliveParams(keepalive.ClientParameters{
        Time:                20 * time.Second, // 无活动 20s 后发 PING 探测
        Timeout:             5 * time.Second,  // PING 后 5s 仍无响应 → 连接判死
        PermitWithoutStream: true,             // 无活跃 RPC 也发 ping(长连接场景要开)
    }),
)

⚠️Time有隐形下限和博弈权威注释(keepalive.go:36-47):

  • 客户端最小 10s(设置再小会被强制提到 10s);
  • 服务端默认只允许客户端每 5 分钟 ping 一次 (EnforcementPolicy.MinTime 默认 5min)------客户端 ping 太频繁,服务端直接发 GOAWAY 断连!
  • 若被服务端踢掉,客户端会自动把 Time 翻倍重试。

结论:跟服务端运维约好 keepalive 策略再调参,否则"客户端频繁重连"的诡异现象就是这么来的。

服务端参数(keepalive/keepalive.go:62-99)

Go 复制代码
s := grpc.NewServer(
    grpc.KeepaliveParams(keepalive.ServerParameters{
        MaxConnectionIdle:     5 * time.Minute,  // 空闲超过 5min → GOAWAY 让客户端重连
        MaxConnectionAge:      2 * time.Hour,    // 连接最长活 2h(±10% 抖动,防止重连风暴)
        MaxConnectionAgeGrace: 5 * time.Minute,  // 到龄后的宽限期
        Time:                  time.Hour,        // 服务端侧探测
        Timeout:               20 * time.Second,
    }),
    grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
        MinTime:             10 * time.Second,   // 客户端 ping 间隔下限,违反则断连
        PermitWithoutStream: true,               // 允许无流时 ping
    }),
)

MaxConnectionAge是滚动重启的好朋友:连接不会永久存活,配合 LB 摘流量,能做到"老连接慢慢退役、新连接进新实例"。

三、连接状态机(面瘫排障的地图)

Go 复制代码
                    NewClient() 创建
                         │
                         ▼
   ┌──────┐  首次 RPC / Connect()  ┌────────────┐   连接成功   ┌───────┐
   │ IDLE │ ─────────────────────► │ CONNECTING │ ───────────► │ READY │
   └──────┘                        └────────────┘              └───────┘
      ▲  ▲                              │ 连接失败                 │ 错误
      │  │                              ▼                         ▼
      │  │                       (带退避重试)               ┌──────────────────┐
      │  │                              │                   │ TRANSIENT_FAILURE │
      │  └──────────────────────────────┼───────────────────┤ (有错但会恢复,持续重试)│
      │      重新解析成功/Idle管理器触发   └───────────────────└──────────────────┘
      │                                                        │
      └────────── 长时间无 RPC,自动进入 Idle 省资源 ◄─────────────┘
                         
   任意状态 ────── conn.Close() ──────► SHUTDOWN(终态)

ClientConn 的状态定义在 connectivity/connectivity.go:51-62,5 个状态:

状态 API(clientconn.go:711-745):

Go 复制代码
conn.GetState()                                        // 当前状态
conn.WaitForStateChange(ctx, currentState)             // 阻塞等状态变化
conn.Connect()                                         // 主动退出 Idle

生产排障速查

|------------------------------------------|---------------------------------------|----------------------------------------|
| 现象 | 真相 | 处置 |
| 调用报 Unavailable ... connection refused | TRANSIENT_FAILURE | 看目标地址/DNS/服务是否存活 |
| WaitForReady 与否 | 默认不等待:非 READY 时调用立即失败 | grpc.WaitForReady(true) 让调用排队等 READY |
| 连接频繁重建 | keepalive 被服务端踢 / MaxConnectionAge 到点 | 对齐 keepalive 策略 |
| 长时间空闲后首调慢 | 通道进了 IDLE,触发重连 | 预热:定时发健康检查 RPC |
| 所有请求超时雪崩 | 某下游慢 + 无 deadline 传播 | 全链路 deadline(本篇第一节) |

四、重试与退避(配好超时的另一半)

  • gRPC 支持方法级透明重试 (对 Unavailable 且幂等的调用),通过 service config (服务端下发的 JSON 配置)启用,retryPolicy 字段;
  • 客户端连接失败的重连 自带指数退避(backoff 包),无需手写重连循环------不要在业务层再包一层 for { dial },通道本身就会自愈;
  • 示例参考 examples/features/retry、examples/features/wait_for_ready。

错误处理Metadata与生产实践

一、错误处理:status + codes

心法:gRPC 的 error 是结构化的,不是字符串

任何一端拿到的错误都必须能回答三个问题:错误码是多少?消息是什么?有没有附加详情? 这三样打包成 status.Status:

Go 复制代码
// 服务端返回错误
return nil, status.Error(codes.NotFound, "user 123 not found")

// 带多个 detail(例如校验错误批量返回,参考 examples/features/error_details)
st := status.New(codes.InvalidArgument, "invalid request")
st, _ = st.WithDetails(&errdetails.BadRequest{
    FieldViolations: []*errdetails.BadRequest_FieldViolation{
        {Field: "age", Description: "must be positive"},
    },
})
return nil, st.Err()
Go 复制代码
// 客户端判错误
resp, err := c.GetUser(ctx, req)
if err != nil {
    switch status.Code(err) {          // 唯一正确的判错方式
    case codes.NotFound:
        // ...兜底逻辑
    case codes.Unavailable:
        // ...重试(注意幂等)
    default:
        log.Printf("rpc failed: %v", err)   // %v 会打印 "rpc error: code = ... desc = ..."
    }
}

常用错误码速查(codes 包)

|----------------------------------------|--------------------------------|
| 码 | 场景 |
| OK(0) | 成功 |
| InvalidArgument | 参数校验失败(≈ HTTP 400) |
| NotFound | 资源不存在(≈ 404) |
| AlreadyExists | 重复创建(≈ 409) |
| PermissionDenied / Unauthenticated | 有身份但没权限 / 没有身份(403 / 401) |
| ResourceExhausted | 配额、限流(429) |
| FailedPrecondition | 状态不对(如未初始化) |
| Unavailable | 连不上/暂时不可用------可重试的主要信号 |
| DeadlineExceeded / Canceled | 超时 / 取消(05 篇) |
| Internal | 服务端内部错误------别把内部错误细节泄给客户端 |

⚠️ 两个纪律:

  1. 业务 error 不要直接 return nil, err 透传------非 status 错误会被包成 codes.Unknown,语义丢失;
  1. 拦截器返回的错误同样走这条通道(04 篇)。

二、Metadata:HTTP 头的 gRPC 版

gRPC 请求头/响应头是普通的 HTTP/2 header(key: value 列表),在 Go 里用 metadata.MD(本质 map[string][]string)操作:

Go 复制代码
// 客户端:发
md := metadata.Pairs(
    "authorization", "bearer xxx",
    "trace-id", "abc123",
)
ctx := metadata.NewOutgoingContext(context.Background(), md)
resp, err := c.SayHello(ctx, req)

// 服务端:收
md, ok := metadata.FromIncomingContext(ctx)
if ok {
    tokens := md.Get("authorization")   // Get 返回 []string
    if len(tokens) == 0 { /* 401 */ }
}

// 服务端:响应头(要在发第一条消息之前调用)
if err := grpc.SetHeader(ctx, metadata.Pairs("x-server-version", "1.2.3")); err != nil { ... }

// 客户端:收响应头(流式常用于拿 header 后再决定处理)
header, err := stream.Header()   // 阻塞直到头到达

规则:

  • key 自动转小写,合法字符有限(不是任意字符串);
  • 大 key 以 -bin结尾表示二进制 metadata(如携带证书、压缩数据);
  • grpc- 前缀是保留的,业务别用;
  • 配合 04 篇的鉴权拦截器,metadata 就是"横切信息"的标准载体。
  • 完整示例:examples/features/metadata。

三、生产实践清单

1. TLS 必配(不要在生产用 insecure)

Go 复制代码
// 服务端
creds, _ := credentials.NewServerTLSFromFile("server.crt", "server.key")
s := grpc.NewServer(grpc.Creds(creds))

// 客户端
creds, _ := credentials.NewClientTLSFromFile("ca.crt", "server.example.com")
conn, _ := grpc.NewClient(addr, grpc.WithTransportCredentials(creds))

// 进阶:mTLS(双向认证)、自定义验证逻辑 → examples/features/encryption、advancedtls

2. 健康检查(k8s 探针 / LB 摘流量的标准)

Go 复制代码
import "google.golang.org/grpc/health"
import "google.golang.org/grpc/health/grpc_health_v1"

s := grpc.NewServer(...)
grpc_health_v1.RegisterHealthServer(s, health.NewServer())
// k8s 的 liveness/readiness probe 用 grpc-probe 或 grpcurl 打
// grpc.health.v1.Health/Check 即可

LB/istio 靠这个协议判断实例是否该接流量。

3. 反射服务(让 grpcurl 能直接调你,调试神器)

Go 复制代码
import "google.golang.org/grpc/reflection"

s := grpc.NewServer(...)
reflection.Register(s)      // 注册后:grpcurl -plaintext localhost:50051 list

没有它,grpcurl 必须手工提供 proto 文件;有了它,联调时秒查所有接口。示例:examples/features/reflection。

4. 优雅退出(发版不掉请求)

Go 复制代码
go func() {
    ch := make(chan os.Signal, 1)
    signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
    <-ch
    s.GracefulStop()   // 停止收新请求,等存量 RPC 完成(有超时兜底)
                       // 对比 s.Stop():立即断,存量请求全挂 → 只用于致命错误
}()

// grpcurl 验证:examples/features/gracefulstop

5. 消息大小与资源限制

默认单条消息上限 4MB(收/发双向)。大对象要么分块(客户端流),要么调限:

Go 复制代码
s := grpc.NewServer(grpc.MaxRecvMsgSize(16<<20), grpc.MaxSendMsgSize(16<<20))
conn, _ := grpc.NewClient(addr, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(16<<20)))

. 一条 Conn 用到底

ClientConn 是长连接通道 + 内置负载均衡 + 自愈的重对象:

  • 进程内共享一个(按目标地址),不要每次请求 NewClient;
  • 它线程安全,HTTP/2 多路复用:一个 TCP 连接上跑成百上千并发 RPC(11 篇);
  • defer conn.Close() 只在进程退出时执行。

7. 观测性三选一

|-----------------|-------------------------|------------------------------------------------|
| 手段 | 用途 | 位置 |
| stats.Handler | 每条 RPC 的始末事件(耗时、流控、连接级) | stats 包,examples/features/stats_monitoring |
| channelz | 运行时连接/子通道/socket 树形调试信息 | examples/features/debugging |
| OpenTelemetry | 标准化 trace/metrics 接入 | examples/features/opentelemetry |

相关推荐
小小龙学IT3 小时前
Go 泛型(Generics)深度解析:从类型参数到生产实践
开发语言·数据库·golang
不会写DN3 小时前
Go日志库工程选型与逃逸分析评测报告
java·服务器·golang
灯澜忆梦4 小时前
【RabbitMQ #3】 | Go 客户端 + SpringAMQP
分布式·golang·rabbitmq
JWASX6 小时前
Java 转 go 学习 - 基本语法
学习·golang
microrain9 小时前
首包即身份:SagooIoT 网络组件的注册包、粘包与透传设计
物联网·golang·开源·sagooiot
GoFly开发者10 小时前
纯 Go 桌面 AI 智能体实战:Wails3 + Eino ADK 构建 Agent 对话应用(流式输出 / 工具审批 / RAG / 打包全记录)
golang·ai agent·eino·wails3·agent桌面开发
小小龙学IT1 天前
三菱 PLC MC 协议(SLMP / QnA 兼容 3E 帧)深度解析:从帧结构到 C++/Go 双语言采集实战
c语言·c++·golang
念何架构之路1 天前
zap日志SugaredLogger 剖析
云原生·golang
FfHUCisI1 天前
sync.Once 与 sync.Cond 源码与并发控制陷阱
服务器·开发语言·后端·golang