在Go中建立一个服务注册中心教程

86 阅读7分钟

想在Go中建立一个有多个运行部分的应用程序?假设你有一些服务器,需要在运行时做一些不同的事情,比如执行一些后台工作,更新缓存,处理一些请求,暴露一个REST API,执行对其他API的外向请求,所有这些都不会阻塞主线程--你该怎么做?通常情况下,这是一个创建微服务架构的好任务,你有多个应用程序通过一些网络服务网相互交谈,每个容器都在一些漂亮的docker环境中,通过Kubernetes或docker-compose等东西进行协调。

然而,有时你只是想要一个直接的应用程序,它可以做所有的事情!这是一个很好的例子。一个很好的例子是区块链节点,如比特币或以太坊节点,它需要在运行时做一堆事情,包括:

  • Syncing the blockchain
  • 暴露一个RPC端点
  • 挖掘区块,给矿工相应的奖励
  • 倾听p2p连接,处理对等人的生命周期
  • 维护一个开放的数据库连接到一些持久的键值存储,如Level-DB

上面的一些项目是相互依赖的,当我为节点启动一个单一的进程时,它们都应该运行。我们如何在Go中实现这样的东西?这就是依赖注入的一个完美的用例。在这篇博文中,我们要看一个简单的模式来完成这个任务。

首先,我们的运行时基本上是一系列的服务,每个服务都在做一系列的事情,要求或发送彼此之间的数据,并可能有错误或关键故障,我们应该很容易地从鸟瞰图中意识到。我们要理想地声明在启动进程时应该运行的服务,并且应该有一种方法在服务死亡时优雅地停止它们。然后我们可以定义一个名为Service 的接口,让我们可以:

  1. 启动进程
  2. 停止该进程
  3. 检查进程的当前状态

根据我们的定义,任何符合上述标准的东西都是一个服务!我们将在下面看到为什么这很有帮助:

type Service interface {
	// Start spawns the main process done by the service.
	Start()
	// Stop terminates all processes belonging to the service,
	// blocking until they are all terminated.
	Stop() error
	// Returns error if the service is not considered healthy.
	Status() error
}

接下来,我们要定义一个实际的结构,按照服务的特定类型来跟踪它们。我们按照服务的类型保存一个地图,但我们保存这些类型的有序列表,因为Go中的地图没有固定的顺序。对我们来说,定义一个服务的顺序是很重要的,因为服务经常依赖于其他的服务,而这些服务应该被初始化:

// ServiceRegistry provides a useful pattern for managing services.
// It allows for ease of dependency management and ensures services
// dependent on others use the same references in memory.
type ServiceRegistry struct {
	services     map[reflect.Type]Service // map of types to services.
	serviceTypes []reflect.Type           // keep an ordered slice of registered service types.
}

// NewServiceRegistry starts a registry instance for convenience
func NewServiceRegistry() *ServiceRegistry {
	return &ServiceRegistry{
		services: make(map[reflect.Type]Service),
	}
}

接下来,我们希望能够以特定的顺序将服务注册到我们的注册表中。如果一个服务不存在于注册表中,我们就把它添加到地图中,同时也添加到我们有序的注册服务类型列表中:

// RegisterService appends a service constructor function to the service
// registry.
func (s *ServiceRegistry) RegisterService(service Service) error {
	kind := reflect.TypeOf(service)
	if _, exists := s.services[kind]; exists {
		return fmt.Errorf("service already exists: %v", kind)
	}
	s.services[kind] = service
	s.serviceTypes = append(s.serviceTypes, kind)
	return nil
}

接下来,我们希望能够按照注册时指定的顺序实际启动所有的服务。让我们看一下:

// StartAll initialized each service in order of registration.
func (s *ServiceRegistry) StartAll() {
	log.Infof("Starting %d services: %v", len(s.serviceTypes), s.serviceTypes)
	for _, kind := range s.serviceTypes {
		log.Debugf("Starting service type %v", kind)
		go s.services[kind].Start()
	}
}

我们在一个goroutine 中启动每个服务,这样它就不会根据其指定的.Start() 方法阻塞主线程。当我们希望优雅地停止一切时,我们按照注册时的相反顺序调用每个服务的.Stop() 函数,沿途检查错误:

// StopAll ends every service in reverse order of registration, logging a
// panic if any of them fail to stop.
func (s *ServiceRegistry) StopAll() {
	for i := len(s.serviceTypes) - 1; i >= 0; i-- {
		kind := s.serviceTypes[i]
		service := s.services[kind]
		if err := service.Stop(); err != nil {
			log.Panicf("Could not stop the following service: %v, %v", kind, err)
		}
	}
}

如何使用

现在我们有了一个很酷的方法,可以在一个单一的应用程序中运行多个服务,那么我们该如何使用它呢?让我们来谈谈一个简单的架构:

