环境搭建和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 个坑
grpc.Dial/grpc.DialContext已过时 ------新代码一律用grpc.NewClient(clientconn.go:157起)。它创建时不联网 ,所以err几乎永远为 nil,连接失败会在第一次 RPC 时才暴露。
insecure凭证不能省 ------没有WithTransportCredentials会直接报错。生产环境换成credentials.NewClientTLSFromFile(...)。
- 忘嵌
UnimplementedGreeterServer→ 编译报错。它不是为了偷懒,是跨语言 RPC 的向前兼容契约。
- protoc 找不到插件 →
$GOPATH/bin不在 PATH。
- 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
四、双向流的三条铁律(生产事故高发)
- 读必须放独立 goroutine ------
Send和Recv可以并发,但同一个流上两个 goroutine 同时Send(或同时Recv)是数据竞争,会直接 panic;
CloseSend()只关闭发送方向 ------之后服务端Recv得到io.EOF,但你仍可以继续Recv收对方的消息;
- 流有生命周期------客户端 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 | 服务端内部错误------别把内部错误细节泄给客户端 |
⚠️ 两个纪律:
- 业务 error 不要直接
return nil, err透传------非 status 错误会被包成codes.Unknown,语义丢失;
- 拦截器返回的错误同样走这条通道(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 |