gRPC 和 etcd

官网 : 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 的类型映射

protgolang
doublefloat64
floatfloat32
int32int32
int64int64
sint32int32
sint64int64
fixed32uint32
fixed64uint64
sfixed32int32
sfixed64int64
bytes[]byte

gRPC 通信模式

主要区分方式通过 rpc 函数输入参数 类型(stream) 和 返回的 参数 类型来区分。

  1. 单项 RPC,即客户端发送一个请求给服务端,从服务端获取一个应答,就像一次普通的函数调用。完成后结束。
  2. 服务端流式 RPC,即客户端发送一个请求给服务端,可获取一个数据流用来读取一系列消息。客户端从返回的数据流里一直读取直到没有更多消息为止。
  3. 客户端流式 RPC,即客户端用提供的一个数据流写入并发送一系列消息给服务端。一旦客户端完成消息写入,就等待服务端读取这些消息并返回应答。
  4. 双向流式 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

  • 基本的交互流程
client etcd server 通过 watch 监听某个 key 的变化,一般监听 prefix 初始化客户端,设置 lease ,put 创建k-v地址对,设置 keepLive key 发生变化 更新本地地址数据 查找请求地址 服务地址 发送请求 client etcd server
  • 服务注册
// 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 的流入控制。

  1. 简单思路,自己注册一个 interceptor(常见限流算法:https://blog.csdn.net/xingjigongsi/article/details/134821264),对于集群的限流需要借助集中的存储机制,通常是 redis。
  2. 结合 HTTP2 的滑动窗口机制,做底层的流量控制。

引用自:https://blog.51cto.com/u_14844/10014528

基于HTTP2 的控制

gRpc 底层的 httpServer 和 client 基于 BDP 算法做了流量控制(http2Client、http2Server)

需要配置初始化窗口、并且初始化窗口大小大于默认窗口大小(65535B/64KB)会关闭。
在这里插入图片描述

在这里插入图片描述

  1. HTTP/2利用流来实现多路复用,这引入了对TCP连接的使用争夺,会造成流被阻塞。流量控制方案确保在同一连接上的多个流之间不会造成破坏性的干扰。流量控制会用于各个独立的流,也会用于整个连接。
  2. HTTP/2通过使用WINDOW_UPDATE帧来进行流量控制
  3. 只有DATA帧服从流量控制,所有其它类型的帧并不消耗流量控制窗口的空间。这保证了重要的控制帧不会被流量控制阻塞。

核心思想在于定期的采样,通过 bdpPing 来会之间的 ack 成交数量来估算当前窗口应该调整的大小。可以配合相应的平滑函数来减少毛刺

动态的流量控制(BDP):https://blog.51cto.com/u_14844/10014528
http2 窗口相关配置: InitialConnectionWindowSize 和 InitialStreamWindowSize

调试和监控

简介和调试

postman 调试 gRpc

需要导入 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 核心流程

Created with Raphaël 2.3.0 开始 Dial DialContext NewClient 应用配置 注册拦截器 校验creds 解析 resolver

服务调用核心流程

Created with Raphaël 2.3.0 Invoke newClientStream 等待地址解析完成 转换为传输层数据 触发负载均衡策略,通过picker 获取一个链接 创建 http2 的stream 发送消息

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 协议的分布式事务。

  1. modifications to the same key multiple times within a single transaction are forbidden。
  2. 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.

在这里插入图片描述

在这里插入图片描述

Go微服务开发是利用Go语言进行微服务架构的开发方式。在这个问题中,使用了gin、grpcetcd进行重构grpc-todolist项目。 Gin是一个轻量级的Web框架,使用它可以快速构建高性能的Web应用程序。它具有简单易用、性能出色和灵活的特点。在微服务开发中,Gin可以作为HTTP服务器框架,处理和响应客户端的HTTP请求。 gRPC是一种高性能、开源的远程过程调用(RPC)框架。它支持多种编程语言,并使用带有协议缓冲区的Google Protocol Buffers进行数据交换。在微服务架构中,gRPC可以用于服务之间的通信,通过定义接口和消息格式,实现服务间的数据传输和调用。 Etcd是一个高可靠、分布式的键值存储系统。它使用Raft一致性算法来保证数据的可靠性和一致性。在微服务开发中,Etcd可以作为服务发现和配置管理的工具,用于注册和发现各个微服务的信息。 对于重构grpc-todolist项目来说,使用gin可以将原有的HTTP接口改写为更加高性能的接口,提高整个系统的性能。通过使用gRPC,可以将原有的接口定义为gRPC接口,实现服务间的高效通信,并且易于扩展和维护。同时,借助Etcd实现服务注册和发现,提高系统的可用性和灵活性。 总而言之,通过使用gin、grpcetcdgrpc-todolist项目进行重构,可以提高系统性能、扩展性和可维护性。这种微服务开发方式能够更好地适应大规模分布式系统的需求,使得系统更加稳定和可靠。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值