服务发现是微服务架构中的一个核心组件,它允许服务实例在启动时向注册中心注册自己的元数据,如网络地址、服务名称和标签等。这些信息使得其他服务能够发现并与之通信,从而实现服务间的动态解耦和高效协作。

在本文中,我们将深入探讨服务发现的客户端接口设计。服务发现的客户端接口通常包括注册、注销和查询服务实例的方法。服务注册是服务实例将自己信息注册到注册中心的过程,注销则是服务实例在停止时从注册中心删除自己的信息。查询服务实例则是服务消费者根据服务名称查询可用的服务实例列表。

我们将介绍几个知名的服务发现项目,包括etcd、Zookeeper和Consul。这些项目提供了不同的服务发现机制和客户端实现方式。例如,etcd使用Raft算法保证数据的一致性,支持服务注册与发现;Zookeeper提供了分布式协调服务,包括服务注册和健康检查;Consul则提供了全面的服务发现和健康检查功能,支持多数据中心部署。

为了将理论知识与实践相结合,本文还将提供一个简单的示例,展示如何将两个HTTP微服务与服务发现集成。我们将创建两个微服务,并通过服务发现组件实现它们的注册和发现。这个示例将帮助读者理解服务发现在实际应用中的工作方式。

客户端接口设计

在微服务架构中,服务发现是实现服务间通信和动态路由的关键机制。为了有效地管理和协调这些服务,我们需要设计一套健壮的客户端接口。本节将深入探讨如何构建这样的接口,包括定义服务节点(ServiceNode)、服务发现客户端(NodeRegistry)以及负载均衡器(LoadBalancer)的核心组件。我们将分析这些组件的实现原理,探讨它们在服务发现过程中的作用,以及如何通过这些接口实现服务的注册、发现和负载均衡。

ServiceNode

ServiceNode是服务发现机制中的一个基础组件,它代表了一个服务实例的网络位置和状态。在微服务架构中,一个服务可能由多个实例组成,每个实例都运行着相同的代码,但可能部署在不同的物理或虚拟机器上。ServiceNode 结构体通常包含服务实例的 IP 地址、端口号以及一系列标签(tags),这些标签可以用于进一步描述服务实例的特性,如版本号、环境(开发、测试、生产)等。

type ServiceNode struct {
  ServiceName string
  IP    net.IP
  Port   int
  Tags   map[string]string
}

这样的设计允许服务消费者根据标签选择特定的服务实例进行调用,例如,可能只想调用某个特定版本的服务实例,或者基于标签进行路由选择。

NodeRegistry

DiscoveryClient 是服务发现机制的核心接口,它定义了服务发现客户端必须实现的方法。这些方法包括获取服务节点列表、注册当前节点到服务注册中心以及注销节点。NodeRegistry接口的实现负责与服务注册中心的交互,无论是通过 REST API、gRPC 还是其他通信协议。

type NodeRegistry interface {
  GetNodes(ctx context.Context, serverName string, tags map[string]string) ([]*ServiceNode, error)
  Register(ctx context.Context, node *ServiceNode) error
  Unregister(ctx context.Context, node *ServiceNode) error

}

这种接口设计使得服务发现客户端可以与具体的服务注册中心实现解耦,提高了代码的可维护性和可扩展性。服务消费者可以通过实现此接口的任何客户端库与不同的服务注册中心进行交互,而无需关心背后的实现细节。

LoadBalancer

LoadBalancer 定义了负载均衡器的接口,它负责从服务发现客户端获取的服务节点列表中选择一个节点来处理请求。负载均衡器可以采用不同的策略,如轮询、随机选择、加权轮询或基于 IP 哈希的策略,以确保请求均匀地分配到各个服务实例。

type LoadBalancer interface {
  Select(nodes []*ServiceNode) *ServiceNode
}

负载均衡器的设计对于提高系统的可用性和响应性至关重要。通过实现不同的负载均衡策略,可以为不同的业务场景提供最适合的请求分配机制。

DiscoveryClient

对于业务应用来说,只需要根据服务名称获取一个节点信息然后进行请求即可。因此应该组合NodeRegistry和LoadBalancer的能力,获取到节点列表后直接进行路由,最后将最终节点返回给业务应用。

type DiscoveryClient struct {
 LoadBalancer LoadBalancer
 Registry NodeRegistry
}
func (d *DiscoveryClient) Resolve(ctx context.Context, serviceName)(*ServiceNode) {
 // TODO 返回服务节点
}

etcd

