【go-zero】gRPC服务

  • ~27.50K 字
  1. 1. 客户端
    1. 1.1. 配置
      1. 1.1.1. RpcClientConf
      2. 1.1.2. EtcdConf
      3. 1.1.3. ServerMiddlewaresConf
    2. 1.2. 客户端实现
      1. 1.2.1. 直连模式
      2. 1.2.2. 服务发现模式
      3. 1.2.3. newClient的方法
      4. 1.2.4. 发起调用
      5. 1.2.5. 配置文件
      6. 1.2.6. 错误处理
      7. 1.2.7. 熔断器
      8. 1.2.8. 拦截器
    3. 1.3. 客户端连接
      1. 1.3.1. 直连
      2. 1.3.2. etcd 服务发现
      3. 1.3.3. 原生支持
  2. 2. 服务端
    1. 2.1. 开发
      1. 2.1.1. 定义proto
      2. 2.1.2. 生成代码
      3. 2.1.3. 实现业务逻辑
      4. 2.1.4. 配置文件
      5. 2.1.5. 服务启动流程
    2. 2.2. 配置
      1. 2.2.1. RpcServerConf
      2. 2.2.2. EtcdConf
      3. 2.2.3. RedisKeyConf
      4. 2.2.4. ServerMiddlewaresConf
    3. 2.3. 示例
      1. 2.3.1. 初始化
      2. 2.3.2. 连接模式
      3. 2.3.3. stub实现
      4. 2.3.4. 编写业务代码
      5. 2.3.5. 开启gRPC调试开关
      6. 2.3.6. 中间件的使用
      7. 2.3.7. 调用顺序
      8. 2.3.8. 传递metadata
  3. 3. 拦截器
    1. 3.1. 服务端
      1. 3.1.1. 一元拦截器
      2. 3.1.2. 流式拦截器
      3. 3.1.3. 注册方式
    2. 3.2. 客户端
      1. 3.2.1. 一元拦截器
      2. 3.2.2. 流式拦截器
      3. 3.2.3. 注册方式
    3. 3.3. 实例
      1. 3.3.1. Token注入
      2. 3.3.2. 跨服务透传元数据拦截器
      3. 3.3.3. 日志拦截器
    4. 3.4. 内置拦截器

客户端

配置

RpcClientConf

名称 类型 含义 注意 默认值 是否必选
Etcd EtcdConf 服务发现配置,当需要使用 etcd 做服务发现时配置 当服务端已经注册etcd,需要让客户端动态获取服务地址时配置 无 否
Endpoints string 类型数组 RPC Server地址列表,用于直连(不用服务发现),当需要直连 rpc server 集群时配置 直接指定rpc服务器地址列表,不适用服务发现,当配置etcd时会被忽略 无 否
Target string 域名解析地址 gRPC原生的命名解析机制,格式为scheme://authority/endpoint_name 无 否
App string rpc 认证的 app 名称,仅当 rpc server 开启认证时配置 当鉴权开启时,客户端必须提供合法app,会自动插入每次请求的metadata 无 否
Token string rpc 认证的 token,仅当 rpc server 开启认证时配置 当鉴权开启时,客户端必须提供合法token,会自动插入每次请求的metadata 无 否
NonBlock bool 是否阻塞模式,当值为 true 时,不会阻塞 rpc 链接 false时Dial会阻塞到建立成功或超时,true时即使RPC服务不可达,客户端也不会卡死,但是前几次请求可能会失败 false 否
Timeout int64 超时时间 客户端发起 RPC 调用的最大等待时间,会覆盖gRPC上下文的超时 2000ms 否
KeepaliveTime Time.Duration 保活时间 gRPC 客户端向服务端发送 PING 帧的间隔时间,用于保持长连接不被中间代理或防火墙断开 20s 否
Middlewares ClientMiddlewaresConf 是否启用中间件

若需要使用etcd,在yaml文件中注册如下:

1
2
3
4
Etcd:
Hosts:
- 127.0.0.1:2379
Key: user.rpc
  • hosts:etcd集群地址
  • key:服务注册的key,通常为服务名

EtcdConf

当使用etcd做服务发现时配置

名称 类型 含义 默认值 是否必选
Hosts string 类型数组 etcd 集群地址 无 是
Key string 服务发现 key 无 是
ID int64 etcd 租户 id 无 否
User string etcd 用户名 无 否
Pass string etcd 密码 无 否
CertFile string etcd 证书文件 CertFile 否
CertKeyFile string etcd 证书私钥文件 无 否
CACertFile string etcd 证书根文件 CertFile 否
InsecureSkipVerify bool etcd 是否跳过证书验证 无 否

ServerMiddlewaresConf

中间件配置,当需要控制中间件时配置,控制需要开启哪些中间件

名称 类型 含义 默认值 是否必选
Trace bool 是否开启链路追踪 true 否
Recover bool 是否开启异常恢复 true 否
Stat bool 是否开启统计 true 否
Prometheus bool 是否开启 prometheus true 否
Breaker bool 是否开启熔断 true 否

客户端实现

gRPC客户端封装在zrpc.Client 中,自动集成了服务发现、负载均衡、熔断器和链路追踪

直连模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import (
"github.com/zeromicro/go-zero/zrpc"
"myservice/greeter" // 生成的 proto 包
)

func NewServiceContext(c config.Config) *ServiceContext {
conn, err := zrpc.NewClient(zrpc.RpcClientConf{
Endpoints: []string{"localhost:8080"},
})
if err != nil {
log.Fatal(err)
}
return &ServiceContext{
Config: c,
GreeterClient: greeter.NewGreeterClient(conn.Conn()),
}
}

