Featured image of post 一种基于 Gin + etcd 的微服务架构实现:Gin Hybrid Microservice

一种基于 Gin + etcd 的微服务架构实现:Gin Hybrid Microservice

在 Gin Hybrid 的基础上进行微服务化,使用 etcd 作为注册中心、配置中心和服务发现中心,使用 HTTP JSON API 进行服务间通信的一种微服务框架的实现。

背景

由于写某个项目的需要,设计了一套基于 Gin 的微服务框架。

框架基于原先Gin Hybrid的代码上进行修改,增加了 etcd 作为服务注册中心、配置中心和发现中心,以及使用 JSON API 进行微服务间通信的功能。

现在 Go 圈子中没有一套像 Spring 一样大一统的微服务框架,但也有不少优秀的解决方案,例如国内的go-zero、国外的Go Micro以及 B 站开源的Kratos等等。

但这些框架都略微复杂,具有一定学习成本。例如 go-zero 使用自研的模板生成工具,用起来感觉很奇怪。另外在 go-zero 的架构中,service 层实现具体的逻辑暴露 gRPC 接口,controller 层暴露 JSON API 接口,使用 gRPC 去调用 service 层。service 层微服务之间的调用走的是 gRPC。

额外增加的 gRPC 和 protobuf 声明带来了复杂性,不易于修改。在不同微服务上也免不了重复声明同一个 dto。另外也还需要 JSON API 的层暴露给用户。

这种架构在大型的多人团队中比较合适,但作为 one man 的小型项目来说未免过于臃肿。

因此 Gin Hybrid Microservice 想要实现的是一个适合 one man 使用的迷你微服务框架。

另外使用了 Go 1.18 的泛型特性,将许多模型定义进行了简化。

目前项目发布的地址是 Gin Hybrid 的microservice分支。

架构

Gin Hybrid Microservice 使用如下架构:

  • Gin Hybrid 的路由封装,具体可以查看之前的博客文章
  • etcd 作为服务注册中心、发现中心和配置中心
  • 不论是微服务之间调用还是用户调用,都通过统一格式的 HTTP JSON API

下面展示所使用的例子是一个算法平台的架构。该平台架构图如下所示(不是重点):

所有微服务共用一个仓库,在 cmd 包中包含不同的启动目录:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
. // cmd包
├── algo
│   ├── config.toml
│   └── main.go
├── cmd.go
├── file
│   ├── config.toml
│   └── main.go
├── gateway
│   ├── config.toml
│   └── main.go
├── model
│   ├── config.toml
│   └── main.go
└── user
    ├── config.toml
    └── main.go

在每个微服务的main.go中注册各自的路由。

config.toml中提供微服务的本地配置,例如:

1
2
3
4
5
6
7
8
name = "algo"
ip = "10.10.10.1"

[etcd]
endpoints = ["x.x.x.x:2379"]
user = "root"
pass = "root"
namespace = "vc"

name为微服务的名称,必须全局唯一。ip为微服务间互相访问的 IP,也就是其他微服务能够通过这个 IP 地址对其进行 RPC 调用,通常为内网地址。再往下则是连接 etcd 的配置文件。

端口号将从 etcd 中读取,不需要在本地配置中写。

鉴权

在通常的微服务架构中,有一个承担网关职责的微服务,在 Gin Hybrid Microservice 中也是如此。用户不直接访问各个微服务的端口,而是将网关服务作为用户所有流量的入口,这样可以方便配置上层 Nginx 进行反代等操作。

在 Gin Hybrid Microservice 中,网关微服务不承担用户鉴权的职责,只是根据路由,将用户请求完整转发到各个微服务中,附带上所有的 HTTP Headers 等信息。各个微服务自行完成提取 JWT Token 及后续的鉴权步骤。

组件

etclient

etclient 包提供 etcd 操作的客户端封装。同时负责将微服务自身通过注册到 etcd,并赋予自己一个 LeaseID,定时续租。

关于 etcd 的 Lease 机制可以参考一下网络上的文档。