etcd(https://etcd.io/)是一个高度一致的分布式键值存储,它提供了一种可靠的方式来存储需要由分布式系统或机器集群访问的数据。它在微服务和 Kubernetes 集群中不仅可以作为服务注册于发现,还可以作为 key-value 存储的中间件。etcd 的目标是构建一个高可用的分布式键值数据库,具有简单、键值对存储、监测变更、安全、快速和可靠等特点。它采用 Raft 算法实现分布式系统数据的高可用性、一致性,并且使用 Go 语言编写,具有出色的跨平台支持和强大的社区。

etcd 的架构包括 client 层、API 网络层、Raft 算法层和存储层。client 层包含 client v2 和 v3 两个版本的 API 客户端。API 网络层处理客户端访问服务器和服务器节点间的通信协议。Raft 算法层实现了 Leader 选举、日志复制等核心算法特性,保障 etcd 多节点间的数据一致性。存储层包含预写日志 WAL 模块、快照 Snapshot 模块和 boltdb 模块,其中 WAL 可保障 etcd crash 后数据不丢失,boltdb 则保存了集群元数据和用户写入的数据。

etcd 的应用场景广泛,最常用于服务注册与发现,作为集群管理的组件使用,也可以作为 K-V 存储,作为数据库使用。它的存储特点是采用 kv 型数据存储,支持动态存储和静态存储,分布式存储,可集成为多节点集群,存储方式采用类似目录结构。

在安全性方面,etcd 支持通过 TLS 协议进行的加密通信,包括对等体之间的加密内部群集通信以及加密的客户端流量。通过使用 SSL 证书,etcd 可以实现互联网的通信安全,防止窃听、篡改和冒充风险。

本节我们基于etcd来实现前文中设计的接口。

etcd的安装与启动

要开始使用 etcd,您可以访问 GitHub 上的 etcd 仓库,具体网址为 https://github.com/etcd-io/etcd/releases/,从中下载与您的操作系统相匹配的二进制文件。下载完成后,您可以通过执行 etcd 的二进制文件来启动一个单节点的 etcd 服务器。在本节中,我们将重点介绍如何利用 etcd 实现服务发现客户端,因此,对于演示目的而言,单节点的 etcd 服务器已经足够。但请注意,在生产环境中,为了确保高可用性和数据一致性,强烈推荐使用 etcd 的集群部署。

为了验证您的 etcd 服务器是否已经正常运行,您可以使用 etcdctl 这个命令行工具来进行测试。首先,打开一个新的终端窗口,然后输入 etcdctl put greeting "Hello, etcd" 命令,如果一切正常,您应该会看到 "OK" 作为返回结果。接下来,通过执行 etcdctl get greeting 命令,您应该能够看到预期的输出 "Hello, etcd"。这表明您的 etcd 服务器已经成功启动,并且可以正常处理键值存储的读写操作。

包结构设计

作为我们的首个服务注册中心实现,在开始编码之前,让我们先规划一下包结构的设计。这将帮助我们组织代码,确保项目的可维护性和可扩展性。以下是我们为etcd服务注册中心设计的包结构概览:

├── client
│  └── client.go
├── discovery
│  ├── discovery.go
│  └── etcd
│    ├── registry.go
│    └── registry_test.go
├── go.mod
└── loadbalancer
└── loadbalancer.go
  • client: 服务注册客户端,提供给应用层使用
  • discovery: 服务注册接口声明

    • etcd: 服务注册的etcd实现
  • loadbalancer: 负载均衡接口声明

编写接口

根据之前的设计,我们直接在discovery/discovery.go编写服务节点和注册中心的声明。

在工程实践中,注册中心会本地缓存服务的实例列表,避免频繁请求注册中心导致注册中心过载影响服务发现。下列代码使用的sync.Map来保证并发安全。

discovery/discovery.go

package discovery

import (
  "context"
  "net"
)

// ServiceNode 服务节点
type ServiceNode struct {
  ServiceName string
  IP     net.IP
  Port    int
  Tags    map[string]string
}

// NodeRegistry 服务节点注册中心
type NodeRegistry interface {
  // GetNodes 获取服务节点
  GetNodes(ctx context.Context, serviceName string, tags map[string]string) ([]*ServiceNode, error)
  // Register 注册服务节点
  Register(ctx context.Context, node *ServiceNode) error
  // Unregister 注销服务节点
  Unregister(ctx context.Context, node *ServiceNode) error
}

// MatchTags 匹配标签
func MatchTags(nodeTags, queryTags map[string]string) bool {
  for name, value := range queryTags {
    if nodeTags[name] != value {
      return false
    }
  }
  return true
}

编写实现

实现类的流程基本是类似的,在GetNodes方法中优先读取本地缓存,如果不存在,则远程请求注册中心,并监听节点变更事件,实现本地节点的实时变更。在Register方法中注册节点并实现心跳机制,在etcd中,心跳机制是基于租约实现的,只要定期续约,节点就会存活。

discovery/etcd/registry.go

package etcd

import (
  "context"
  "fmt"
  json "github.com/json-iterator/go"
  "github.com/xialeistudio/go-service-discovery/discovery"
  "go.etcd.io/etcd/client/v3"
  "log"
  "strconv"
  "sync"
  "time"
)

var (
  // DialTimeout 默认的连接超时时间
  DialTimeout = 5 * time.Second
  // LeaseTTL 默认的租约时间
  LeaseTTL = 10 * time.Second
)

type registry struct {
  nodeListMap *sync.Map // <serviceName, <nodeKey, *ServiceNode>>
  client *clientv3.Client
  watcher clientv3.Watcher
  kv   clientv3.KV
}

func NewRegistry(endpoints []string) (discovery.NodeRegistry, error) {
  config := clientv3.Config{
    Endpoints:  endpoints,
    DialTimeout: DialTimeout,
  }
  client, err := clientv3.New(config)
  if err != nil {
    return nil, fmt.Errorf("failed to create etcd client: %v", err)

  }
  return &registry{
    nodeListMap: &sync.Map{},
    client:   client,
    kv:     clientv3.NewKV(client),
    watcher:   clientv3.NewWatcher(client),
  }, nil
}

func (r *registry) GetNodes(ctx context.Context, serviceName string, tags map[string]string) ([]*discovery.ServiceNode, error) {
  // 检查本地缓存是否有服务节点
  nodes, exists := r.nodeListMap.Load(serviceName)
  if !exists {
    var err error
    // 本地缓存为空,从 etcd 拉取
    nodes, err = r.pullNodes(ctx, serviceName)
    if err != nil {
      return nil, err
    }
    // 缓存到本地
    r.nodeListMap.Store(serviceName, nodes)
    // 启动协程监听节点变化
    go r.watchNodes(ctx, serviceName)
  }

  // 根据标签过滤服务节点
  var filteredNodes []*discovery.ServiceNode
  nodes.(*sync.Map).Range(func(key, value interface{}) bool {
    node := value.(*discovery.ServiceNode)
    if discovery.MatchTags(node.Tags, tags) {
      filteredNodes = append(filteredNodes, node)
    }
    return true
  })

  return filteredNodes, nil
}

func (r *registry) Register(ctx context.Context, node *discovery.ServiceNode) error {
  value, err := json.MarshalToString(node)
  if err != nil {
    return fmt.Errorf("failed to marshal service node: %v", err)
  }
  // 创建租约
  lease, err := r.client.Grant(ctx, int64(LeaseTTL.Seconds()))
  if err != nil {
    return err
  }
// 将服务节点信息写入etcd
  key := makeNodeKey(node)
  _, err = r.kv.Put(ctx, key, value, clientv3.WithLease(lease.ID))
  if err != nil {
    return fmt.Errorf("failed to put key to etcd: %v", err)
  }
  // 续租
  go r.keepLeaseAlive(lease.ID, node)
  return nil
}

func (r *registry) Unregister(ctx context.Context, node *discovery.ServiceNode) error {
  key := makeNodeKey(node)
  _, err := r.kv.Delete(ctx, key)
  if err != nil {
    return fmt.Errorf("failed to delete key from etcd: %v", err)
  }
  r.removeNode(node)
  return nil
}

func makeNodeKey(node *discovery.ServiceNode) string {
  return node.ServiceName + "/" + node.IP.String() + ":" + strconv.Itoa(node.Port)
}

// 续租
func (r *registry) keepLeaseAlive(leaseId clientv3.LeaseID, node *discovery.ServiceNode) {
  ticker := time.NewTicker(LeaseTTL / 2)
  defer ticker.Stop()
  for range ticker.C {
    func() {
      _, err := r.client.KeepAliveOnce(context.Background(), leaseId)
      if err != nil {
       r.removeNode(node)
       return
      }
    }()
  }
}

func (r *registry) removeNode(node *discovery.ServiceNode) {
  r.nodeListMap.Range(func(key, value interface{}) bool {
​    nodes := value.(*sync.Map)
​    nodes.Delete(makeNodeKey(node))
​    return true
  })
}

func (r *registry) pullNodes(ctx context.Context, serviceName string) (*sync.Map, error) {
  resp, err := r.kv.Get(ctx, serviceName+"/", clientv3.WithPrefix())
  if err != nil {
​    return nil, fmt.Errorf("failed to get nodeListMap from etcd: %v", err)
  }
  var nodes sync.Map
  for _, kv := range resp.Kvs {
​    node := &discovery.ServiceNode{}
​    if err := json.Unmarshal(kv.Value, node); err != nil {
​      return nil, fmt.Errorf("failed to unmarshal node data: %v", err)
​    }
​    nodes.Store(string(kv.Key), node)
  }
  return &nodes, nil
}

 

func (r *registry) watchNodes(ctx context.Context, serviceName string) {
  watchChan := r.watcher.Watch(ctx, serviceName+"/", clientv3.WithPrefix())
  for wResp := range watchChan {
​    for _, ev := range wResp.Events {
​      switch ev.Type {
​      case clientv3.EventTypePut:
​       // 新增或更新服务节点
​       node := &discovery.ServiceNode{}
​       if err := json.Unmarshal(ev.Kv.Value, node); err != nil {
​         log.Printf("failed to unmarshal node data: %v", err)
​         continue
​       }
​       r.nodeListMap.LoadOrStore(serviceName, &sync.Map{})
​       nodes, _ := r.nodeListMap.Load(serviceName)
​       nodes.(*sync.Map).Store(string(ev.Kv.Key), node)
​      case clientv3.EventTypeDelete:
​       // 删除服务节点
​       nodes, _ := r.nodeListMap.Load(serviceName)
​       nodes.(*sync.Map).Delete(string(ev.Kv.Key))
​      }
​    }
  }
}

registry结构体实现了 discovery.NodeRegistry 接口,用于管理微服务架构中的服务节点。结构体包含一个节点映射 nodes,用于缓存服务节点信息,以及 etcd 客户端 client,用于与 etcd 集群通信。

NewRegistry 函数用于初始化 etcd 客户端并创建 registry 实例。它接受 etcd 服务端点列表作为参数,并配置客户端以连接到 etcd 集群。

GetNodes 方法用于获取指定服务名称的服务节点列表。如果本地缓存中不存在该服务的节点信息,它会从 etcd 中拉取节点信息,并启动一个协程 watchNodes 来监听 etcd 中服务节点的变化。

Register 方法允许服务节点将自己注册到 etcd 中,并创建一个租约以保持其注册信息的有效性。它还启动一个协程 keepLeaseAlive 来续租,确保节点信息不会过期。

Unregister 方法用于从 etcd 中注销服务节点,并更新本地缓存。

keepLeaseAlive 方法负责定期续租,以保持服务节点在 etcd 中的注册信息不过期。

removeNode 方法用于从本地缓存中移除已注销的服务节点。

pullNodes 方法从 etcd 中拉取服务节点信息,并将其反序列化为 ServiceNode 对象列表。

watchNodes 方法监听 etcd 中服务节点的变化事件,并更新本地缓存。

单元测试编写

在微服务架构中,服务注册中心的稳定性和可靠性对于整个系统的运行至关重要。为了确保我们的 etcd 服务注册中心能够正确地处理服务节点的注册、查询和注销操作,我们编写了一系列单元测试来验证其功能。这些测试不仅涵盖了单个服务节点的注册和注销,还包括了多节点多标签的场景,确保服务发现逻辑在各种情况下都能正常工作。

discovery/etcd/registry_test.go

package etcd

import (
  "context"
  "github.com/stretchr/testify/assert"
  "go-service-discovery/discovery"
  "net"
  "testing"
  "time"
)

func TestEtcdRegistry(t *testing.T) {
  a := assert.New(t)
  r, err := NewRegistry([]string{"localhost:2379"})
  a.Nil(err)
  t.Run("Register single node", func(t *testing.T) {
​    node := &discovery.ServiceNode{
​      ServiceName: "test",
​      IP:     net.IPv4(127, 0, 0, 1),
​      Port:    8484,
​      Tags: map[string]string{
​       "version": "1.0",
​      },
​    }
​    // 注册节点
​    err = r.Register(context.Background(), node)
​    a.Nil(err)
​    time.Sleep(time.Millisecond * 100)
​    // 获取节点
​    nodes, err := r.GetNodes(context.Background(), "test", map[string]string{
​      "version": "1.0",
​    })
​    a.Nil(err)
​    a.Len(nodes, 1)
​    a.Equal(node, nodes[0])
​    // 注销节点
​    err = r.Unregister(context.Background(), node)
​    a.Nil(err)

​    time.Sleep(time.Millisecond * 100)
​    // 获取节点
​    nodes, err = r.GetNodes(context.Background(), "test", map[string]string{
​      "version": "1.0",
​    })
​    a.Nil(err)
​    a.Len(nodes, 0)
  })
  t.Run("Register multi nodes with multi tags", func(t *testing.T) {
​    node1 := &discovery.ServiceNode{
​      ServiceName: "test",
​      IP:     net.IPv4(127, 0, 0, 1),
​      Port:    8484,
​      Tags: map[string]string{
​       "version": "1.0",
​       "region": "cn",
​      },
​    }
​    node2 := &discovery.ServiceNode{
​      ServiceName: "test",
​      IP:     net.IPv4(127, 0, 0, 2),
​      Port:    8484,
​      Tags: map[string]string{
​       "version": "1.0",
​       "region": "us",
​      },
​    }
​    // 注册节点
​    err = r.Register(context.Background(), node1)
​    a.Nil(err)
​    err = r.Register(context.Background(), node2)
​    a.Nil(err)
​    time.Sleep(time.Millisecond * 100)
​    // 获取节点
​    nodes, err := r.GetNodes(context.Background(), "test", map[string]string{
​      "version": "1.0",
​      "region": "cn",
​    })
​    a.Nil(err)
​    a.Len(nodes, 1)
​    a.Equal(node1, nodes[0])
​    // 获取节点
​    nodes, err = r.GetNodes(context.Background(), "test", map[string]string{
​      "version": "1.0",
​      "region": "us",
​    })
​    a.Nil(err)
​    a.Len(nodes, 1)
​    a.Equal(node2, nodes[0])
​    // 获取节点
​    nodes, err = r.GetNodes(context.Background(), "test", nil)
​    a.Nil(err)
​    a.Len(nodes, 2)
​    // 注销节点
​    err = r.Unregister(context.Background(), node1)
​    a.Nil(err)
​    err = r.Unregister(context.Background(), node2)
​    a.Nil(err)
​    time.Sleep(time.Millisecond * 100)
​    // 获取节点
​    nodes, err = r.GetNodes(context.Background(), "test", map[string]string{
​      "version": "1.0",
​    })
​    a.Nil(err)
​    a.Len(nodes, 0)
  })
}

需要说明的是,测试代码中包含了time.Sleep操作来阻塞协程,这是因为节点监听是在另外的协程实现的,是异步过程,需要阻塞主协程来让异步代码执行完毕,更新本地节点缓存后才能通过测试。

本节详细介绍了基于 etcd 的服务注册中心实现,涵盖了从客户端初始化、服务节点的注册与注销,到服务节点的查询和监听等功能。通过 etcd 强大的分布式键值存储能力,我们构建了一个高可用和一致性的服务发现机制。测试部分验证了服务注册中心在处理单节点和多节点场景下的正确性和稳定性,确保了服务发现过程中数据的准确性和实时性通过本节的学习,我们不仅理解了 etcd 在服务发现中的关键作用,还掌握了如何在实际项目中应用 etcd 来实现服务注册与发现。

Consul注册中心

Consul 是一个开源的服务网格解决方案,提供服务发现、配置和分段功能。它结合了分布式系统的几个关键组件,包括服务发现、健康检查、键值存储和多数据中心支持。Consul 的设计目标是提供一种简单、灵活且可靠的方法来构建和运行分布式系统。

Consul 的核心特性包括一个分布式服务目录,它允许服务实例注册自己并提供元数据,其他服务可以通过查询这个目录来发现它们。它还提供了健康检查功能,可以确保只有健康的服务实例被客户端发现。此外,Consul 提供了一个内置的 DNS 和 HTTP API 接口,用于服务发现,使得服务之间的通信变得简单。

Consul 还支持分布式一致性配置,允许团队存储和同步配置信息。它的分段特性允许服务在不同的网络环境中进行隔离,这对于微服务架构中的蓝绿部署和金丝雀发布非常有用。

Consul 的架构设计支持高可用性,它使用 Raft 算法来保证数据的一致性和容错性。Consul 通常部署为一个集群模式,集群中的每个节点都参与数据的复制和一致性保证。这种设计使得 Consul 成为构建现代云原生应用的有力工具,尤其是在需要跨多个数据中心或云环境进行服务发现和配置管理的场景中。

Consul 的安装和启动

要开始使用 Consul 作为服务注册中心,首先需要安装 Consul。您可以访问 HashiCorp 官方开发者网站 https://developer.hashicorp.com/consul/install 下载与您的操作系统相匹配的 Consul 二进制文件。

安装完成后,打开终端并运行以下命令来启动 Consul 的开发服务器模式。这将启动一个单节点的 Consul 服务器,适用于开发和测试环境。

consul agent -dev -bind 127.0.0.1

接下来,在新的终端会话中,您可以使用以下命令来设置一个键值对。此命令将存储一个简单的字符串值,并返回操作结果。如果操作成功,您应该看到 "true" 作为输出。

curl \
  --request PUT \
  --data 'hello consul' \
  http://127.0.0.1:8500/v1/kv/foo

最后,为了验证键值对是否已正确设置,您可以执行以下命令来检索该键的值。预期的输出将显示键 "foo" 及其关联的值,值将以 Base64 编码的形式展示。

curl http://127.0.0.1:8500/v1/kv/foo

预期的输出结果如下,展示了键 "foo" 的详细信息,包括其值 "aGVsbG8gY29uc3Vs"(Base64 编码的 "hello consul"):

[
  {
    "LockIndex": 0,
    "Key": "foo",
    "Flags": 0,
    "Value": "aGVsbG8gY29uc3Vs",
    "CreateIndex": 24,
    "ModifyIndex": 24
  }
]

包结构设计

我们直接在上一节的基础上添加 consul 包即可。

├── client
│   └── client.go
├── discovery
│   ├── discovery.go
│   └── etcd
│       ├── registry.go
│       └── registry_test.go
│   └── consul
│       ├── registry.go
│       └── registry_test.go
├── go.mod
└── loadbalancer
    └── loadbalancer.go

编写实现

Consul 服务发现客户端的逻辑和 etcd 类似,都是提供获取节点、注册服务实例、取消注册服务实例。不同的是 Consul 的 watch 机制和 etcd 不同,而且 Consul 支持健康检查。

discovery/consul/registry.go

package consul

import (
    "context"
    "fmt"
    "log"
    "net"
    "sync"

    capi "github.com/hashicorp/consul/api"
    "github.com/hashicorp/consul/api/watch"
    "github.com/xialeistudio/go-service-discovery/discovery"
)

type registry struct {
    nodeListMap *sync.Map // <serviceName, <nodeKey, *ServiceNode>>
    config      *capi.Config
    client      *capi.Client
}

func (r *registry) GetNodes(ctx context.Context, serviceName string, tags map[string]string) ([]*discovery.ServiceNode, error) {
    nodes, exists := r.nodeListMap.Load(serviceName)
    if !exists {
        var err error
        // 本地缓存为空,从 consul 拉取
        nodes, err = r.pullNodes(ctx, serviceName)
        if err != nil {
            return nil, err
        }
        // 缓存到本地
        r.nodeListMap.Store(serviceName, nodes)
        // 启动 watch
        go r.watchNodes(serviceName)
    }
    // 按标签过滤节点
    var filteredNodes []*discovery.ServiceNode
    nodes.(*sync.Map).Range(func(key, value interface{}) bool {
        node := value.(*discovery.ServiceNode)
        if discovery.MatchTags(node.Tags, tags) {
            filteredNodes = append(filteredNodes, node)
        }
        return true
    })
    return filteredNodes, nil
}

func (r *registry) Register(ctx context.Context, node *discovery.ServiceNode) error {
    service := &capi.AgentServiceRegistration{
        ID:      makeNodeKey(node),
        Name:    node.ServiceName,
        Address: node.IP.String(),
        Port:    node.Port,
        Meta:    node.Tags,
        Check: &capi.AgentServiceCheck{ // 健康检查
            TCP:                            fmt.Sprintf("%s:%d", node.IP.String(), node.Port),
            Timeout:                        "5s",
            Interval:                       "5s",
            DeregisterCriticalServiceAfter: "30s",
            Status:                         capi.HealthPassing,
        },
    }
    err := r.client.Agent().ServiceRegisterOpts(service, capi.ServiceRegisterOpts{}.WithContext(ctx))
    if err != nil {
        return fmt.Errorf("register service error: %w", err)
    }
    return nil
}

func (r *registry) Unregister(ctx context.Context, node *discovery.ServiceNode) error {
    opts := &capi.QueryOptions{}
    opts = opts.WithContext(ctx)
    err := r.client.Agent().ServiceDeregisterOpts(makeNodeKey(node), opts)
    if err != nil {
        return fmt.Errorf("unregister service error: %w", err)
    }
    return nil
}

func (r *registry) pullNodes(ctx context.Context, name string) (*sync.Map, error) {
    opts := &capi.QueryOptions{}
    opts = opts.WithContext(ctx)
    services, err := r.client.Agent().ServicesWithFilterOpts(fmt.Sprintf(`Service=="%s"`, name), opts)
    if err != nil {
        return nil, fmt.Errorf("pull services error: %w", err)
    }
    var results sync.Map
    for _, service := range services {
        node := &discovery.ServiceNode{
            ServiceName: name,
            IP:          net.ParseIP(service.Address),
            Port:        service.Port,
            Tags:        service.Meta,
        }
        results.Store(makeNodeKey(node), node)
    }
    return &results, nil
}

func (r *registry) watchNodes(name string) {
    params := map[string]any{
        "type":    "service",
        "service": name,
    }
    plan, _ := watch.Parse(params)
    plan.Handler = func(u uint64, i interface{}) {
        if i == nil {
            return
        }
        val, ok := i.([]*capi.ServiceEntry)
        if !ok {
            return
        }
        if len(val) == 0 {
            r.nodeListMap.Store(name, &sync.Map{})
            return
        }
        var (
            healthInstances sync.Map
        )
        for _, instance := range val {
            if instance.Service.Service != name {
                continue
            }
            if instance.Checks.AggregatedStatus() == capi.HealthPassing {
                node := &discovery.ServiceNode{
                    ServiceName: name,
                    IP:          net.ParseIP(instance.Service.Address),
                    Port:        instance.Service.Port,
                    Tags:        instance.Service.Meta,
                }
                healthInstances.Store(makeNodeKey(node), node)
            }
        }
        r.nodeListMap.Store(name, &healthInstances)
    }
    defer plan.Stop()
    if err := plan.Run(r.config.Address); err != nil {
        log.Printf("watch service %s error: %v", name, err)
    }
}

func NewRegistry(config *capi.Config) (discovery.NodeRegistry, error) {
    client, err := capi.NewClient(config)
    if err != nil {
        return nil, fmt.Errorf("new consul client error: %w", err)
    }
    return &registry{
        nodeListMap: new(sync.Map),
        client:      client,
        config:      config,
    }, nil
}

func makeNodeKey(node *discovery.ServiceNode) string {
    return fmt.Sprintf("%s/%s:%d", node.ServiceName, node.IP.String(), node.Port)
}

watch 机制允许客户端实时监听服务状态的变化。在上述代码中,watchNodes 函数实现了对 Consul 服务状态的监听,当服务注册或注销时,通过 handler 更新本地缓存,确保服务消费者能够获取到最新的服务节点列表。这种机制使得服务消费者能够及时响应服务提供者的变化,提高了系统的动态性和可靠性。

而健康检查确保只有健康的服务实例对消费者可见,上述代码使用 TCP 连接来进行健康检查。一旦服务实例出现问题,Consul 会自动将其标记为不健康,并从服务发现中移除,避免流量被路由到该实例。

单元测试编写

因为我们使用了面向接口的方式来设计注册中心,前面的 etcd 和当前的 Consul 都只是实现类,因此单元测试可以通用,唯一不同的是初始化 Registry 的代码。

discovery/consul/registry_test.go

package consul

import (
    "context"
    "net"
    "testing"
    "time"

    capi "github.com/hashicorp/consul/api"
    json "github.com/json-iterator/go"
    "github.com/stretchr/testify/assert"
    "github.com/xialeistudio/go-service-discovery/discovery"
)

func TestConsulRegistry(t *testing.T) {
    a := assert.New(t)
    // 初始化注册中心
    r, err := NewRegistry(&capi.Config{
        Address: "localhost:8500",
    })
    a.Nil(err)

    t.Run("Register single node", func(t *testing.T) {
        node := &discovery.ServiceNode{
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 1),
            Port:        8484,
            Tags: map[string]string{
                "version": "1.0",
            },
        }
        // 注册节点
        err = r.Register(context.Background(), node)
        a.Nil(err)
        time.Sleep(time.Millisecond * 100)
        // 获取节点
        nodes, err := r.GetNodes(context.Background(), "test", map[string]string{
            "version": "1.0",
        })
        a.Nil(err)
        a.Len(nodes, 1)
        a.Equal(node, nodes[0])
        // 注销节点
        err = r.Unregister(context.Background(), node)
        a.Nil(err)
        time.Sleep(time.Millisecond * 100)
        // 获取节点
        nodes, err = r.GetNodes(context.Background(), "test", map[string]string{
            "version": "1.0",
        })
        a.Nil(err)
        a.Len(nodes, 0)
    })

    t.Run("Register multi nodeListMap with multi tags", func(t *testing.T) {
        node1 := &discovery.ServiceNode{
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 1),
            Port:        8484,
            Tags: map[string]string{
                "version": "1.0",
                "region":  "cn",
            },
        }
        node2 := &discovery.ServiceNode{
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 2),
            Port:        8484,
            Tags: map[string]string{
                "version": "1.0",
                "region":  "us",
            },
        }
        // 注册节点
        err = r.Register(context.Background(), node1)
        a.Nil(err)
        err = r.Register(context.Background(), node2)
        a.Nil(err)
        time.Sleep(time.Millisecond * 100)
        // 获取节点
        nodes, err := r.GetNodes(context.Background(), "test", map[string]string{
            "version": "1.0",
            "region":  "cn",
        })
        a.Nil(err)
        a.Len(nodes, 1)
        str, _ := json.MarshalToString(nodes)
        t.Log(str)
        a.Equal(node1, nodes[0])
        // 获取节点
        nodes, err = r.GetNodes(context.Background(), "test", map[string]string{
            "version": "1.0",
            "region":  "us",
        })
        a.Nil(err)
        a.Len(nodes, 1)
        a.Equal(node2, nodes[0])
        // 获取节点
        nodes, err = r.GetNodes(context.Background(), "test", nil)
        a.Nil(err)
        a.Len(nodes, 2)
        // 注销节点
        err = r.Unregister(context.Background(), node1)
        a.Nil(err)
        err = r.Unregister(context.Background(), node2)
        a.Nil(err)
        time.Sleep(time.Millisecond * 100)
        // 获取节点
        nodes, err = r.GetNodes(context.Background(), "test", map[string]string{
            "version": "1.0",
        })
        a.Nil(err)
        a.Len(nodes, 0)
    })
}

