服务注册于发现可以说是微服务架构服务中必须要解决的一大问题,本文基于 etcd 实现了一个简单的服务发现与注册功能
服务注册
核心逻辑
注册
在 etcd 中,直接以 service 标识为 key,service 的地址为 value,存储即可
健康检查
通常有两种策略
-
由注册中心定期循环的向服务发出请求,根据响应情况判断服务的健康状态
-
服务主动向注册中心发起健康通知
编码
-
定义通用接口 因为服务注册有不同的解决方案,如etcd,consul,redis等等,故定义通用接口来规范服务注册这一行为,这里定义的服务注册包含
Register与DeRegister两种方法type Register interface { // Register 注册 Register(service Service) error // DeRegister 注销 DeRegister(service Service) error } type Service interface { Name() string Addr() string } -
实现接口方法 定义一个 etcd 的接口体,其中包含 etcd 客户端,与租约的信息,使用这一结构体来实现注册接口
type RegisterEtcd struct { // 客户端信息 cli *clientv3.Client // 租约信息,基于租约实现健康检查 leaseId clientv3.LeaseID leaseTTL int64 } func (r RegisterEtcd) Register(service Service) error { return nil } func (r RegisterEtcd) DeRegister(service Service) error { return nil } -
创建
RegisterEtcd实例func NewRegisterEtcd(endpoints []string) (*RegisterEtcd, error) { // 检查 endpoints if len(endpoints) == 0 { return nil, fmt.Errorf("empty endponits") } // 连接到 etcd cli, err := clientv3.New(clientv3.Config{ Endpoints: endpoints, DialTimeout: time.Second * 3, }) if err != nil { return nil, err } // 初始化 RegisterEtcd re := &RegisterEtcd{ cli: cli, leaseTTL: 10, } return re, nil } -
实现注册方法(这里采用的时第一种健康检查策略)
- 完成租约申请
- 将服务放入etcd,同时绑定租约
- 租约续约
func (r RegisterEtcd) Register(service Service) error { // 申请租约 grantResp, err := r.cli.Grant(context.Background(), r.leaseTTL) if err != nil { return err } // grantResp.ID 就是租约 ID r.leaseId = grantResp.ID // 将服务放入 etcd ,绑定租约 _, err = r.cli.Put(context.Background(), service.Name(), service.Addr(), clientv3.WithLease(r.leaseId)) if err != nil { return err } // 租约续约 _, err = r.cli.KeepAlive(context.Background(), r.leaseId) if err != nil { return err } log.Printf("service %s was registed", service.Name()) return nil } -
模拟一个订单服务,并将其注册到 etcd 中
type OrderService struct { name string addr string } func (o OrderService) Name() string { return o.name } func (o OrderService) Addr() string { return o.addr } func ServiceOrder(addr string) { // 获取一个 OrderService orderService := OrderService{ name: "order", addr: addr, } // 获取 RegisterEtcd re, err := NewRegisterEtcd([]string{"localhost:2379"}) if err != nil { log.Fatalln("new register failed err:", err) } // 注册 err = re.Register(orderService) if err != nil { log.Fatalln("register service failed err:", err) } // 运行服务 log.Println("order service start...") select {} }为以上代码编写一个测试函数
package dicovery import "testing" func TestServiceOrder(t *testing.T) { ServiceOrder("localhost:8080") }执行测试函数后在终端执行
ectdctl get order,获得以下输出这样就实现了单一服务注册到 etcd 中,
如果想要注册多个 order 服务,只需要对存储到 etcd 的 key 做出修改即可,如一个 order 服务,服务的 key 就可以由两部分组成,order + 唯一标识,在查询 etcd 时就可以通过
--prefix查询到order服务的所有地址etcdctl get --prefix order -
注意在上面的代码中,续约操作会返回一个
channel来接受每次续约的响应,但我们并未处理,这里对其进行处理在
RegisterEtcd中添加一个字段keepAliveRespChan <-chan clientv3.LeaseKeepAliveResponse,并在续约时使用此字段接受返回参数并编写一个函数处理响应即可 -
实现注销方法
核心为撤销租约
func (r RegisterEtcd) DeRegister(service Service) error { if _, err := r.cli.Revoke(context.Background(), r.leaseId); err != nil { return err } if err := r.cli.Close(); err != nil { return err } return nil } -
通过系统信号来监控,只需在服务中添加以下代码,在程序退出时会向 chInt 发送信号,然后使用select 监控,收到信号时调用
DeRegister方法即可// 监控系统终止信号,来撤销租约 chInt := make(chan os.Signal) signal.Notify(chInt, os.Interrupt) // 监控 interrupt 信号(如ctrl+c) select { case <-chInt: if err = re.DeRegister(orderService); err != nil { log.Printf("deregister service %s failed err:%s", orderService.name, err.Error()) } log.Printf("service %s deregister", orderService.name) }
服务发现
核心逻辑
寻址
在终端中我们使用的是 etcdctl get --prefix order 来获取
负载均衡
在寻址中,我们获取到了服务的所有地址,而使用时只需要选择其中一个,这就引出了负载均衡的问题
常用的负载均衡算法有以下
- 随机
- 轮询
- 加权轮询
- 一致性哈希
但在服务发现这种场景中,使用随机可以说是最优选择,如 consul 的服务发现便是使用的随机算法
编码
-
定义通用接口与结构体 与注册类似,我们需要定义一个通用接口
type Discovery interface { // GetServiceAddr 获取某一服务地址 GetServiceAddr(name string) (string, error) // WatchService 监控服务地址变化 WatchService(name string) error } type DiscoveryEtcd struct { cli *clientv3.Client } -
实例化
func NewDiscoveryEtcd(endpoints []string) (*DiscoveryEtcd, error) { // 检查 endpoints if len(endpoints) == 0 { return nil, fmt.Errorf("empty endponits") } // 连接到 etcd cli, err := clientv3.New(clientv3.Config{ Endpoints: endpoints, DialTimeout: time.Second * 3, }) if err != nil { return nil, err } // 初始化 RegisterEtcd re := &DiscoveryEtcd{ cli: cli, } return re, nil } -
实现
GetAddr方法func (d *DiscoveryEtcd) GetServiceAddr(name string) (string, error) { resp, err := d.cli.Get(context.Background(), name, clientv3.WithPrefix()) if err != nil { return "", err } if len(resp.Kvs) == 0 { return "", fmt.Errorf("no such service") } // 使用随机算法返回一个服务地址 rand.Seed(time.Now().UnixNano()) randIndex := rand.Intn(len(resp.Kvs)) // [0,n) addr := string(resp.Kvs[randIndex].Value) return addr, nil }编写测试函数
先模拟一个服务来调用 Order 服务
func ServiceDriver() { de, err := NewDiscoveryEtcd([]string{"localhost:2379"}) if err != nil { log.Fatalln(err) } serviceName := "order" addr, err := de.GetServiceAddr(serviceName) if err != nil { log.Fatalln(err) } log.Printf("service %s was discoveried on %s\n", serviceName, addr) // 获取到地址后调用该服务即可 select {} }测试函数
func TestServiceOrder(t *testing.T) { ServiceOrder("localhost:8080") } func TestServiceOrder2(t *testing.T) { ServiceOrder("localhost:8081") } func TestServiceOrder3(t *testing.T) { ServiceOrder("localhost:8082") } func TestServiceDriver(t *testing.T) { ServiceDriver() }多次测试 Driver 服务,每次输出地址随机
总结
服务注册与发现解决的就是在微服务架构下多个服务相互独立,错综复杂,寻址困难的问题
服务注册与发现可以总结为以下过程
而对于实现服务注册与发现的中间件,应该具有但不限于以下特性:
-
高可用
- 能够保证服务的高可用性,包括但不限于容灾、集群化、备份、恢复等措施
-
可扩展
- 能够方便地进行服务的扩展,支持水平和垂直扩展
-
高效性
- 能够快速响应服务请求,提供高效的服务
而 etcd 是一款使用 go 语言编写的分布式的、高可用的键值存储系统,他具有以下的特性:
- 分布式:Etcd是一个分布式系统,可以将数据存储在多个节点上,避免单点故障的问题。
- 高可用:Etcd具有高可用性,可以支持多节点,通过选举机制选出主节点,确保服务的可用性。
- 一致性:Etcd使用Raft算法保证数据的一致性,确保多个节点之间数据的同步性。
- 支持事务:Etcd支持事务,可以在单个请求中实现多个操作,从而避免了因为操作失败而导致的数据不一致的问题。
- 监控和故障诊断:Etcd提供了监控和故障诊断工具,可以对节点的状态进行监控和分析,及时发现和处理故障。
- RESTful API:Etcd提供了RESTful API,方便用户进行数据的读写操作,同时也支持多种客户端库,如Go、Java、Python等,可以方便地集成到不同的应用中。
正是由于 etcd 具有这些特性,其非常适合用来作为服务注册与发现的中间件
etcd 其实还可以做为配置中心来使用