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
-1