在确保 Consul 服务端运行的情况下执行单元测试即可。

Consul 作为服务注册与发现的工具,在微服务架构中扮演着至关重要的角色。它通过提供一个中心化的服务注册表,使得服务实例能够相互发现并进行通信。服务注册过程中,每个服务实例将自己注册到 Consul,并关联健康检查以确保其可用性。Consul 的 watch 机制允许服务消费者实时监听服务状态的变化,从而动态更新本地服务节点列表。这种动态服务发现的能力,不仅提高了系统的灵活性和可扩展性,还增强了服务之间的解耦合。Consul 的这些特性共同确保了微服务架构的高效运行和稳定性,使其成为现代云原生应用的理想选择。

Zookeeper注册中心

ZooKeeper 是一个分布式协调服务,它为分布式应用提供一致性协调功能。作为一个开源的协调服务,ZooKeeper 提供了一系列的原语,使得开发者能够构建可靠的分布式同步服务。

ZooKeeper 的核心是它的状态机模型,它维护了一个具有层次结构的命名空间,类似于文件系统的树形结构。每个节点在这个命名空间中被称为 znode,它可以存储数据和状态信息。客户端可以通过创建、读取、更新和删除这些 znode 来实现服务的注册与发现。

在微服务架构中,ZooKeeper 常被用作服务注册中心,服务实例在启动时会在 ZooKeeper 中注册自己的信息,如 IP 地址和端口号。其他服务实例可以通过查询这些 znode 来发现可用的服务提供者。此外,ZooKeeper 还提供了 watch 机制,允许服务实例监听 znode 的变化,从而实现服务的动态发现和负载均衡。

