一、 grpc服务解析接口
grpc 的 resolver 包中提供了可以自定义服务发现的接口:
// package google.golang.org/grpc/resolver
func Register(b Builder) {
m[b.Scheme()] = b
}
// ...
type Builder interface {
// 创建新的Resolver,需要保存 cc
Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
// 标识解析器,返回字符串,遵循RFC 3986,比如在服务地址输入 etcd://server.ip,会使用 Scheme 为 etcd 的解析器解析 server.ip
Scheme() string
}
// ...
type Resolver interface {
// 解析目标地址,需要在实现中调用上面 Build 中入参 ClientConn 的 UpdateState 方法更新解析结果。 注意可能存在并发情况
ResolveNow(ResolveNowOptions)
// Close closes the resolver.
Close()
}
二、基于 etcd 的服务发现
我们首先考虑下实现服务发现需要哪些步骤:
- 服务端:需要注册节点,而且注册的节点需要有过期时间,防止服务挂掉节点还能被客户端解析;相同服务集群下的不同地址注册需要用某种方式标识,以便客户端发现。这里我们使用 etcd 的租约实现保活;使用
服务名/地址
的方式注册,客户端可以用前缀的形式发现对应服务名下的所有地址。- 客户端:需要解析节点,解析时需要提前知道待解析的服务名,然后根据约定信息获取对应服务的地址列表,同时还需要监听服务注册信息的变化,及时更新服务节点信息。这里我们使用前缀的形式发现对应服务名下的所有地址,使用 etcd 的 watch 机制监听服务节点变化。
服务发现的这部分代码这里单独拿出来放到了git仓库里:https://github.com/sauryniu/discovery.git
下面就一些实现把代码贴出来。
- 服务节点信息
这里我们创建个 node 结构保存服务节点信息:
// node.go
package discovery
import (
"fmt"
"strings"
)
type Node struct {
Name string `json:"name"` // 名称
Addr string `json:"addr"` // 地址
}
// 把服务名中的 . 转换为 /
func (s Node) transName() string {
return strings.ReplaceAll(s.Name, ".", "/")
}
// 构建节点 key
func (s Node) buildKey() string {
return fmt.Sprintf("/%s/%s", s.transName(), s.Addr)
}
// 构建节点前缀
func (s Node) buildPrefix() string {
return fmt.Sprintf("/%s", s.transName())
}
- 从 etcd 解析服务节点
// etcdResolver.go
package discovery
import (
"context"
"encoding/json"
etcdV3 "go.etcd.io/etcd/client/v3"
"google.golang.org/grpc/resolver"
"os"
"strings"
"sync"
"time"
)
const (
tickerTime = 10 * time.Second
)
type etcdResolver struct {
// 记录所有创建的解析器,同一个host只创建一个解析器
mr map[string]resolver.Resolver
mrMux sync.RWMutex
// etcd 客户端
cli *etcdV3.Client
// etcd 地址
etcdAddrs []string
// 连接 etcd 超时时间
dialTimeout time.Duration
// 需要解析的目标节点
tnsMux sync.RWMutex
targetNodeSet map[string]*Node
// 解析到的服务节点, host:addr:*Node
snsMux sync.RWMutex
serviceNodes map[string]map[string]*Node
cancel context.CancelFunc
once sync.Once
}
// 获取当前解析到的服务节点
func (e *etcdResolver) getServiceNodes(name string) []*Node {
e.snsMux.RLock()
defer e.snsMux.RUnlock()
nodes := make([]*Node, 0)
for _, n := range e.serviceNodes[name] {
nodes = append(nodes, n)
}
return nodes
}
// 设置解析到的服务节点
func (e *etcdResolver) setServiceNodes(name string, nodes ...*Node) {
e.snsMux.Lock()
defer e.snsMux.Unlock()
ns := e.serviceNodes[name]
if ns == nil {
ns = make(map[string]*Node)
}
for i := range nodes {
logger.Infof("resolver node [%s:%s]", name, nodes[i].Addr)
ns[nodes[i].Addr] = nodes[i]
}
e.serviceNodes[name] = ns
}
// 溢出服务节点
func (e *etcdResolver) removeServiceNode(name, addr string) {
e.snsMux.Lock()
defer e.snsMux.Unlock()
nodes := e.serviceNodes[name]
if nodes == nil {
return
}
delete(nodes, addr)
}
// 设置解析器
func (e *etcdResolver) setManuResolver(host string, m resolver.Resolver) {
e.mrMux.Lock()
defer e.mrMux.Unlock()
e.mr[host] = m
}
// 根据host获取解析器
func (e *etcdResolver) getManuResolver(host string) (resolver.Resolver, bool) {
e.mrMux.RLock()
defer e.mrMux.RUnlock()
if m, ok := e.mr[host]; ok {
return m, ok
}
return nil, false
}
// 设置解析目标节点
func (e *etcdResolver) setTargetNode(host string) {
e.tnsMux.Lock()
e.targetNodeSet[host] = &Node{Name: host}
e.tnsMux.Unlock()
// 开始解析时进行相关操作,只执行一次
e.once.Do(func() {
var ctx context.Context
ctx, e.cancel = context.WithCancel(context.Background())
e.start(ctx)
})
}
// 获取解析目标节点
func (e *etcdResolver) getTargetNodes() []*Node {
e.tnsMux.RLock()
defer e.tnsMux.RUnlock()
nodes := make([]*Node, 0)
for _, n := range e.targetNodeSet {
nodes = append(nodes, n)
}
return nodes
}
// 解析所有需要解析的节点
func (e *etcdResolver) resolverAll(ctx context.Context) {
nodes := e.getTargetNodes()
for _, node := range nodes {
// 根据前缀获取节点信息
cctx, cancel := context.WithTimeout(context.Background(), e.dialTimeout)
rsp, err := e.cli.Get(cctx, node.buildPrefix(), etcdV3.WithPrefix())
cancel()
if err != nil {
logger.Errorf("get service node [%s] error:%s", node.Name, err.Error())
continue
}
for j := range rsp.Kvs {
n := &Node{}
err = json.Unmarshal(rsp.Kvs[j].Value, n)
if err != nil {
logger.Errorf("get service node [%s] error:%s", node.Name, err.Error())
continue
}
e.setServiceNodes(node.Name, n)
}
}
// 解析完服务节点后,更新到连接上
e.mrMux.RLock()
defer e.mrMux.RUnlock()
for _, v := range e.mr {
v.ResolveNow(resolver.ResolveNowOptions{})
}
}
func (e *etcdResolver) start(ctx context.Context) {
if len(e.etcdAddrs) == 0 {
panic("discovery should call SetDiscoveryAddress or set env DISCOVERY_HOST")
}
var err error
e.cli, err = etcdV3.New(etcdV3.Config{
Endpoints: e.etcdAddrs,
DialTimeout: e.dialTimeout,
})
if err != nil {
panic(err)
}
// 开始先全部解析
e.resolverAll(ctx)
ticker := time.NewTicker(tickerTime)
// 定时解析
go func() {
for {
select {
case <-ticker.C:
e.resolverAll(ctx)
case <-ctx.Done():
logger.Infoln("resolver ticker exit")
return
}
}
}()
// 每个节点watch变化
nodes := e.getTargetNodes()
for i := range nodes {
go func(node *Node) {
wc := e.cli.Watch(ctx, node.buildPrefix(), etcdV3.WithPrefix())
for {
select {
case rsp := <-wc:
for _, event := range rsp.Events {
switch event.Type {
case etcdV3.EventTypePut:
n := &Node{}
err = json.Unmarshal(event.Kv.Value, n)
if err != nil {
logger.Errorf("unmarshal to node error:%s", err.Error())
continue
}
e.setServiceNodes(node.Name, n)
case etcdV3.EventTypeDelete:
n := &Node{}
err = json.Unmarshal(event.Kv.Value, n)
if err != nil {
logger.Errorf("unmarshal to node error:%s", err.Error())
continue
}
e.removeServiceNode(node.Name, n.Addr)
}
}
case <-ctx.Done():
logger.Infoln("resolver watcher exit")
return
}
}
}(nodes[i])
}
}
func (e *etcdResolver) stop() {
logger.Infoln("resolver stop")
e.cancel()
}
func etcdResolverInit() {
envEtcdAddr := os.Getenv("DISCOVERY_HOST")
eRegister = &etcdRegister{
nodeSet: make(map[string]*Node),
cli: nil,
dialTimeout: time.Second * 3,
ttl: 3,
}
if len(envEtcdAddr) > 0 {
eRegister.etcdAddrs = strings.Split(envEtcdAddr, ";")
}
}
- 注册节点信息到 etcd
// etcdRegister.go
package discovery
import (
"context"
"encoding/json"
etcdV3 "go.etcd.io/etcd/client/v3"
"google.golang.org/grpc/resolver"
"os"
"strings"
"sync"
"time"
)
type etcdRegister struct {
// 注册节点set
nodeSet map[string]*Node
// etcd句柄
cli *etcdV3.Client
// etcd服务地址
etcdAddrs []string
// 连接超时时间
dialTimeout time.Duration
// etcd租约id
etcdLeaseId etcdV3.LeaseID
// 注册节点过期时间
ttl int64
// 取消函数,用去结束注册任务
cancel context.CancelFunc
once sync.Once
}
// 新增注册的服务节点
func (e *etcdRegister) addServiceNode(node *Node) {
e.nodeSet[node.buildKey()] = node
// 新增注册节点的时候,开始执行注册任务
e.once.Do(
func() {
var ctx context.Context
ctx, e.cancel = context.WithCancel(context.Background())
e.start(ctx)
})
}
// 开始注册任务
func (e *etcdRegister) start(ctx context.Context) {
if len(e.etcdAddrs) == 0 {
panic("discovery should call SetDiscoveryAddress or set env DISCOVERY_HOST")
}
// 连接etcd
var err error
e.cli, err = etcdV3.New(etcdV3.Config{
Endpoints: e.etcdAddrs,
DialTimeout: e.dialTimeout,
})
if err != nil {
panic(err)
}
// 创建租约
cctx, cancel := context.WithTimeout(ctx, e.dialTimeout)
rsp, err := e.cli.Grant(cctx, e.ttl)
if err != nil {
panic(err)
}
cancel()
e.etcdLeaseId = rsp.ID
// 保活
kc, err := e.cli.KeepAlive(ctx, rsp.ID)
if err != nil {
logger.Errorf("etcd keepalive error:%s", err.Error())
}
go func() {
for {
select {
case kaRsp := <-kc:
if kaRsp != nil {
e.register(ctx)
}
case <-ctx.Done():
logger.Infoln("register exit")
return
}
}
}()
}
// 注册节点
func (e *etcdRegister) register(ctx context.Context) {
// 遍历所有的服务节点进行注册
for _, n := range e.nodeSet {
value, err := json.Marshal(n)
if err != nil {
logger.Errorf("json marshal node:%s error:%s", n.Name, err.Error())
continue
}
// 使用租约id注册
cctx, cancel := context.WithTimeout(ctx, e.dialTimeout)
_, err = e.cli.Put(cctx, n.buildKey(), string(value), etcdV3.WithLease(e.etcdLeaseId))
cancel()
if err != nil {
logger.Errorf("put %s:%s to etcd with lease id %d error:%s", n.buildKey(), string(value), e.etcdLeaseId, err.Error())
continue
}
logger.WithField("component", "discovery").Infof("put %s:%s to etcd with lease id %d", n.buildKey(), string(value), e.etcdLeaseId)
}
}
// 停止注册任务
func (e *etcdRegister) stop() {
logger.Infoln("register stop")
// 退出注册任务
e.cancel()
// 清理注册信息
for _, n := range e.nodeSet {
value, err := json.Marshal(n)
if err != nil {
logger.Errorf("json marshal node:%s error:%s", n.Name, err.Error())
continue
}
cctx, cancel := context.WithTimeout(context.Background(), e.dialTimeout)
_, _ = e.cli.Delete(cctx, n.buildKey())
cancel()
logger.Infof("delete %s:%s from etcd", n.buildKey(), string(value))
}
}
// 注册器初始化
func etcdRegisterInit() {
envEtcdAddr := os.Getenv("DISCOVERY_HOST")
eResolver = &etcdResolver{
mr: make(map[string]resolver.Resolver),
dialTimeout: time.Second * 3,
targetNodeSet: make(map[string]*Node),
serviceNodes: make(map[string]map[string]*Node),
}
if len(envEtcdAddr) > 0 {
eResolver.etcdAddrs = strings.Split(envEtcdAddr, ";")
}
}
- 实现 Resolver 接口:
// resolver.go
package discovery
import "google.golang.org/grpc/resolver"
type IResolver interface {
getServiceNodes(host string) []*Node
setTargetNode(host string)
setManuResolver(host string, m resolver.Resolver)
}
type manuResolver struct {
cc resolver.ClientConn
target resolver.Target
r IResolver
}
func (m manuResolver) ResolveNow(options resolver.ResolveNowOptions) {
nodes := m.r.getServiceNodes(m.target.URL.Host)
addresses := make([]resolver.Address, 0)
for i := range nodes {
addresses = append(addresses, resolver.Address{
Addr: nodes[i].Addr,
})
}
if err := m.cc.UpdateState(resolver.State{
Addresses: addresses,
}); err != nil {
logger.Errorf("resolver update cc state error:%s", err.Error())
}
}
func (manuResolver) Close() {
}
- 实现 Builder 接口:
// builder.go
package discovery
import "google.golang.org/grpc/resolver"
const (
etcdScheme = "etcd"
)
type builder struct{}
func (builder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
mr := manuResolver{
cc: cc,
target: target,
r: eResolver,
}
// 记录解析器
mr.r.setManuResolver(target.URL.Host, mr)
// 记录需要解析的节点
mr.r.setTargetNode(target.URL.Host)
return mr, nil
}
func (builder) Scheme() string {
return etcdScheme
}
func init() {
resolver.Register(builder{})
}
最后是使用方法:
// client.go
import _ "github.com/sauryniu/discovery"
// server.go
import "github.com/sauryniu/discovery"
func init() {
discovery.AddServiceNode(&discovery.Node{
Name: "api.grpcdemo.com",
Addr: "127.0.0.1:8080",
})
}
// main.go
func main() {
go server.Start(":8080")
// 确保服务开起来
time.Sleep(time.Second)
client.Test("etcd://api.grpcdemo.com")
}