默认 Lease 的租期是 10s,在 5s 的时候会续租一次。因为考虑到微服务可能出现超过 10s 的网络中断情况,所以在续租失败的情况下将会重新申请 LeaseID,抛弃旧的 ID。

一个微服务可能依赖其他微服务的 RPC 接口,在这种情况下,服务应使用 etcd 的 watch 机制和 prefix 机制,监听某一个微服务目录下服务实例的上线下线情况,以便更新自己调用该服务时使用的负载均衡列表。但如果为服务自身从 etcd 断线,watch 机制便无法收到断线这段时间内的变更。因此,重新连接后需要重新使用 Get By Prefix 获取到最新的服务实例列表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (c *Client) registerServiceOnce() error {
	resp, err := c.client.Lease.Grant(context.Background(), leaseTTL)
	if err != nil {
		return err
	}
	c.leaseID = resp.ID
	err = c.updateListDirectory()
	if err != nil {
		return err
	}
	log.Println("registered service successfully with lease id: " + strconv.Itoa(int(c.leaseID)))
	go c.serviceRegisterEventObservers.NotifyAll()
	return nil
}
// updateListDirectory register current service into the etcd directory
func (c *Client) updateListDirectory() error {
	value := c.conf.IP + ":" + strconv.Itoa(c.conf.Port)
	err := c.PutRawKey("list/"+c.conf.Name+"/"+strconv.Itoa(int(c.leaseID)), value, clientv3.WithLease(c.leaseID))
	if err != nil {
		return err
	}
	log.Println("updated list directory: " + value)
	return nil
}

以上 registerServiceOnce 函数在初次启动微服务时调用,并在每次从 etcd 断线后都调用。该函数会申请一个 LeaseID,将自己的服务实例注册到 etcd 的对应目录下(value 中写入当前微服务的 IP 和端口以供其他微服务访问)。最后调用 NotifyAll 函数通知一个观察者列表,以此获取当前服务依赖的那些服务的最新状态。后续要介绍的 rest 包中有对应逻辑,将服务依赖的更新函数添加到这个列表中。

conf

conf 包读取本地配置文件,初始化当前微服务,并加载 etcd 的云端配置文件。使用 watch 机制监听云端配置文件的状态并进行及时更新。同时还承担了初始化一些公共的依赖,例如数据库的职责。

配置文件分为以下几种:

  • InitConf:本地配置文件,即为config.toml中所写的内容。不同微服务的格式均相同。
  • ParentConf:父级配置文件,所有微服务都共有的配置项,例如数据库的配置、JWT 的配置等等。不同微服务的格式均相同。
  • SelfConf:每个微服务自身独有的配置项,例如启动端口等。使用了泛型。不同微服务可以自己定义格式。

这些配置项被封装在 conf 包的核心结构体 ServiceConfig 中,与此同时该结构体还包括一些其他依赖,如 DB 等:

1
2
3
4
5
6
7
8
type ServiceConfig[T any] struct {
	InitConf     Init
	ParentConf   Parent
	SelfConf     T
	Etclient     *etclient.Client
	InitConfPath string
	DB           *gorm.DB
}

不同微服务虽然端口不一样,但端口字段的形式是一样的(都是port)。所有还需要一个Common结构,用于定义一些相同字段但值不同的配置项,让所有 SelfConf 都以组合的形式「继承」它。

因此各种 SelfConf 的定义看起来可能是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type Gateway struct {
	Common
}

type User struct {
	Common
	Email UserEmail `toml:"email"`
}
type UserEmail struct {
	Address  string `toml:"address"`
	Username string `toml:"username"`
	Password string `toml:"password"`
	Host     string `toml:"host"`
	Port     int    `toml:"port"`
	TLS      bool   `toml:"tls"`
}

type Algo struct {
	Common
	Mq string `toml:"mq"`
}

type File struct {
	Common
}

type Model struct {
	Common
}