ZooKeeper 的安装和启动

ZooKeeper 作为基于 Java 的分布式协调服务,其运行离不开 Java 环境的支持。因此,首先需要确保您的系统中安装了 JDK 或 JRE。您可以通过访问 https://www.java.com/zh-CN/download/help/download_options_zh-cn.html 下载与您的操作系统相匹配的 Java 版本,并进行安装。

安装完成后,您可以通过打开终端或命令提示符,输入以下命令来验证 Java 是否安装成功:

java -version

例如,笔者的系统中显示的输出如下:

openjdk version "17.0.9" 2023-10-17
OpenJDK Runtime Environment Homebrew (build 17.0.9+0)
OpenJDK 64-Bit Server VM Homebrew (build 17.0.9+0, mixed mode, sharing)

接下来,您可以访问 https://zookeeper.apache.org/releases.html 下载最新版本的 ZooKeeper,并按照提供的指南进行解压和配置。

启动 ZooKeeper 服务时,您可以在终端执行以下命令,以在前台模式运行 ZooKeeper,这样有助于实时查看服务的输出信息:

zkServer start-foreground

为了与 ZooKeeper 服务器建立连接,您可以在新的终端会话中执行以下命令:

zkCli

一旦连接成功,您可以通过执行 stat / 命令来查看根节点的状态。以下是笔者的示例输出:

