服务注册与发现

283 阅读3分钟

服务注册于发现可以说是微服务架构服务中必须要解决的一大问题,本文基于 etcd 实现了一个简单的服务发现与注册功能

服务注册

核心逻辑

注册

在 etcd 中,直接以 service 标识为 key,service 的地址为 value,存储即可

健康检查

通常有两种策略

  1. 由注册中心定期循环的向服务发出请求,根据响应情况判断服务的健康状态

    image.png

  2. 服务主动向注册中心发起健康通知

    image.png

编码

  1. 定义通用接口 因为服务注册有不同的解决方案,如etcd,consul,redis等等,故定义通用接口来规范服务注册这一行为,这里定义的服务注册包含RegisterDeRegister两种方法

    type Register interface {
    	// Register 注册
    	Register(service Service) error
    
    	// DeRegister 注销
    	DeRegister(service Service) error
    }
    
    type Service interface {
    	Name() string
    	Addr() string
    }
    
  2. 实现接口方法 定义一个 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
    }
    
  3. 创建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
    }
    
  4. 实现注册方法(这里采用的时第一种健康检查策略)

    • 完成租约申请
    • 将服务放入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
    }
    
  5. 模拟一个订单服务,并将其注册到 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,获得以下输出

    image.png

    这样就实现了单一服务注册到 etcd 中,

    如果想要注册多个 order 服务,只需要对存储到 etcd 的 key 做出修改即可,如一个 order 服务,服务的 key 就可以由两部分组成,order + 唯一标识,在查询 etcd 时就可以通过 --prefix 查询到order服务的所有地址 etcdctl get --prefix order

  6. 注意在上面的代码中,续约操作会返回一个channel 来接受每次续约的响应,但我们并未处理,这里对其进行处理

    RegisterEtcd中添加一个字段keepAliveRespChan <-chan clientv3.LeaseKeepAliveResponse,并在续约时使用此字段接受返回参数并编写一个函数处理响应即可

    image.png

  7. 实现注销方法

    核心为撤销租约

    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
    }
    
  8. 通过系统信号来监控,只需在服务中添加以下代码,在程序退出时会向 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 的服务发现便是使用的随机算法

编码

  1. 定义通用接口与结构体 与注册类似,我们需要定义一个通用接口

    type Discovery interface {
    	// GetServiceAddr 获取某一服务地址
    	GetServiceAddr(name string) (string, error)
    
    	// WatchService 监控服务地址变化
    	WatchService(name string) error
    }
    
    type DiscoveryEtcd struct {
    	cli *clientv3.Client
    }
    
  2. 实例化

    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
    }
    
  3. 实现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 服务,每次输出地址随机

总结

服务注册与发现解决的就是在微服务架构下多个服务相互独立,错综复杂,寻址困难的问题

服务注册与发现可以总结为以下过程

image.png

而对于实现服务注册与发现的中间件,应该具有但不限于以下特性:

  • 高可用

    • 能够保证服务的高可用性,包括但不限于容灾、集群化、备份、恢复等措施
  • 可扩展

    • 能够方便地进行服务的扩展,支持水平和垂直扩展
  • 高效性

    • 能够快速响应服务请求,提供高效的服务

而 etcd 是一款使用 go 语言编写的分布式的、高可用的键值存储系统,他具有以下的特性:

  1. 分布式:Etcd是一个分布式系统,可以将数据存储在多个节点上,避免单点故障的问题。
  2. 高可用:Etcd具有高可用性,可以支持多节点,通过选举机制选出主节点,确保服务的可用性。
  3. 一致性:Etcd使用Raft算法保证数据的一致性,确保多个节点之间数据的同步性。
  4. 支持事务:Etcd支持事务,可以在单个请求中实现多个操作,从而避免了因为操作失败而导致的数据不一致的问题。
  5. 监控和故障诊断:Etcd提供了监控和故障诊断工具,可以对节点的状态进行监控和分析,及时发现和处理故障。
  6. RESTful API:Etcd提供了RESTful API,方便用户进行数据的读写操作,同时也支持多种客户端库,如Go、Java、Python等,可以方便地集成到不同的应用中。

正是由于 etcd 具有这些特性,其非常适合用来作为服务注册与发现的中间件

etcd 其实还可以做为配置中心来使用