在 conf 初始化之时,还不能启动 web server,因为端口需要从 etcd 中读取。因此需要先初始化 etclient。在通过 etclient 获取到端口后,再调用 etclient 的 RegisterService 函数,正式将当前微服务注册到 etcd 的服务实例列表中。同时启动 watch 线程监听配置文件变化。

LoadConfig 函数在初始化 etclient 后,依次读取父级配置文件和自身配置文件,并通过反射从自身配置文件中读取到Common部分的内容,从中提取出本服务的启动端口。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
func LoadConfig[T any](config *ServiceConfig[T]) (*etclient.Client, error) {
	// load local config
	_, err := toml.DecodeFile(config.InitConfPath, &config.InitConf)
	if err != nil {
		return nil, err
	}
	// initialize etclient using local config
	etclientConf := etclient.Conf{
		Endpoints: config.InitConf.Etcd.Endpoints,
		Namespace: config.InitConf.Etcd.Namespace,
		Name:      config.InitConf.Name,
		IP:        config.InitConf.IP,
		User:      config.InitConf.Etcd.User,
		Pass:      config.InitConf.Etcd.Pass,
		Port:      0, // not available for now
	}
	etclientIns, err := etclient.NewClient(etclientConf)
	if err != nil {
		return nil, err
	}
	parentV, err := etclientIns.GetRawKey("parent_config")
	if err != nil && err != etclient.ErrNotExist {
		return nil, err
	}
	err = toml.Unmarshal([]byte(parentV), &config.ParentConf)
	if err != nil {
		return nil, err
	}
	// initialize config for current service
	configV, err := etclientIns.GetRawKey(config.InitConf.Name + "/config")
	if err != nil && err != etclient.ErrNotExist {
		return nil, err
	}
	err = toml.Unmarshal([]byte(configV), &config.SelfConf)
	if err != nil {
		return nil, err
	}
	commonV := reflect.ValueOf(&config.SelfConf).Elem().FieldByName("Common").Interface().(Common)
	etclientConf.Port = commonV.Port
	err = etclientIns.RegisterService(etclientConf)
	if err != nil {
		return nil, err
	}
	go watchConfigThread(config)
	return etclientIns, nil
}

rest

rest 包负责处理微服务间的依赖关系,以及服务间进行 RPC 调用的逻辑。

要创建一个 restClient,需要传入*conf.ServiceConfig对象。一个服务启动时只需要创建一个 restClient,后续使用该 client 添加所有的服务依赖。

一个微服务可能依赖多个其他微服务。开发者在 cmd 包中调用 AddServiceDependency 函数通过服务名称声明依赖的服务,得到一个*Service对象。其函数定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (c *Client[T]) AddServiceDependency(name string) (*Service, error) {
	service := &Service{
		Name:         name,
		Endpoints:    map[clientv3.LeaseID]string{},
		mu:           sync.Mutex{},
		etclientInst: c.srvConf.Etclient,
		httpClient:   c.httpClient,
		rpcKey:       c.srvConf.ParentConf.RPCKey,
	}
	err := service.UpdateServiceDirectory()
	if err != nil {
		return nil, err
	}
	go service.updateServiceDirectoryThread()
	c.srvConf.Etclient.AddServiceRegisterEventListener(func() {
		err := service.UpdateServiceDirectory()
		if err != nil {
			log.Println("observer failed to update service directory of " + service.Name + ": " + err.Error())
		}
	})
	c.services = append(c.services, service)
	return service, nil
}

AddServiceDependency 方法创建并返回一个 Service 对象,更新运行该微服务的所有节点列表,并启动 watch 线程。同时向 etclient 添加一个监听器函数,用于在 etclient 发生网络错误重连时及时更新节点列表。

由于不区分 JSON API 的路由与 RPC Call 的路由,部分敏感的服务可能只允许微服务间调用使用,而不允许用户直接耐用使用。因此设定了一个 RPCKey 机制,微服务间互相调用都会在 HTTP Header 中带上这个头,在路由绑定时可以指定某个路由是否为 RPCOnly。如果是的话,则需要校验 RPCKey。RPCKey 的具体配置也在 ParentConf 中。