[zk: localhost:2181(CONNECTED) 1] stat /
cZxid = 0x0
ctime = Thu Jan 01 08:00:00 CST 1970
mZxid = 0x0
mtime = Thu Jan 01 08:00:00 CST 1970
pZxid = 0x3a
cversion = 27
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 0
numChildren = 1

至此,我们已经成功安装并启动了 ZooKeeper 服务器,并且能够通过命令行界面与其交互。

编写实现

ZooKeeper 需要先建立父路径之后才能添加服务实例节点,因此需要先创建根路径 /services,在注册服务节点之前确保服务名称节点已经创建,最后创建服务实例节点即可。

我们使用的 watch 机制是接收到节点事件后尝试获取节点详情数据,如果获取不到则证明节点被删除,需要跳过循环,不能直接 return,否则会导致本地缓存无法更新。

discovery/zookeeper/registry.go

package zookeeper

import (
    "context"
    "errors"
    "fmt"
    "sync"
    "time"

    "github.com/go-zookeeper/zk"
    json "github.com/json-iterator/go"
    "github.com/xialeistudio/go-service-discovery/discovery"
)

var (
    // DialTimeout 默认的连接超时时间
    DialTimeout = 5 * time.Second
    // BasePath 服务注册的根路径
    BasePath = "/services"
)