直连模式会显式指定服务地址,不走注册中心,客户端会和endpoints列表中的地址建立gRPC连接

缺点为地址写死,迁移后需要手动更新代码或重新部署

适用于本地开发与单元测试和服务器地址固定不变的场景

服务发现模式

1
2
3
4
5
6
7
8
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
GreeterClient: greeter.NewGreeterClient(
zrpc.MustNewClient(c.GreeterRpc).Conn(),
),
}
}

客户端通过服务注册中心动态获取可用服务节点,具体ip集中在app.yaml中

运行流程:

  • 客户端启动时连接etcd
  • 根据key从etcd拉取一组节点(服务端启动时注册的)
  • 内置的负载均衡器会从这组地址中选择以恶搞发起调用
  • etcd中的节点发生变更时,客户端自动更新本地地址列表

优点在于适合动态扩缩容,配合Breaker中间件,可以自动摘除故障节点,与整个go-zero生态结合紧密

适用于生产环境

newClient的方法

  • NewClient会返回client和err,需要手动处理错误,启动时连接失败不会panic
  • MustNewClient在内部调用client,如果出错则直接panic

大部分项目使用第二个即可,需要特殊处理初始化错误时,才使用第一个

发起调用

在gozero中HTTP服务调用下游RPC服务的写法:

1
2
3
4
5
6
7
8
9
func (l *HelloLogic) Hello(req *types.HelloReq) (*types.HelloResp, error) {
resp, err := l.svcCtx.GreeterClient.SayHello(l.ctx, &greeter.SayHelloReq{
Name: req.Name,
})
if err != nil {
return nil, err
}
return &types.HelloResp{Message: resp.Message}, nil
}

在逻辑层结构体,包含两个重要成员:

  • svcCtx *ServiceContext包含所有依赖
  • ctx context.Context由框架传入,携带了本次HTTP请求的全部上下文
    • 当 go-zero 的 HTTP 网关收到请求时,会从请求头中提取 traceparent、tracestate 等 W3C TraceContext 字段,生成带有 SpanContext 的 context,存入 l.ctx
    • 当调用 gRPC 客户端时,go-zero 的 gRPC 拦截器会自动将 l.ctx 中的 trace 信息注入到 gRPC 的 metadata 中,生成 traceparent header
    • 下游 RPC 服务收到请求后,若同样使用了 go-zero 并开启了 trace 中间件,会自动解析该 header,并创建子 Span,将其父 Span 设为当前 HTTP Span

注意:

  • l.ctx除了trace,还会携带deadline(由http超时或上层调用设置),gRPC会调用并自动遵循,超时返回context.DeadlineExceeded

  • 若需要在RPC传递业务自定义元数据,可以使用metadata.NewOutgoingContext(l.ctx) 将 kv 放入 context,gRPC 拦截器会自动传递。

配置文件

RPC客户端在对应etc/app.yaml中配置,参数如下:

1
2
3
4
5
6
7
8
9
10
11
12
GreeterRpc:
# 方案一:etcd 服务发现
Etcd:
Hosts: [127.0.0.1:2379]
Key: greeter.rpc

# 方案二:静态地址
Endpoints:
- 127.0.0.1:8080

Timeout: 2000 # 客户端请求超时(毫秒),0 表示不限
KeepaliveTime: 20000 # gRPC keepalive ping 间隔(毫秒)

两种方案是互斥的,框架会根据是否填入Etcd Key来选择使用哪种模式,etcd的优先级更高,其中:

  • Timeout:客户端认为一个RPC调用失败前的最大等待时间,防止线程被长时间挂住
  • KeepaliveTime:gRPC长连接每隔多久发送一次ping,防止负载均衡或防火墙因空闲断开连接

错误处理

gRPC的错误是一个结构化状态,包含一个标准的status.Code和描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import "google.golang.org/grpc/status"

resp, err := l.svcCtx.GreeterClient.SayHello(l.ctx, req)
if err != nil {
st, _ := status.FromError(err)
switch st.Code() {
case codes.NotFound:
return nil, errorx.NewCodeError(404, "用户不存在")
case codes.DeadlineExceeded:
return nil, errorx.NewCodeError(504, "上游超时")
default:
return nil, err
}
}
  • status.FromError(err) 从 gRPC 错误中提取出 *status.Status 对象
  • st.Code() 得到对应的 gRPC 状态码
  • 可以根据这些状态码使用NewCodeError将错误转换成自己需要的格式,然后再返回给 HTTP 客户端。
  • 内置的errorx会和httpx配合,将带code的错误直接映射为HTTP响应状态码

熔断器

gozero的RPC客户端默认启用了熔断器,基于自适应熔断,逻辑是:

在滑动时间窗口内(默认为10秒),如果下游请求的错误率超过50%,熔断器就会打开,拒绝后续请求。一段时间后会进入半开状态,试探性放行少量请求,如果成功率回升则关闭

拦截器

有些场景需要向每个gRPC请求的metadata中注入业务信息,如x-request-id,认证token等,gozero可以使用WithUnaryClientInterceptor添加自定义拦截器

