官网 : https://protobuf.dev/
微软 gRpc 指导文档
proto3 关键字
// 引入其他依赖
import "other.proto";
// 说明协议版本
syntax = "proto3";
// 详见 /google/protobuf/descriptor.proto.
// java 的包名字
// option java_package = "com.example.foo";
// go 包名字
option go_package = "foo";
// message 结构体定义
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 results_per_page = 3;
}
// define an RPC service interface
service SearchService {
// rpc 定义调用方法
rpc Search(SearchRequest) returns (SearchResponse);
}
// enum
enum Corpus {
CORPUS_UNSPECIFIED = 0;
CORPUS_UNIVERSAL = 1;
CORPUS_WEB = 2;
CORPUS_IMAGES = 3;
CORPUS_LOCAL = 4;
CORPUS_NEWS = 5;
CORPUS_PRODUCTS = 6;
CORPUS_VIDEO = 7;
}
// map 定义
map<string, Project> projects = 3;
oneof:定义互斥字段,只能选择其中一个字段。
repeated:定义重复字段,可以有多个值。
required:定义必需字段,必须有值。
optional:定义可选字段,可以有值也可以为空。
extensions:定义扩展字段。
extend:扩展已有的消息类型或枚举类型。
Golang 的类型映射
prot | golang |
---|---|
double | float64 |
float | float32 |
int32 | int32 |
int64 | int64 |
sint32 | int32 |
sint64 | int64 |
fixed32 | uint32 |
fixed64 | uint64 |
sfixed32 | int32 |
sfixed64 | int64 |
bytes | []byte |
gRPC 通信模式
主要区分方式通过 rpc 函数输入参数 类型(stream) 和 返回的 参数 类型来区分。
- 单项 RPC,即客户端发送一个请求给服务端,从服务端获取一个应答,就像一次普通的函数调用。完成后结束。
- 服务端流式 RPC,即客户端发送一个请求给服务端,可获取一个数据流用来读取一系列消息。客户端从返回的数据流里一直读取直到没有更多消息为止。
- 客户端流式 RPC,即客户端用提供的一个数据流写入并发送一系列消息给服务端。一旦客户端完成消息写入,就等待服务端读取这些消息并返回应答。
- 双向流式 RPC,即两边都可以分别通过一个读写数据流来发送一系列消息。这两个数据流操作是相互独立的,所以客户端和服务端能按其希望的任意顺序读写,例如:服务端可以在写应答前等待所有的客户端消息,或者它可以先读一个消息再写一个消息,或者是读写相结合的其他方式。每个数据流里消息的顺序会被保持。
gRpc 的主要设计思想
利用 channel 进行解耦,通过信号量机制进行同步。
拦截器使用
- ClientInterceptor(客户端拦截器):
a)请求日志记录及监控;
b)添加请求头数据、以便代理转发使用;
c)请求或者结果重写; - ServerInterceptor(服务端拦截器):
a)访问认证;
b)请求日志记录及监控;
c)代理转发;
// 声明服务端拦截器
func unerInt(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
fmt.Println("this is my unertInt!")
return handler(ctx, req)
}
func main() {
...
//注册拦截器
var opts []grpc.ServerOption
opts = append(opts, grpc.UnaryInterceptor(unerInt))
grpcServer := grpc.NewServer(opts...)
...
}
-
grpc.UnaryInterceptor : grpc.UnaryInterceptor 方法仅允许注册一个拦截器否则会抛出重复注册的错误
-
grpc.ChainUnaryInterceptor : 多个拦截器需要使用 grpc.ChainUnaryInterceptor 方法,,调用顺序根据放入数组的顺序来依次调用
metaData 结构
连接池
gRpc 没有提供链接池,需要自己实现。
为什么 gRPC 不设置自己的链接池呢?
gprc自带多路复用,单链接能力很高(几万QPS),项目初期除非是对已有大流量项目改造升级,不必要一定上连接池,不但增加额外复杂度,实际可能靠单连接就能扛住,从结果角度就可能是无用功。具体要不要,压测一下实际性能,根据预估流量进行评估即可。一个数据是单连接支持8w qps没有问题,可以大概作为指标。
grpc 的 HTTP2 连接有复用能力,N 个 goroutine 用一个 HTTP2 连接没有任何问题,不会单纯因为没有可用连接而阻塞执行。
grpc 内建的 balancer 已经有很好的连接管理的支持了,可以提供很好的负载策略,每个后端实例一个 HTTP2 物理连接,而且可以用插件扩展,如果希望对单个后端实例创建多个 HTTP2 连接,小改个 balancer 插件即可。
Resolver
grpc 常用的注册中心有 ETCD、Consul、Zookeeper 等。
引用:https://www.sohu.com/a/368368405_657921
将 grpc.DialContext 的 target string值通过 parseTarget 解析为 resolver.Target,新版本为 URL 的包装体
寻找 resolver
- gRPC 会优先从 resolver.Target 中的获取 scheme 名称,该值即为开发者在实现 resolver.Builder 时 Scheme() string 方法返回值一样。
- gRPC 去 DialOption 中的 resolver 列表寻找名称相同 resolver
通过 newCCResolverWrapper 方法调用 resolver.Buidler.Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)方法实现解析
告知负载均衡器后续处理 - gRPC 在哪里找到 resolver.Builder 的?
gRPC 的 resolver.Builder 并不会无中生有,而是我们在实例化 resolver.Buidler 的实现类时进行注册了,其实就是写到 gRPC 内部的一个全局 map 变量中了,
gRPC 在寻找是也是通 scheme 为 key 去这个 map 里找。
使用 etcd
下载安装:
go 环境配置:go get go.etcd.io/etcd/client/v3
- 基本的交互流程
- 服务注册
// EtcdRegister 服务注册
type EtcdRegister struct {
etcdCli *clientv3.Client // etcdClient对象
leaseId clientv3.LeaseID // 租约id
}
// ServiceRegister 服务注册。expire表示过期时间,serviceName和serviceAddr分别是服务名与服务地址
func (e *EtcdRegister) ServiceRegister(serviceName, serviceAddr string, expire int64) (err error) {
// 创建客户端
client, err := clientv3.New(clientv3.Config{
Endpoints: []string{etcdServerAddr}, // 服务地址
DialTimeout: 3 * time.Second,
})
e := &EtcdRegister{
etcdCli: client,
}
// 创建租约
// Leases are a mechanism for detecting client liveness. The cluster grants leases with a time-to-live.
// A lease expires if the etcd cluster does not receive a keepAlive within a given TTL period.
lease, err := e.etcdCli.Grant(context.Background(), expire)
...
// 记录生成的租约Id
e.leaseId = lease.ID
// 将租约与k-v绑定
// Keys are saved into the key-value store by issuing a Put call, which takes a PutRequest:
res, err := e.etcdCli.Put(context.Background(), key, value, clientv3.WithLease(e.leaseId))
...
// 获取监听通道
keepAliveChan, err := e.etcdCli.KeepAlive(context.Background(), e.leaseId)
...
// 持续续租
go func(keepAliveChan <-chan *clientv3.LeaseKeepAliveResponse) {
for {
select {
case resp := <-keepAliveChan:
log.Printf("续约成功...leaseID=%d", resp.ID)
}
}
}(keepAliveChan)
// 关闭服务
// 撤销租约
e.etcdCli.Revoke(context.Background(), e.leaseId)
return e.etcdCli.Close()
}
func main(){
// grpc 服务初始化
// grpc 服务调用 register 暴露当前服务
}
- 服务发现
// EtcdDiscovery 服务发现
type EtcdDiscovery struct {
cli *clientv3.Client // etcd连接
serviceMap map[string]string // 服务列表(k-v列表)
lock sync.RWMutex // 读写互斥锁
}
func NewServiceDiscovery(endpoints []string) (*EtcdDiscovery, error) {
// 创建etcdClient对象
cli, err := clientv3.New(clientv3.Config{
Endpoints: endpoints,
DialTimeout: 5 * time.Second,
})
if err != nil {
return nil, err
}
return &EtcdDiscovery{
cli: cli,
serviceMap: make(map[string]string), // 初始化kvMap
}, nil
}
// ServiceDiscovery 读取etcd的服务并开启协程监听kv变化
func (e *EtcdDiscovery) ServiceDiscovery(prefix string) error {
// 根据服务名称的前缀,获取所有的注册服务
resp, err := e.cli.Get(context.Background(), prefix, clientv3.WithPrefix())
if err != nil {
return err
}
// 遍历key-value存储到本地map
for _, kv := range resp.Kvs {
e.putService(string(kv.Key), string(kv.Value))
}
// 开启监听协程,监听prefix的变化
go func() {
watchRespChan := e.cli.Watch(context.Background(), prefix, clientv3.WithPrefix())
log.Printf("watching prefix:%s now...", prefix)
for watchResp := range watchRespChan {
for _, event := range watchResp.Events {
switch event.Type {
case mvccpb.PUT: // 发生了修改或者新增
e.putService(string(event.Kv.Key), string(event.Kv.Value)) // ServiceMap中进行相应的修改或新增
case mvccpb.DELETE: //发生了删除
e.delService(string(event.Kv.Key)) // ServiceMap中进行相应的删除
}
}
}
}()
return nil
}
// SetService 新增或修改本地服务
func (s *EtcdDiscovery) putService(key, val string) {
s.lock.Lock()
s.serviceMap[key] = val
s.lock.Unlock()
log.Println("put key :", key, "val:", val)
}
// DelService 删除本地服务
func (s *EtcdDiscovery) delService(key string) {
s.lock.Lock()
// 互斥获取本地服务信息
delete(s.serviceMap, key)
s.lock.Unlock()
log.Println("del key:", key)
}
// GetService 获取本地服务
func (s *EtcdDiscovery) GetService(serviceName string) (string, error) {
s.lock.RLock()
// 互斥获取本地服务信息
serviceAddr, ok := s.serviceMap[serviceName]
s.lock.RUnlock()
if !ok {
return "", fmt.Errorf("can not get serviceAddr")
}
return serviceAddr, nil
}
// Close 关闭服务
func (e *EtcdDiscovery) Close() error {
return e.cli.Close()
}
func main(){
// 初始化 grpc 客户端
// 获取服务地址信息,这里可能就涉及到负载均衡的问题了
// 发送请求
}
resolver 注册
resolver 的注册过程是将自己实现的 resolver 以插件的形式整合到 grpc 框架中去。
需要自己实现 Builder 对象 resolver 对象。并在 dial 时进行注册。
// Builder creates a resolver that will be used to watch name resolution updates.
type Builder interface {
// Build creates a new resolver for the given target.
//
// gRPC dial calls Build synchronously, and fails if the returned error is
// not nil.
Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
// Scheme returns the scheme supported by this resolver. Scheme is defined
// at https://github.com/grpc/grpc/blob/master/doc/naming.md. The returned
// string should not contain uppercase characters, as they will not match
// the parsed target's scheme as defined in RFC 3986.
Scheme() string
}
// 编写builder 和 resovler
package helloworld
import (
"fmt"
"google.golang.org/grpc/resolver"
)
type customBuilder struct {
scheme string
}
func NewCustomBuilder(scheme string) resolver.Builder {
return &customBuilder{
scheme: scheme,
}
}
func (b *customBuilder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
// 创建resover
if target.Endpoint() == "serviceA" {
r := myResolver{
target: target,
cc: cc,
}
r.ResolveNow(resolver.ResolveNowOptions{})
fmt.Println("enter resolver build")
return r, nil
}
fmt.Println("enter resolver build default!")
return nil, fmt.Errorf("can not find the service")
}
func (b *customBuilder) Scheme() string {
return b.scheme
}
// 实现 resolver,可以嵌入 etcd 的地址信息 和 etcd 的监控逻辑
type myResolver struct {
target resolver.Target
cc resolver.ClientConn
}
func (m myResolver) ResolveNow(opt resolver.ResolveNowOptions) {
if m.target.Endpoint() == "serviceA" {
// 更新目标地址信息
fmt.Println("update address")
m.cc.UpdateState(resolver.State{Addresses: []resolver.Address{{Addr: "localhost:8081"}}})
} else {
m.cc.UpdateState(resolver.State{Addresses: []resolver.Address{{Addr: "localhost:8089"}}})
}
}
func (m myResolver) Close() {}
func main (){
...
// 注册resover,或者使用 grpc.WithResolvers()
resolver.Register(pb.NewCustomBuilder("service"))
// client
conn, err := grpc.Dial("service:///serviceA", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewGreeterClient(conn)
...
}
Balencer
balencer 同样需要自己实现相关的对象。
内置了几个实现负载均衡策略:
grpclb
rls
roundrobin (round_robin)
weightroundrobin
weighttarget
balencer 在 resolver 中 updateState (resolver.ClientConn.UpdateState(State) ) 时候进行触发:
ServiceConfig 处理
BalancerWrapper 创建
调用 balancer.updateClientConnState 方法 执行负载均衡逻辑更新
实现自己的 balencer
可以参考 grpc 实现的balencer 例子来做,其中主要步骤也是实现自己的 builder 、Picker ,然后注册。
流量控制
流量控制都是在应用层面进行的:业务层和基于通信协议的,在控制上可以分为 client 的流出控制和server 的流入控制。
- 简单思路,自己注册一个 interceptor(常见限流算法:https://blog.csdn.net/xingjigongsi/article/details/134821264),对于集群的限流需要借助集中的存储机制,通常是 redis。
- 结合 HTTP2 的滑动窗口机制,做底层的流量控制。
基于HTTP2 的控制
gRpc 底层的 httpServer 和 client 基于 BDP 算法做了流量控制(http2Client、http2Server)
需要配置初始化窗口、并且初始化窗口大小大于默认窗口大小(65535B/64KB)会关闭。
- HTTP/2利用流来实现多路复用,这引入了对TCP连接的使用争夺,会造成流被阻塞。流量控制方案确保在同一连接上的多个流之间不会造成破坏性的干扰。流量控制会用于各个独立的流,也会用于整个连接。
- HTTP/2通过使用WINDOW_UPDATE帧来进行流量控制
- 只有DATA帧服从流量控制,所有其它类型的帧并不消耗流量控制窗口的空间。这保证了重要的控制帧不会被流量控制阻塞。
核心思想在于定期的采样,通过 bdpPing 来会之间的 ack 成交数量来估算当前窗口应该调整的大小。可以配合相应的平滑函数来减少毛刺。
动态的流量控制(BDP):https://blog.51cto.com/u_14844/10014528
http2 窗口相关配置: InitialConnectionWindowSize 和 InitialStreamWindowSize
调试和监控
需要导入 proto 文件。
日志打印
原文链接:https://blog.csdn.net/qq_41630102/article/details/137561172
grpc内置日志默认是打印到os.Stderr中的,在实际使用过程中很不方便。幸运的是,grpclog有提供设置日志接口用于替换默认的日志写入。
grpclog.SetLoggerV2()即可设置自定义日志,入参为用函数grpclog.NewLoggerV2()创建的grpclog.LoggerV2 接口。
import (
rotatelogs "github.com/lestrrat-go/file-rotatelogs"
"google.golang.org/grpc/grpclog"
"time"
)
func init() {
rpcLogPath := "log/grpc.log"
rpcLogWriter, err := rotatelogs.New(
rpcLogPath+".%Y%m%d", // 日志文件格式
rotatelogs.WithLinkName(rpcLogPath), // 最新日志文件链接名称
rotatelogs.WithRotationTime(time.Hour*24), // 滚动时间,每24小时
rotatelogs.WithMaxAge(time.Hour*24*15), // 日志保存的最长时间,这里是15天
rotatelogs.WithConsole(), // 是否同步打印到控制台
)
if err != nil {
panic(err)
}
grpclog.SetLoggerV2(grpclog.NewLoggerV2(rpcLogWriter, rpcLogWriter, rpcLogWriter))
}
client 请求的链路
引用:https://blog.csdn.net/fuyuande/article/details/130169728
Dial 核心流程
服务调用核心流程
ETCD
引用:https://baijiahao.baidu.com/s?id=1762582857703857201&wfr=spider&for=pc
官网
etcd Etcd是一个高可用的分布式键值存储系统,主要用于共享配置信息和服务发现,是Kubernetes等容器编排工具的重要组件之一.
核心概念:
租约 :过期时间,需要调用 keepAlive 定时刷新。
Leases are a mechanism for detecting client liveness. The cluster grants leases with a time-to-live. A lease expires if the etcd cluster does not receive a keepAlive within a given TTL period.
事物 :基于 raft 协议的分布式事务。
- modifications to the same key multiple times within a single transaction are forbidden。
- All comparisons are applied atomically;
监听 :异步监听 key 的变化。
The Watch API provides an event-based interface for asynchronously monitoring changes to keys. An etcd watch waits for changes to keys by continuously watching from a given revision, either current or historical, and streams key updates back to the client.