type registry struct {
    nodeListMap *sync.Map // <serviceName, <nodeKey, *ServiceNode>>
    conn        *zk.Conn
}

func (r *registry) GetNodes(ctx context.Context, serviceName string, tags map[string]string) ([]*discovery.ServiceNode, error) {
    nodes, exists := r.nodeListMap.Load(serviceName)
    if !exists {
        var err error
        // 本地缓存为空,从 zookeeper 拉取
        nodes, err = r.pullNodes(ctx, serviceName)
        if err != nil {
            return nil, err
        }
        // 缓存到本地
        r.nodeListMap.Store(serviceName, nodes)
        // 启动 watch
        go r.watchNodes(serviceName)
    }
    // 按标签过滤节点
    var filteredNodes []*discovery.ServiceNode
    nodes.(*sync.Map).Range(func(key, value interface{}) bool {
        node := value.(*discovery.ServiceNode)
        if discovery.MatchTags(node.Tags, tags) {
            filteredNodes = append(filteredNodes, node)
        }
        return true
    })
    return filteredNodes, nil
}

func (r *registry) Register(_ context.Context, node *discovery.ServiceNode) error {
    if err := r.ensureServiceNode(node.ServiceName); err != nil {
        return err
    }
    data, err := json.Marshal(node)
    if err != nil {
        return fmt.Errorf("failed to marshal node: %v", err)
    }
    nodePath := fmt.Sprintf("%s/%s", makeServicePath(node.ServiceName), makeNodeKey(node))
    _, err = r.conn.Create(nodePath, data, zk.FlagEphemeral, zk.WorldACL(zk.PermAll))
    if err != nil {
        return fmt.Errorf("failed to create node %v: %v", node, err)
    }
    return nil
}

func makeNodeKey(node *discovery.ServiceNode) string {
    return fmt.Sprintf("%s:%d", node.IP.String(), node.Port)
}

func (r *registry) Unregister(_ context.Context, node *discovery.ServiceNode) error {
    nodePath := fmt.Sprintf("%s/%s", makeServicePath(node.ServiceName), makeNodeKey(node))
    err := r.conn.Delete(nodePath, -1)
    if err != nil {
        return fmt.Errorf("failed to delete node %v: %v", node, err)
    }
    return nil
}