mygoproject/
  p2p/
    service.go
  api/
    service.go
  db/
    service.go
  numbercrunching/
    service.go 

我们按照规定的顺序注册和启动每个服务。

package main

func main() {
    registry := NewServiceRegistry()
    
    // Register our database first.
    db := database.InitializeDB()
    registry.RegisterService(db)
    
    // We then start up our p2p server.
    p2pServer := p2p.InitializeP2P()
    registry.RegisterService(p2pServer)

    // We then start up our API.
    apiServer := api.InitializeAPI()
    registry.RegisterService(apiServer)

    // We then start up some number crunching service.
    miscServer := misc.InitializeNumberCrunching()
    registry.RegisterService(miscServer)
    
    // Rev it up!
    registry.StartAll()
}

上面的代码是否做了什么...?如果我的API服务器依赖于DB,如果我的数字计算器依赖于我的API......怎么办?我们如何才能实现服务之间的依赖关系呢?

进入依赖注入

我们按照指定的顺序声明和注册每个服务是有原因的。也就是说,一些服务依赖于其他服务,我们希望保持整个依赖关系图相当简单。一个重要的编程范式是关注点分离的理念,这意味着程序中的每个模块都应该关注其特定的逻辑,而不应该被要求做其逻辑范围之外的事情。也就是说,你不应该期望你的API服务器也处理数据库连接的内部事务,或通过P2P对等物管理器拨号给其他服务器。一切都应该是独立的,容易推理,也容易测试。

在我们上面的玩具例子中,关注点分离的一个重要部分是,每个服务都不应该关心如何获得对其他服务的访问。它应该在初始化时被提供其依赖关系。也就是说,如果我是API服务器,我应该只知道我可以访问数据库和P2P服务,我不需要担心如何从很远的地方请求它们来获取它们

这种明确定义依赖关系并其注入到需要它们的服务中的概念被称为依赖注入,当你看了上面的代码后,这个花哨的术语现在变得更有意义。如果你看一下我们的API服务器代码,如果我们遵循上面的服务模式,它可能看起来很直截了当。

package api

type Server struct {
    db *database.Database
    p2pServer *p2p.Server
}

API服务器不需要担心如何访问数据库或p2p服务,因为它在初始化时已经将它们注入了。很酷......但我们的服务注册表代码还不允许这种注入。让我们来看看我们如何做到这一点。

依赖性注入非常棒

// FetchService takes in a struct pointer and sets the value of that pointer
// to a service currently stored in the service registry. This ensures the input argument is
// set to the right pointer that refers to the originally registered service.
func (s *ServiceRegistry) FetchService(service interface{}) error {
	if reflect.TypeOf(service).Kind() != reflect.Ptr {
		return fmt.Errorf("input must be of pointer type, received value type instead: %T", service)
	}
	element := reflect.ValueOf(service).Elem()
	if running, ok := s.services[element.Type()]; ok {
		element.Set(reflect.ValueOf(running))
		return nil
	}
	return fmt.Errorf("unknown service: %T", service)
}

上面的fetch服务函数是关键。它让我们抓取到我们在服务注册表中跟踪的服务的正确指针。我们可以用它来进行依赖注入。

让我们重构我们的代码来使用它:

package main

import "log"

func main() {
    registry := NewServiceRegistry()
    
    // Register our database first.
    db := database.InitializeDB()
    registry.RegisterService(db)
    
    // We then start up our p2p server.
    registerP2P(registry)

    // We then start up our API.
    registerAPI(registry)
    
    // Rev it up!
    registry.StartAll()
}

func registerP2P(reg *ServiceRegistry) {
    var dbService *database.Service
    if err := reg.FetchService(&dbService); err != nil {
        log.Fatal(err)
    }

    p2pServer := p2p.InitializeP2P(p2p.Config{
        database: dbService, 
    })
    registry.RegisterService(p2pServer)
}

func registerAPI(reg *ServiceRegistry) {
    var dbService *database.Service
    if err := reg.FetchService(&dbService); err != nil {
        log.Fatal(err)
    }

    var p2pService *p2p.Server
    if err := reg.FetchService(&p2pService); err != nil {
        log.Fatal(err)
    }

    apiServer := api.InitializeAPI(api.Config{
        database: dbService,
        p2p: p2pService, 
    })
    registry.RegisterService(apiServer)
}

好了!我们走吧我们明确地定义了每个服务在初始化时需要的依赖关系,使它们很容易保持自主性和相应的关注点分离。下次如果你不得不选择创建一个复杂的微服务架构,可以考虑这个简单的带有依赖注入的单体,为你省去一些麻烦!我们实际上在我的团队的Prysm 项目中使用了这个完全相同的模式,我们对Ethereum 2.0区块链的实现,你可以在这里找到。