router 包中,在 Gin Hybrid 的基础之上增加了 RPCKey 的校验:

1
2
3
4
5
6
7
if apiRouter.RPCOnly {
	rpcKey := ctx.GetHeader("X-RPC-Key")
	if rpcKey != conf.ParentConf.RPCKey {
		ctx.JSON(401, "direct API Call sent to RPC-only routes")
		return
	}
}

在持有 Service 对象后,可以通过 Call 方法进行微服务间的调用。该方法的函数签名如下:

1
func (s *Service) Call(v any, method string, path string, data any, jwt string) error

v变量应该为一个指向结构体的指针,用于接收返回的结果。method为接口调用的方法,如 GET、POST 等。若为 GET,将调用参数序列化后通过 URL Parameter 的方式发送;如果为 POST,将调用参数通过 URL 编码后放置于 POST Body 中发送。path变量为接口的路径。data变量是调用时传入的 HTTP 请求参数,可以为 map 或一个结构体。如果为结构体,将通过反射的方式从中取得所有值并进行序列化。

jwt为用户的 jwt,可以为空。若不为空,则将其使用在Authorization头中,作为用户鉴权使用。这是由于 gateway 微服务不承担用户鉴权的职责,而是每个微服务各自进行 JWT 鉴权。因此在对需要进行用户鉴权的接口进行 RPC 调用时,只需要附带上当前微服务从用户那边收到的 JWT Token 即可;而对于仅供 RPC 调用的接口,应该在路由声明处设置 RPCOnly 为 true,Call 函数会自动带上 RPCKey 的头。

所有 JSON API 的封装均为如下:

1
2
3
4
5
type Result struct {
	Code int             `json:"code"`
	Msg  string          `json:"msg,omitempty"`
	Data json.RawMessage `json:"data,omitempty"`
}

Call 函数将从该类型微服务的实例列表中随机取出一个实例地址进行调用,如果调用错误,将返回 error;如果调用成功,则把 Data 字段的 JSON 内容 Unmarshal 到传入的 v 参数中,返回的 error 为 nil。

service

service 包存放业务逻辑。在创建 Service 实例时应传入其对应的conf.ServiceConfig对象以便从中读取配置,并进行一些初始化操作等。如果该服务依赖其他微服务,也应该在 New 的函数中传入。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type AlgoService struct {
	srvConf               *conf.ServiceConfig[conf.Algo] // 对应配置实例
	algoDAO               *dao.AlgoDAO // 初始化数据库 DAO
	mqClient              *mq.Client // 初始化 RabbitMQ Client
	instructionUpdateChan chan<- dto.MqInstruction // 初始化 channel
	userService           *rest.Service // algo 服务依赖的 user 服务
}

func NewAlgoService(srvConf *conf.ServiceConfig[conf.Algo], mqClient *mq.Client,
	userService *rest.Service) *AlgoService {
	instructionUpdateChan := make(chan dto.MqInstruction, 128)
	srv := &AlgoService{srvConf: srvConf, algoDAO: dao.NewAlgoDAO(srvConf.DB), mqClient: mqClient,
		instructionUpdateChan: instructionUpdateChan, userService: userService}
	statusDelivery, err := srv.mqClient.ConsumeQueue("status")
	if err != nil {
		panic(err)
	}
	logDelivery, err := srv.mqClient.ConsumeQueue("log")
	if err != nil {
		panic(err)
	}
	go srv.mqHandler(statusDelivery, logDelivery, instructionUpdateChan)
	return srv
}

data/dto

DTO(Data Transfer Objects)包用于存放用户与服务、服务与服务之间数据传输所用的结构体。当用户与服务之间传递时,可以在 Service 中用 Gin 标准的方法绑定参数到结构体上:

1
2
3
4
var req dto.CreateUpdateProjectReq
if err := aw.Ctx.ShouldBind(&req); err != nil {
	return aw.Error(err.Error())
}