func (r *registry) ensureServiceNode(name string) error {
    nodePath := makeServicePath(name)
    exists, _, err := r.conn.Exists(nodePath)
    if err != nil {
        return fmt.Errorf("failed to check if node exists %v: %v", nodePath, err)
    }
    if !exists {
        _, err = r.conn.Create(nodePath, nil, zk.FlagPersistent, zk.WorldACL(zk.PermAll))
        if err != nil {
            return fmt.Errorf("failed to create node %v: %v", nodePath, err)
        }
    }
    return nil
}

func (r *registry) pullNodes(_ context.Context, serviceName string) (*sync.Map, error) {
    var nodes sync.Map
    nodePath := makeServicePath(serviceName)
    children, _, err := r.conn.Children(nodePath)
    if err != nil {
        return nil, fmt.Errorf("failed to get children of node %v: %v", nodePath, err)
    }
    for _, child := range children {
        data, _, err := r.conn.Get(fmt.Sprintf("%s/%s", nodePath, child))
        if err != nil {
            return nil, fmt.Errorf("failed to get node %v: %v", child, err)
        }
        var node discovery.ServiceNode
        if err := json.Unmarshal(data, &node); err != nil {
            return nil, fmt.Errorf("failed to unmarshal node %v: %v", child, err)
        }
        nodes.Store(child, &node)
    }
    return &nodes, nil
}

func makeServicePath(serviceName string) string {
    return fmt.Sprintf("%s/%s", BasePath, serviceName)
}

func (r *registry) watchNodes(name string) {
    nodePath := makeServicePath(name)
    for {
        children, _, ch, err := r.conn.ChildrenW(nodePath)
        if err != nil {
            fmt.Printf("failed to watch children of node %s: %v\n", nodePath, err)
            return
        }
        var nodes sync.Map
        var count int
        for _, child := range children {
            data, _, err := r.conn.Get(fmt.Sprintf("%s/%s", nodePath, child))
            if err != nil { // 如果是节点解除注册触发的事件,此处无法获取到节点数据,直接跳过即可,不能报错,否则无法更新本地节点
                continue
            }
            var node discovery.ServiceNode
            if err := json.Unmarshal(data, &node); err != nil {
                fmt.Printf("failed to unmarshal node %s: %v\n", child, err)
                return
            }
            count++
            nodes.Store(child, &node)
        }
        r.nodeListMap.Store(name, &nodes)
        select {
        case <-ch:
        }
    }
}

func NewRegistry(servers []string) (discovery.NodeRegistry, error) {
    conn, _, err := zk.Connect(servers, DialTimeout)
    if err != nil {
        return nil, fmt.Errorf("failed to connect to zookeeper: %v", err)
    }
    // create base path
    _, err = conn.Create(BasePath, nil, 0, zk.WorldACL(zk.PermAll))
    if err != nil && !errors.Is(err, zk.ErrNodeExists) {
        return nil, fmt.Errorf("failed to create base path %v: %v", BasePath, err)
    }
    return &registry{
        nodeListMap: &sync.Map{},
        conn:        conn,
    }, nil
}

在 ZooKeeper 中,只有永久节点下面可以创建临时节点,因此服务名称节点是永久性的,而服务实例节点是临时的。当注册服务的节点与 ZooKeeper 服务器的连接断开时,临时节点会自动被删除。

单元测试

单元测试代码与 etcd 和 consul 一致,唯一的区别是初始化 Registry 的代码:

discovery/zookeeper/registry_test.go

r, err := NewRegistry([]string{"localhost:2181"})
a.Nil(err)

ZooKeeper 是一个高性能的分布式协调服务,它为构建大型分布式系统提供了关键的协调功能。作为一个服务注册中心,ZooKeeper 能够存储服务实例的信息,并允许服务消费者查询和订阅这些信息。它的树形结构使得服务的注册和发现变得直观和灵活。ZooKeeper 的临时节点和 watch 机制进一步增强了服务注册中心的能力,确保了服务实例的动态管理和自动更新。这些特性使得 ZooKeeper 成为微服务架构中服务发现和协调的理想选择,尤其是在需要强一致性和高可靠性的场景中。

负载均衡

在微服务架构中,服务调用的负载均衡是一个至关重要的环节,它决定了如何将大量的服务请求有效地分配到多个服务实例上。良好的负载均衡策略可以显著提高系统的吞吐量、响应速度和整体的可靠性。本节将深入探讨负载均衡的原理和实现,介绍几种常见的负载均衡算法,包括轮询、随机、加权轮询等,以及它们在实际应用中的适用场景和实现方式。

原理

负载均衡本质上是从一组服务节点中选择一个节点来处理客户端的请求。这个过程通过算法决定哪个节点应该接收特定的请求,目的是确保所有节点的负载尽可能均衡,从而提高系统的整体性能和可靠性。负载均衡器根据预设的策略,如轮询、随机选择或最少连接数,智能地分发请求,避免任何单一节点因过载而影响服务的稳定性。

轮询实现

轮询(Round Robin)负载均衡策略通过循环遍历服务节点列表,依次将新的请求分配给每个可用的服务实例。这种顺序分配方法简单而公平,确保了所有节点在长时间内接收到的请求数量大致相等,有效地平衡了系统负载,提高了资源利用率。轮询策略尤其适合所有服务节点性能相近的场景。

loadbalancer/round_robin.go

package loadbalancer

import (
    "github.com/xialeistudio/go-service-discovery/discovery"
)

type roundRobin struct {
    index int
}

func (r *roundRobin) Select(nodes []*discovery.ServiceNode) *discovery.ServiceNode {
    if len(nodes) == 0 {
        return nil
    }
    node := nodes[r.index]
    r.index = (r.index + 1) % len(nodes)
    return node
}

func NewRoundRobin() LoadBalancer {
    return &roundRobin{}
}

单元测试

loadbalancer/round_robin_test.go

package loadbalancer

import (
    "net"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/xialeistudio/go-service-discovery/discovery"
)

func Test_roundRobin_Select(t *testing.T) {
    a := assert.New(t)
    nodes := []*discovery.ServiceNode{
        {
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 1),
            Port:        8484,
            Tags:        map[string]string{},
        },
        {
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 2),
            Port:        8484,
            Tags:        map[string]string{},
        },
        {
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 3),
            Port:        8484,
            Tags:        map[string]string{},
        },
    }

    lb := NewRoundRobin()
    node := lb.Select(nodes)
    a.Equal(nodes[0], node)

    node = lb.Select(nodes)
    a.Equal(nodes[1], node)
}