1
2
3
4
5
6
7
8
9
10
11
12
conn, err := zrpc.NewClient(c.GreeterRpc,
zrpc.WithUnaryClientInterceptor(func(
ctx context.Context, method string,
req, reply any, cc *grpc.ClientConn,
invoker grpc.UnaryInvoker, opts ...grpc.CallOption,
) error {
ctx = metadata.AppendToOutgoingContext(ctx,
"x-request-id", requestIDFromCtx(ctx),
)
return invoker(ctx, method, req, reply, cc, opts...)
}),
)
  • metadata.AppendToOutgoingContext 向 gRPC 的 outgoing metadata 中添加 key‑value 对
  • invoker(ctx, method, req, reply, cc, opts...) 继续后续的拦截器链并最终发起调用

常用场景包括:

  • x-request-id :全链路追踪 ID,便于日志关联
  • Authorization / Token:服务间认证
  • x-user-id:传递当前操作用户身份

客户端连接

通过goctl生成一个rpc server服务:

1
2
3
4
# 生成一个 greet 服务
$ goctl rpc new greet
# 新建一个 main.go 文件来创建一个 greet 服务的客户端
$ touch main.go

直连

直连下有两种模式

地址解析模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
clientConf:=zrpc.RpcClientConf{}
conf.FillDefault(&clientConf)// 填充默认值,比如 trace 透传等,参考服务配置说明
clientConf.Target = "dns:///127.0.0.1:8080"
conn := zrpc.MustNewClient(clientConf)
client := greet.NewGreetClient(conn.Conn())
resp, err := client.Ping(context.Background(), &greet.Request{})
if err != nil {
log.Println(err)
return
}
log.Println(resp)
}

其中Target是gRPC内置的URI风格解析器标识,语法为schema://authority/endpoint

如dns:///127.0.0.1:8080:告诉gRPC的resolver通过DNS查询解析这个地址,当指定的是IP地址时,DNS解析直接返回该IP,类似直连;当指定的是域名时,才会做真正的DNS解析

  • 若DNS返回多个IP,gRPC会在它们之间做客户端负载均衡
  • 适合K8s Headless Service场景配合DNS服务发现使用
  • 其中target和endpoints互斥,同时设置时,前者会被忽略

多节点直连模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
clientConf:=zrpc.RpcClientConf{}
conf.FillDefault(&clientConf)// 填充默认值,比如 trace 透传等,参考服务配置说明
clientConf.Endpoints = []string{"127.0.0.1:8080","127.0.0.2:8080"}// 直连集群时,只需要给 Endpoints 配置 rpc server的地址即可
conn := zrpc.MustNewClient(clientConf)
client := greet.NewGreetClient(conn.Conn())
resp, err := client.Ping(context.Background(), &greet.Request{})
if err != nil {
log.Println(err)
return
}
log.Println(resp)
}

gozero会遍历给出的endpoints列表,将每个地址构造为gRPC的sunConn,客户端内置的负载均衡策略(P2C,即随机选择两个,选择其中最优的一个)会在这些节点间发送请求,若某个节点故障,gRPC层面的连接重试和gozero的熔断器会将其临时剔除

这样处理配置直观,且不需要DNS解析或注册中心,多个节点之间自动负载均衡,配合上熔断器可以提高可用性,但是地址列表是静态的,需要手动更新配置并重启

etcd 服务发现

go-zero下,可以使用etcd做服务发现,初始化如下面的例子:从 etcd 中找到名为 greet.rpc 的服务节点,然后建立 gRPC 连接并调用其 Ping 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {
clientConf:=zrpc.RpcClientConf{}
conf.FillDefault(&clientConf)// 填充默认值,比如 trace 透传等,参考服务配置说明
clientConf.Etcd = discov.EtcdConf{// 通过 etcd 服务发现时,只需要给 Etcd 配置即可
Hosts: []string{"127.0.0.1:2379"},
Key: "greet.rpc",
User: "",// 当 etcd 开启 acl 时才填写,这里为了展示所以没有删除,实际使用如果没有开启 acl 可忽略
Pass: "",// 当 etcd 开启 acl 时才填写,这里为了展示所以没有删除,实际使用如果没有开启 acl 可忽略
CertFile: "",// 当 etcd 开启 acl 时才填写,这里为了展示所以没有删除,实际使用如果没有开启 acl 可忽略
CertKeyFile: "",// 当 etcd 开启 acl 时才填写,这里为了展示所以没有删除,实际使用如果没有开启 acl 可忽略
CACertFile: "",// 当 etcd 开启 acl 时才填写,这里为了展示所以没有删除,实际使用如果没有开启 acl 可忽略
InsecureSkipVerify: false,// 当 etcd 开启 acl 时才填写,这里为了展示所以没有删除,实际使用如果没有开启 acl 可忽略
}
conn := zrpc.MustNewClient(clientConf)
client := greet.NewGreetClient(conn.Conn())
resp, err := client.Ping(context.Background(), &greet.Request{})
if err != nil {
log.Println(err)
return
}
log.Println(resp)
}
  • 首先通过conf和fillDefault将RPC客户端配置结构体填入默认值,防止服务异常
  • clientConf.Etcd用于配置etcd:
    • Hosts:etcd的集群地址
    • Key:服务在etcd中的注册key(服务名)
    • 其余字段当etcd开启ACL/TLS时才需要填写
  • 接下来使用MustNewClient创建一个gRPC客户端,此时内部执行的动作:
    • 根据etcd配置启动服务发现(初始化discov.DiscovClient)
    • 从etcd监听到的节点列表中,选择一个可用地址,建立一条gRPC ClientConn
    • 把该ClientConn包装进zrpc.Client并返回
  • conn.Conn() 返回真正的 *grpc.ClientConn
  • greet.NewGreetClient 是根据protobuf文件编译出的 gRPC client 构造函数,负责将结构体方法映射成 RPC 调用
  • client.Ping发送请求并等待响应,超时使用gRPC配置中的timeout,gozero自动在拦截层添加context超时