当服务与服务之间传递时,可以之间将结构体对象传入 Call 方法中:

1
2
3
4
5
6
err = a.userService.Call(nil, "post", "/send_message", dto.UserSendMessage{
	TaskGroupID: taskGroup.ID,
	UserID:      user.ID,
	Email:       user.Email,
	Message:     msg,
}, "")

一个 RPCOnly 为 false 的接口可以由用户之间调用,也可以由服务之间 RPC 调用。如果这两种调用都使用同一种结构,那么在同一个仓库、同一个包的存放 dto 就能避免使用多仓库时的重复声明。

cmd

cmd 包中存放每个微服务各自的入口包,每个微服务声明所需的依赖,如依赖的其他服务、依赖的组件等(所有微服务共同依赖的组件应在 conf.ServiceConfig 中声明)。例如对于 algo 微服务的main.go

1
2
3
4
5
6
7
8
9
func main() {
	srvConf := conf.MustNewServiceConfig[conf.Algo]()
	mqClient := mq.MustNewClient(srvConf.SelfConf.Mq)
	restClient := rest.NewClient(srvConf)
	userService := restClient.MustAddServiceDependency("user")
	cmd.Entry(cmd.EntryConfig{Port: srvConf.SelfConf.Port}, func(engine *gin.Engine, api *gin.RouterGroup) {
		router.RegisterAPIRouters(getRouters(srvConf, mqClient, userService), api, srvConf)
	})
}

getRouters 同样是一个main.go中的函数,用于注册路由:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func getRouters(srvConf *conf.ServiceConfig[conf.Algo], mqClient *mq.Client,
	userService *rest.Service) []router.APIRouter {
	srv := service.NewAlgoService(srvConf, mqClient, userService)
	routers := []router.APIRouter{
		{
			Method:   "post",
			Path:     "/raw_data",
			Handlers: router.AssembleHandlers(middleware.Auth, srv.CreateRawData),
		},
        ...
		{
			Method:   "delete",
			Path:     "/task_groups/subscriptions",
			Handlers: router.AssembleHandlers(srv.DeleteEmailSubscription),
			RPCOnly:  false,
		},
	}
	return routers
}

总结

本文介绍了一种基于 Gin + etcd 的微服务架构实现:Gin Hybrid Microservice。该架构的主要特点是:

  • 使用 Gin 作为 web 框架,提供了一种简洁的路由封装方式,支持 RESTful API 和模板渲染的混合模式。
  • 使用 etcd 作为服务注册中心、配置中心和服务发现中心,利用 Lease 机制实现服务实例的自动注册和续租,利用 watch 机制实现配置文件和服务列表的实时更新。
  • 不论是微服务之间调用还是用户调用,都通过统一格式的 HTTP JSON API,避免了额外引入 gRPC 和 protobuf 的复杂性。同时使用 RPCKey 机制保证了部分敏感接口只能由微服务间调用。
  • 使用泛型特性简化了配置文件和 rest 客户端的定义,提高了代码的复用性和可读性。
  • 使用 DTO 包存放数据传输对象,避免了在不同微服务间重复声明同一个结构体。

该架构的优点是:

  • 简单易用,适合 one man 或小型团队使用,无需学习复杂的框架和工具,只需掌握 Gin 和 etcd 的基本用法即可。
  • 灵活可扩展,可以根据不同业务场景自定义微服务的功能和依赖,也可以根据需要增加或减少微服务的数量和类型。
  • 高效可靠,使用 HTTP JSON API 作为通信协议,保证了数据传输的速度和兼容性,使用 etcd 作为中心化的管理组件,保证了服务实例和配置文件的一致性和可用性。

总之,Gin Hybrid Microservice 是一种轻量级的微服务架构实现,旨在提供一种快速开发、部署和运维微服务应用的方案。希望本文能够对有兴趣使用 Go 语言开发微服务应用的读者有所帮助和启发。

Licensed under CC BY-NC-SA 4.0