加权轮询实现

加权轮询根据服务节点的权重来决定请求的分配。每个服务节点被赋予一个权重值,权重高的节点将更频繁地接收到请求。这种方法考虑了节点的不同处理能力,使得处理能力强的节点可以承担更多的流量,从而优化资源利用率并提高系统的整体性能。加权轮询适用于服务节点性能不均或有特定性能要求的场景。

loadbalancer/weighted_round_robin.go

package loadbalancer

import (
    "sync"

    "github.com/spf13/cast"
    "github.com/xialeistudio/go-service-discovery/discovery"
)

type weightedRoundRobin struct {
    mu            sync.Mutex
    index         int
    currentWeight int
    totalWeight   int
    nodes         []*discovery.ServiceNode
}

func (w *weightedRoundRobin) Select(nodes []*discovery.ServiceNode) *discovery.ServiceNode {
    w.mu.Lock()
    defer w.mu.Unlock()

    if len(nodes) == 0 {
        return nil
    }

    // 节点变动时重新计算权重
    if !equalNodes(w.nodes, nodes) {
        w.nodes = nodes
        w.totalWeight = 0
        for _, node := range nodes {
            w.totalWeight += cast.ToInt(node.Tags["weight"])
        }
    }

    for {
        w.index = (w.index + 1) % len(nodes)
        if w.index == 0 {
            w.currentWeight -= gcd(nodes)
            if w.currentWeight <= 0 {
                w.currentWeight = w.totalWeight
                if w.currentWeight == 0 {
                    return nil
                }
            }
        }

        if cast.ToInt(nodes[w.index].Tags["weight"]) >= w.currentWeight {
            return nodes[w.index]
        }
    }
}

func equalNodes(a, b []*discovery.ServiceNode) bool {
    if len(a) != len(b) {
        return false
    }
    for i := range a {
        if a[i] != b[i] {
            return false
        }
    }
    return true
}

func gcd(nodes []*discovery.ServiceNode) int {
    gcdValue := cast.ToInt(nodes[0].Tags["weight"])
    for _, node := range nodes {
        gcdValue = gcdTwo(gcdValue, cast.ToInt(node.Tags["weight"]))
    }
    return gcdValue
}

func gcdTwo(a, b int) int {
    if b == 0 {
        return a
    }
    return gcdTwo(b, a%b)
}

func NewWeightedRoundRobin() LoadBalancer {
    return &weightedRoundRobin{}
}

单元测试

单元测试构造节点时需要添加 weight 到节点标签。

loadbalancer/weighted_round_robin_test.go

package loadbalancer

import (
    "net"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/xialeistudio/go-service-discovery/discovery"
)

func Test_weightedRoundRobin_Select(t *testing.T) {
    a := assert.New(t)
    nodes := []*discovery.ServiceNode{
        {
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 1),
            Port:        8484,
            Tags: map[string]string{
                "weight": "90",
            },
        },
        {
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 2),
            Port:        8484,
            Tags: map[string]string{
                "weight": "50",
            },
        },
        {
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 3),
            Port:        8484,
            Tags: map[string]string{
                "weight": "80",
            },
        },
    }

    lb := NewWeightedRoundRobin()
    node := lb.Select(nodes)
    a.Equal(nodes[0], node)

    node = lb.Select(nodes)
    a.Equal(nodes[0], node)

    node = lb.Select(nodes)
    a.Equal(nodes[2], node)
}

随机实现

随机通过随机选择服务节点来处理请求。这种方法不考虑节点的负载或性能差异,而是简单地从所有可用节点中随机挑选一个来响应请求。随机策略简单易实现,适用于服务节点性能相近且无明显差异的场景,可以提供良好的负载分散,但可能不如其他策略那样精确地平衡负载。

loadbalancer/random.go

package loadbalancer

import (
    "math/rand"
    "time"

    "github.com/xialeistudio/go-service-discovery/discovery"
)

type random struct {
    r *rand.Rand
}

func (r random) Select(nodes []*discovery.ServiceNode) *discovery.ServiceNode {
    if len(nodes) == 0 {
        return nil
    }
    index := r.r.Intn(len(nodes))
    return nodes[index]
}

func NewRandom() LoadBalancer {
    return &random{
        r: rand.New(rand.NewSource(time.Now().Unix())),
    }
}

r 是随机数发生器,通过 NewRandom 构造时填充了时间戳作为随机数种子,保证安全性。在单元测试时使用固定的随机数种子,保证结果的可控性。

单元测试

loadbalancer/random_test.go

package loadbalancer

import (
    "net"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/xialeistudio/go-service-discovery/discovery"
    "math/rand"
)

func Test_random_Select(t *testing.T) {
    a := assert.New(t)
    r := rand.New(rand.NewSource(1))
    lb := random{r: r}
    nodes := []*discovery.ServiceNode{
        {
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 1),
            Port:        8484,
            Tags:        map[string]string{},
        },
        {
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 2),
            Port:        8484,
            Tags:        map[string]string{},
        },
        {
            ServiceName: "test",
            IP:          net.IPv4(127, 0, 0, 3),
            Port:        8484,
            Tags:        map[string]string{},
        },
    }

    node := lb.Select(nodes)
    a.Equal(nodes[2], node)

    node = lb.Select(nodes)
    a.Equal(nodes[0], node)

    node = lb.Select(nodes)
    a.Equal(nodes[2], node)
}

小结

在本文中,我们全面探讨了微服务架构中服务发现的设计与实现,特别关注了三种主流的服务注册中心:etcd、Consul和ZooKeeper。我们详细分析了每种技术的工作原理、特点以及它们在服务发现中的角色。

对于etcd,我们讨论了其基于Raft算法的强一致性特性,以及如何利用其丰富的客户端库来实现服务的注册与发现。Consul的章节则展示了其开箱即用的特性,包括健康检查和HTTP API接口。最后,ZooKeeper的实现部分强调了其树形结构和watch机制,这些都是构建高效服务注册中心的关键。

此外,我们还设计了一个通用的NodeRegistry接口,它定义了服务发现客户端必须实现的方法,如获取服务节点列表、注册和注销服务节点。这个接口的实现为服务发现提供了一个统一的抽象,使得服务消费者可以无缝地与不同的服务注册中心交互。

总的来说,本文为读者提供了一个关于服务发现的全面视角,包括理论基础、关键技术的深入分析以及实际的代码实现。通过学习,读者应该能够理解服务发现的重要性,掌握不同服务注册中心的使用方法,并能够根据自己的需求选择合适的技术来构建服务发现机制。


xialeistudio
21.5k 声望5k 粉丝