原生支持

可以不适用go-zero的zrpc的client初始化,而直接使用grpc.ClientConn原生支持

原生grpc去掉了服务发现,负载均衡,拦截器等能力,适合连接固定地址的场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
conn,err:=grpc.Dial("127.0.0.1:8080",grpc.WithBlock(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err!=nil{
log.Println(err)
return
}
client := greet.NewGreetClient(conn)
resp, err := client.Ping(context.Background(), &greet.Request{})
if err != nil {
log.Println(err)
return
}
log.Println(resp)
}
  • grpc.Dial用于建立连接:
    • 第一个参数是目标服务地址,直连而不走服务中心
    • grpc.WithBlock()使Dial阻塞等待连接成功,如果不加这个选项,Dial会立即返回,底层在后台异步建立
    • grpc.WithTransportCredentials(insecure.NewCredentials()):指定传输证书,这里使用insecure.NewCredentials()无认证,生产环境下可以使用credentials.NewTLS(...)
    • 返回的 conn 是 *grpc.ClientConn,它负责实际的网络连接管理与复用
    • 注意:使用WithBlock() 且没有设置超时时,如果目标服务不存在或网络不通,Dial 可能一直阻塞而不会返回,建议配合带超时的ctx:ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  • client := greet.NewGreetClient(conn)创建业务客户端,该方法是通过protobuf文件生成的client桩代码,接受一个grpc.ClientConn接口,返回一个可以调用具体方法的客户端对象,只负责将方法调用封装成 gRPC 请求并发送
  • client.Ping发送一个ping请求等待响应,这里也是没有超时的,最好自己传入一个带超时的ctx

使用场景:

  • 本地功能调试
  • 服务发现由自己实现

服务端

go-zero 在标准 gRPC 服务端之上封装了弹性治理、服务注册和链路追踪等能力

大体流程:定义 Protobuf → 用 goctl 生成服务骨架 → 填充业务逻辑 → 配置 etcd 注册 → 启动 RPC 服务

开发

定义proto

1
2
3
4
5
6
7
8
9
10
syntax = "proto3";
package greeter;
option go_package = "./greeter";

message SayHelloReq { string name = 1; }
message SayHelloResp { string message = 1; }

service Greeter {
rpc SayHello(SayHelloReq) returns(SayHelloResp);
}

通过protobuf文件定义接口逻辑,生成代码时会以此为依据生成 Handler 接口、消息类型、client stub 和 server 注册逻辑

生成代码

通过goctl生成代码:

1
goctl rpc protoc greeter.proto --go_out=. --go-grpc_out=. --zrpc_out=.

目录结构:

1
2
3
4
5
6
7
8
9
greeter/
├── etc/greeter.yaml
├── internal/
│ ├── config/config.go
│ ├── logic/sayhellologic.go
│ ├── server/greeterserver.go
│ └── svc/servicecontext.go
├── greeter/ # protobuf 生成文件
└── greeter.go
  • greeter/:存放 *.pb.go 和 *_grpc.pb.go,这是由 protoc 生成的纯 gRPC 代码,不渗透任何 go-zero 关联
  • internal/config:定义从 yaml 读取的配置结构体。例如 Name、ListenOn、Etcd
  • internal/svc:保存 ServiceContext 结构体,相当于一个“依赖容器”,把一个服务需要用到的数据库、缓存等资源组装好。
  • internal/logic:业务代码唯一需要写的地方。包含每个 RPC 方法的逻辑
  • internal/server:由 goctl 生成,负责将 gRPC 请求转交给 logic 层处理,本质是适配器。
  • greeter.go:启动入口,会组装整个服务并启动监听

实现业务逻辑

下面是一个logic文件逻辑实现示例:

1
2
3
func (l *SayHelloLogic) SayHello(in *greeter.SayHelloReq) (*greeter.SayHelloResp, error) {
return &greeter.SayHelloResp{Message: "Hello " + in.Name}, nil
}

只需要关注逻辑层并返回resp即可,不需要关注server层

配置文件

配置文件处于etc/*.yaml

1
2
3
4
5
Name: greeter.rpc
ListenOn: 0.0.0.0:8080
Etcd:
Hosts: [127.0.0.1:2379]
Key: greeter.rpc
  • Name指服务名,客户端在etcd中寻找key时使用的是这里的名字
  • ListenOn指监听地址
  • Etcd是服务注册,启动时 go-zero 会将本服务地址(通常是本机外网 IP + :8080)注册到 etcd 的 /services/greeter.rpc(或等价路径)下,这样etcd才能通过key找到该服务

服务启动流程

启动入口greeter.go入口的流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
var c config.Config
conf.MustLoad("etc/greeter.yaml", &c) // 加载配置

server := zrpc.MustNewServer(
c.RpcServerConf, // Name, ListenOn, Etcd 等
func(server *grpc.Server) {
greeter.RegisterGreeterServer(server, server.NewGreeterServer(svcCtx))
},
)
server.Start()
}
  • 首先加载配置,构建serverContext

  • 注册GreeterServer的实现:greeter.RegisterGreeterServer(server, server.NewGreeterServer(svcCtx))

  • 然后自动向etcd注册greeter.rpc服务,并定期续约,监听注册的地址提供请求

  • 只要客户端配置的etcd的key和服务端的etcd的key一致,客户端就可以从etcd发现该服务的多个实例,进而做负载均衡,动态更新连接池等

配置

zRPC 服务配置主要用于控制 rpc 的监听地址、服务注册发现、链路追踪、日志、中间件等

RpcServerConf

这里是服务总配置的入口

1
2
3
4
5
6
7
8
9
10
11
12
type RpcServerConf struct {
service.ServiceConf // 通用服务配置
ListenOn string
Etcd discov.EtcdConf
Auth bool
Redis redis.RedisKeyConf
StrictControl bool
Timeout int64 // default=2000
CpuThreshold int64 // default=900, range=[0:1000]
Health bool // default=true
Middlewares ServerMiddlewaresConf
}
  • service.ServiceConf是通用配置,决定了服务全局行为
  • ListenOn是监听地址,若只写端口号表示监听所有网卡
  • etcd是服务注册与发现配置,不需要向etcd注册时可以为空,此时客户端通过直连地址访问服务
  • auth表示开启rpc调用认证,客户端必须要在gRPC的metadata中携带token,服务端才会接受请求
  • Redis保存了合法token的映射,服务端会去Redis校验
  • StrictControl指定校验失败时的策略,true表示失败即拒绝,false记录waring但不拒绝,适合调试场景
  • Timeout指服务端处理单个请求的超时时间
  • CpuThreshold是自适应降载阈值,默认900,表示当CPU利用率达到90%时开始拒绝部分服务
  • Health表示是否注册gRPC健康检查服务,默认开启,这样k8s或gRPC health client可以探测服务存活状态:grpc_health_v1.NewHealthClient(conn).Check(...)
  • Middlewares控制服务器内置中间件开关,生产环境保持默认即可

EtcdConf

这里是etcd的详细配置

1
2
3
4
5
6
7
8
9
10
11
type EtcdConf struct {
Hosts []string
Key string
ID int64
User string
Pass string
CertFile string
CertKeyFile string
CACertFile string
InsecureSkipVerify bool
}

  • Hosts:etcd 集群节点地址
  • Key:服务唯一标识,建议与服务 Name 保持一致(如 greeter.rpc),客户端也使用这个 Key 去 etcd 中寻找服务
  • ID:可选的实例 ID,如果不填,go-zero 会使用本机 IP + 端口自动生成唯一 ID,用于多实例注册时区分
  • 其余字段:当 etcd 开启 TLS/ACL 时需要提供
  • 注意:gozero的etcd注册路径一般是/services/{Key}/{ID} 的形式,key相同但id不同会被视为同一服务的不同副本

RedisKeyConf

用于认证存储,RedisKeyConf是当你需要使用 redis 来管理 rpc 认证时需要配置的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type (
// A RedisConf is a redis config.
RedisConf struct {
Host string
Type string `json:",default=node,options=node|cluster"`
Pass string `json:",optional"`
Tls bool `json:",optional"`
}

// A RedisKeyConf is a redis config with key.
RedisKeyConf struct {
RedisConf
Key string
}
)
  • Key 是存储 token 的 redis key 前缀,例如 rpcAuth:
  • 服务端会根据客户端传来的 token,去 redis 的 ${Key}${token} 中查找是否存在且未过期

ServerMiddlewaresConf

中间件配置,控制常用中间件的开启和关闭

1
2
3
4
5
6
7
ServerMiddlewaresConf struct {
Trace bool `json:",default=true"`
Recover bool `json:",default=true"`
Stat bool `json:",default=true"`
Prometheus bool `json:",default=true"`
Breaker bool `json:",default=true"`
}

示例

以下是创建一个gRPC服务端的示例

初始化

首先创建目录并初始化go module,并生成一个proto文件,然后根据proto生成gRPC服务

1
2
3
4
5
6
$ mkdir demo && cd demo
$ go mod init demo

$ goctl rpc -o greet.proto

$ goctl rpc protoc greet.proto --go_out=. --go-grpc_out=. --zrpc_out=.

连接模式

有服务发现和直连模式两种连接模式,通过调整etc/文件夹下的yaml文件即可

etcd服务注册使用etcd作为注册中心,只需要在文件下添加etcd的配置即可,最简配置如下:

1
2
3
4
Etcd:
Hosts:
- 127.0.0.1:2379
Key: greet.rpc

这样,服务启动后,会自动把ip注册到注册中心,其他服务如果使用gozero的zrpc客户端,只需要设置相同的etcd key,就可以自动负载均衡到这台服务

使用etcdctl get --prefix greet.rpc查看注册结果

使用etcdctl get --prefix获取所有可用ip和节点

由于 etcd 注册的 key 都是 greet.rpc,从业务表现层来看,是将一个 key 注册到了 etcd,实则 go-zero 底层是将该 key 拼上了一个 etcd 的租户 id 来存储到 etcd 的,类似于:

1
2
key = /services/greet.rpc/{租户ID}
value = "192.168.72.53:8080"
  • 如果多个实例都注册同一个 greet.rpc,直接使用固定 key 会导致互相覆盖
  • 加入租户 ID(实际上是实例唯一标识)后,每个实例都能独立注册,且服务发现方可以通过 get --prefix greet.rpc 一次取出所有实例

这也是gozero客户端可以实现多实例负载均衡的基础

若使用直连模式,去除etcd配置即可

1
2
Name: greet.rpc
ListenOn: 0.0.0.0:8080

stub实现

通过goctl生成的代码就不用stub方法了,goctl会全部实现

代码存在于/internal/server/greetserver.go下,用于实现 gRPC 框架定义的服务接口greet.GreetServer,并把请求转交给 internal/logic 层的业务代码。

结构体定义:

1
2
3
4
type GreetServer struct {
svcCtx *svc.ServiceContext
greet.UnimplementedGreetServer
}
  • svcCtx保存了服务上下文,包括配置、数据库连接、redis等共享依赖,它是 go-zero 依赖注入的核心:所有 logic 方法都通过它访问全局资源
  • greet.UnimplementedGreetServer是官方gRPC生成器在pb文件中定义的,即使未来proto新增了rpc方法但你的代码没有实现,也不会panic而是返回未实现,这是一种向前兼容

构造函数:

1
2
3
4
5
func NewGreetServer(svcCtx *svc.ServiceContext) *GreetServer {
return &GreetServer{
svcCtx: svcCtx,
}
}

将 ServiceContext 注入到服务端对象中,之后每个 RPC 方法都可以使用这个上下文

核心方法:

1
2
3
4
func (s *GreetServer) Ping(ctx context.Context, in *greet.Request) (*greet.Response, error) {
l := logic.NewPingLogic(ctx, s.svcCtx)
return l.Ping(in)
}

假设是一个Ping方法,gozero的处理流程如下:

1
2
3
4
5
6
7
gRPC 请求
↓
GreetServer.Ping() // internal/server (模板方法)
↓
logic.NewPingLogic() // 创建业务逻辑对象
↓
l.Ping(in) // internal/logic 中你真正填写的实现
  • server 层:只做“路由转发”,代码千篇一律,交给 goctl 生成
  • logic 层:包含全部业务规则,由开发者编写
  • 当 .proto 接口变更,goctl 会自动更新 server 层并生成新的 logic 占位代码,你只需在新占位方法中实现业务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package logic
type PingLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewPingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PingLogic {
return &PingLogic{
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *PingLogic) Ping(in *greet.Request) (*greet.Response, error) {
// 你的业务代码写在这里
return &greet.Response{Pong: "pong"}, nil
}

对于逻辑层:

  • NewPingLogic 接收 context 和 svcCtx
  • Ping() 方法直接从 GreetServer.Ping() 调过来,in 就是解码后的请求参数,返回即为响应体

对于客户端:

目录中的greetclient/greet.go是为调用方生成的客户端代码,为了方便业务方生成的代码,当你不是服务端实现者时,只需要greetclient.NewGreetClient(...) 就能请求这个 RPC 服务

编写业务代码

通过goctl生成代码后,只需要在logic下填入我们的业务代码即可

开启gRPC调试开关

gRPC提供了调试功能,以便于通过grpcurl等工具调试,建议在开发环境和测试环境开启,预生产环境和正式环境建议关闭的,相关方法为reflection.Register(grpcServer)

为什么需要:

gRPC 默认使用protobuf二进制协议,请求和响应的结构对外部工具不可见。普通 HTTP 接口可以用 curl 直接调试,但 gRPC 很难,grpcurl 这类工具就是为了解决这个问题:它向服务端发送 server reflection 请求,服务端返回自己支持的服务列表、方法、消息定义。于是 grpcurl 就能像 RESTful 工具一样,动态构造请求和解析响应,不需要预先拿到 .proto 文件

只需要在程序入口文件中添加如下:

1
2
3
if c.Mode == service.DevMode || c.Mode == service.TestMode {
reflection.Register(grpcServer)
}

且在配置文件内声明模式为dev或test:

1
Mode: dev

Mode是基础配置service.ServiceConf中的一个字段,dev是开发环境,test是测试环境,pro/prod是生产环境

grpcurl调试:

假如你的服务运行在 127.0.0.1:8080,并且 Mode: dev

1
2
3
4
5
6
7
8
9
10
11
12
# 列举所有 service
grpcurl -plaintext 127.0.0.1:8080 list

# 查看某个 service 下的所有方法
grpcurl -plaintext 127.0.0.1:8080 list greet.Greeter

# 获取请求消息结构描述
grpcurl -plaintext 127.0.0.1:8080 describe greet.SayHelloReq

# 调用 Ping 方法
grpcurl -plaintext -d '{"name":"go-zero"}' \
127.0.0.1:8080 greet.Greeter/SayHello

若没有开启reflection,会报错Error: server does not support the reflection API,此时只能通过-proto参数显式执行protobuf文件来调试

中间件的使用

gozero中有大量拦截器,对应grpc两种调用模式:

拦截器类型 作用
UnaryXXXInterceptor 拦截普通一元 RPC 请求(一个请求一个响应)
StreamXXXInterceptor 拦截流式 RPC 请求(客户端/服务端双向流)

RpcServerConf.Middlewares中可以通过配置控制五个内置中间件:

  • Trace:链路追踪
  • Recover:捕获 panic
  • Stat:服务调用统计
  • Prometheus:指标暴露
  • Breaker:熔断

其中这五种可以通过配置开启关闭,默认开启

实际项目中,一般将自定义拦截器定义在独立文件/internal/middleware/exampleinterceptor.go,在main入口中引入,保证文件整洁

注册自定义中间件:

1
2
3
4
5
6
7
8
9
10
s := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) {
greet.RegisterGreetServer(grpcServer, server.NewGreetServer(ctx))

if c.Mode == service.DevMode || c.Mode == service.TestMode {
reflection.Register(grpcServer)
}
})

s.AddUnaryInterceptors(exampleUnaryInterceptor)
s.AddStreamInterceptors(exampleStreamInterceptor)

顺序如下:

  1. MustNewServer 创建服务对象
  2. 在回调中注册业务服务和调试反射
  3. 在服务启动前,通过 AddUnaryInterceptors 和 AddStreamInterceptors 注册自定义中间件
  4. Start() 后生效

定义自定义Unary中间件:

1
2
3
4
func exampleUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
// TODO: fill your logic here
return handler(ctx, req)
}
  • ctx:gRPC请求的上下文,可以读取metadata
  • req:已解码的请求消息对象
  • info:包含当前请求对应的服务方法信息info.FullMethod
  • handler:业务处理函数,调用handler后会继续向后执行直到进入logic

定义自定义Stream中间件:

1
2
3
4
func exampleStreamInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
// TODO: fill your logic here
return handler(srv, ss)
}
  • srv:当前服务实例,通常用 server 中的 handler 对象
  • ss:服务端流对象,用于收发消息。
  • info:方法信息
  • handler:流式 RPC 的实际处理函数,需要你手动调用,期间可在外层包裹流程

例子:打印请求日志

1
2
3
4
5
6
func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
log.Printf("收到请求: %s, 参数: %v", info.FullMethod, req)
resp, err := handler(ctx, req)
log.Printf("请求完成: %s, 响应: %v, error: %v", info.FullMethod, resp, err)
return resp, err
}

调用顺序

注意,若有多个自定义拦截器,执行模式按照洋葱模型栈式调用:

1
2
3
4
5
6
7
8
9
10
11
请求进入
↓
自定义拦截器1(前置逻辑)
↓
自定义拦截器2(前置逻辑)
↓
内置拦截器链(Trace、Recover等)
↓
业务 Handler(handler)
↓
逐层返回,执行后置逻辑

传递metadata

gozero中服务端的自定义拦截器会默认拿到ctx,所以读取metadata非常方便

1
2
3
4
5
6
7
8
9
10
11
12
func exampleUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
// 读取客户端传过来的 token
tokenList := md["token"]
if len(tokenList) == 0 || tokenList[0] != "secret-token" {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
return handler(ctx, req)
}

然后可以通过s.AddUnaryInterceptors(exampleUnaryInterceptor) 注册到rpc服务端

如果是在客户端调用时携带metadata,只需要在传入的ctx上附加metadata即可

1
2
3
4
5
6
7
// 方式1:推荐,追加
ctx := metadata.AppendToOutgoingContext(context.Background(),
"token", "secret-token",
"x-trace-id", "abc123",
)

resp, err := client.Ping(ctx, &greet.Request{Name: "Alice"})

在logic层发送header/trailer可以直接使用grpc的方法:

1
2
3
4
5
6
7
8
9
10
type PingLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}

func (l *PingLogic) Ping(in *greet.Request) (*greet.Response, error) {
// 这种方式在服务端 handler 中可用
grpc.SetHeader(l.ctx, metadata.Pairs("x-custom-header", "hello"))
// ...
}

拦截器

拦截器是 gRPC 中实现跨请求逻辑的关键机制,等价于 HTTP 中间件。每次 RPC 调用前后都会执行拦截器,可用于鉴权、记录指标或修改请求上下文

服务端

一元拦截器

以登录认证为例:

1
2
3
4
5
6
7
8
9
func authInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {

md, _ := metadata.FromIncomingContext(ctx)
if len(md["token"]) == 0 {
return nil, status.Error(codes.Unauthenticated, "缺少 token")
}
return handler(ctx, req)
}

拦截器从rpc的ctx中取出metadata,检查是否有token字段,若没有则返回认证错误,若有则继续调用handler继续执行业务代码:

  • ctx:上下文,包括传来的metadata等
  • req:解码后的请求消息对象
  • info:当前调用信息
  • handler:拦截器上的下一环,最终指向业务handler

注意这里any是golang新版本中的interface{}别名

流式拦截器

1
2
3
4
5
6
7
8
9
10
11
func loggingStreamInterceptor(srv any, ss grpc.ServerStream,
info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {

logx.Infof("流式调用开始: %s", info.FullMethod)
err := handler(srv, ss)
if err != nil {
logx.Errorf("流式调用错误: %s — %v", info.FullMethod, err)
}
return err
}

流式 handler 不像一元 handler 那样直接调 handler(ctx, req),而是 handler(srv, ss)。业务代码通过 ss.Recv() 和 ss.Send() 来处理流中的多个消息

  • srv:服务端实现对象
  • ss:grpc.ServerStream接口,对应流式调用的服务端流对象
  • info:当前调用信息,包含流式RPC的方法流,是否是客户端流等信息
  • handler:最终执行流处理的函数

注册方式

1
2
3
4
5
6
server := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) {
greeter.RegisterGreeterServer(grpcServer, srv)
})
server.AddUnaryInterceptors(authInterceptor)
server.AddStreamInterceptors(loggingStreamInterceptor)
server.Start()

也可以放到同一个AddUnaryInterceptors中

1
2
3
4
5
server.AddUnaryInterceptors(
authInterceptor,
loggingInterceptor,
recoverInterceptor,
)

执行顺序依据洋葱模型

客户端

有时候需要给所有发出去的RPC请求附加一些公共信息:比如Token认证、请求ID、业务租户ID或客户端来源/版本,客户端拦截器可以让统一在RPC调用前拦截并修改context/metadata,然后继续发送请求

一元拦截器

例如:在每次对外RPC请求中注入Token或传递请求头

1
2
3
4
5
6
7
zrpc.WithUnaryClientInterceptor(func(ctx context.Context, method string,
req, reply any, cc *grpc.ClientConn,
invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {

ctx = metadata.AppendToOutgoingContext(ctx, "token", getToken())
return invoker(ctx, method, req, reply, cc, opts...)
})
  • ctx:当前调用上下文
  • method:将要调用的gRPC方法名
  • req:请求信息
  • reply:响应信息
  • cc:gRPC连接对象
  • invoker:下一环节的调用函数,调用才能发送请求
  • opts:调用选项

流程:

1
2
3
4
5
6
7
8
9
发起 RPC
↓
进入 client interceptor
↓
给 ctx 追加 metadata token
↓
调用 invoker(ctx, ...) → 把修改后的 context 发给远端服务
↓
返回响应或错误

可以理解为:invoker 就是后续真正的 gRPC 调用链路,你只是“劫持”了它的 ctx 参数,替换成带 metadata 的ctx

流式拦截器

1
2
3
4
5
6
7
zrpc.WithStreamClientInterceptor(func(ctx context.Context, desc *grpc.StreamDesc,
cc *grpc.ClientConn, method string, streamer grpc.Streamer,
opts ...grpc.CallOption) (grpc.ClientStream, error) {

ctx = metadata.AppendToOutgoingContext(ctx, "request-id", uuid.New().String())
return streamer(ctx, desc, cc, method, opts...)
})
  • ctx:调用上下文
  • desc:流描述,描述是客户端,服务端还是双向流
  • cc:连接对象
  • method:方法名
  • streamer:真正的流创建函数,返回一个grpc.ClientStream
  • opts:调用选项

一元拦截器是一次请求一次性处理,invoker完成整个调用,而流式流通过streamer创建流,后续还会通过 stream.Send、stream.Recv 等进行多次消息交互,因此:

  • 在拦截器中,你会修改ctx,然后传给streamer,返回ClientStream
  • 后续所有sendmsg,recvmsg都是用这个流,metadata已经在这个流理的context中固定了

因此流式拦截器适合在流建立前统一处理

注册方式

NewClient支持选项式注入:

1
2
3
4
5
6
7
8
conn, err := zrpc.NewClient(c.GreeterRpc,
zrpc.WithUnaryClientInterceptor(func(...) error { ... }),
zrpc.WithStreamClientInterceptor(func(...) (grpc.ClientStream, error) { ... }),
)
if err != nil {
log.Fatal(err)
}
client := greeter.NewGreeterClient(conn)

MustNewClient需要先获取底层连接

1
2
3
client, err := zrpc.MustNewClient(c.GreeterRpc, zrpc.WithUnaryClientInterceptor(...))
conn := client.Conn()
greeterClient := greeter.NewGreeterClient(conn)

实例

Token注入

1
2
3
4
5
6
7
8
9
10
func tokenClientInterceptor(ctx context.Context, method string, req, reply any,
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {

ctx = metadata.AppendToOutgoingContext(ctx, "token", "my-secret-token")
return invoker(ctx, method, req, reply, cc, opts...)
}

conn, err := zrpc.NewClient(c.GreeterRpc,
zrpc.WithUnaryClientInterceptor(tokenClientInterceptor),
)

跨服务透传元数据拦截器

用于将上游metadata传给下游

1
2
3
4
5
6
7
8
9
10
11
12
func forwardMetadataInterceptor(ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {

incomingMd, _ := metadata.FromIncomingContext(ctx)

outgoingCtx := metadata.NewOutgoingContext(ctx, metadata.Join(
incomingMd,
metadata.Pairs("x-request-id", incomingMd.Get("x-request-id")...),
))

return handler(outgoingCtx, req)
}
  • 首先从当前RPC的ctx中获得客户端传来的metadata
  • 使用metadata.Join合并传来的metadata和需要添加的内容
  • 使用NewOutgoingContext把合并后的metadata绑定到context上
  • 将该ctx传入下游的handler,如果下游服务从这个ctx发起RPC服务,metadata就会被带过去

注意,这里Incoming的metadata是别人传给我的,而Outgoing的metadata是我传给别人的

日志拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func loggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {

start := time.Now()

resp, err := handler(ctx, req)

logx.WithContext(ctx).Infow("rpc 调用",
logx.Field("method", info.FullMethod),
logx.Field("duration", time.Since(start).String()),
logx.Field("error", err),
)

return resp, err
}
  • 调用前记录start
  • 执行业务handler
  • 调用后记录方法名,耗时和错误信息
  • 返回值透传,不影响业务

WithContext是go-zero的增强日志专用写法,可以将当前context的链路信息自动注入日志字段,方便采集到分布式链路系统,logx.Field可以创建键值结构

内置拦截器

go-zero 在每个 zrpc.Server 和 zrpc.Client 上自动注册以下拦截器,无需额外配置:

拦截器 功能
RecoverInterceptor 将 panic 转换为 gRPC Internal 错误
PrometheusInterceptor 记录 RPC 耗时与错误率指标
TracingInterceptor 创建 OpenTelemetry span,传播 trace context
BreakerInterceptor 错误率超阈值时打开熔断器
SheddingInterceptor CPU 负载过高时主动降载
TimeoutInterceptor 根据服务端配置执行每次 RPC 的超时限制
赞助喵
非常感谢您的喜欢!
赞助喵