五、服务整体框架的介绍 - FeifeiyuM/go-microservices-boilerplate GitHub Wiki

服务整体框架的介绍

之前四篇文章,分别从 “工程组织结构”,“http 服务集成”,“rpc 服务集成”,“mq 消息消费集成”,“服务中间件开发”,“服务日志处理”,“服务错误处理” 等七个方面介绍了如何基于 Golang 从 0 到 1 得搭建一个微服务开发脚手架。

总得来说整个框架并没有引入新的概念,也不是完全创造了一个新的框架。总体上来说是一个基于经典的 WEB MVC 分层以及笔者日常开发踩坑后总结后提出的一个微服务开发脚手架。

一、工程组织结构

在 《基于 echo 搭建的简单 http 服务和工程组织结构》中介绍过,本文介绍的微服务框架组织结构遵循的是Standard Go Project Layout。需要介绍的是 internal/ 内部目录下的,工程组织逻辑。

1.1 服务结构介绍

在之前文章的中都已经多次提到,本框架的服务结构是基于经典的 MVC 分层模型,并且吸收一部分 DDD (Domain-Driver Design) 思想而提出的。

1.1.1 经典的 MVC 模型:

经典的 MVC 模型,主要由控制层,视图层,模型层构成。

  • C:控制层(Controller),负责对请求的转发和处理。这个就有点类似本框中 interface/ 路径下的 Rest, gRPC, NSQ 对应接口的 handler 和 Register 函数实现的功能。完成路由,参数校验,鉴权等逻辑。

  • V: 视图层 (View),用于渲染用户可见的见面,但是在目前前后端分离的状态下,在后端或微服务中,这一层都已经没有存在的意义了。

  • M: 模型层 (Model), 包括业务逻辑,数据管理都在该层完成,完成功能比较多,相对臃肿。

在实际的工程实际中,纯后端服务中,C(Controller), L(Logic), D(Dao) 结构。即剔除了 V 视图层。并将臃肿的 M 层扩展成为 L,D 两层。

L 层:有些地方又将其称为 S(Service) 服务层,这一层业务逻辑层,完成对应的业务逻辑和流程。

D 层:即 Repository 资源层,主要完成,数据管理的工作。比如:数据库的增删改查,消息的发送等。

1.1.2 定义更加清晰的 DDD 架构

DDD 领域驱动设计,这两年 从网上盗一张 DDD 分层模型的图片,如图1 alt

DDD 架构一共分为4层,Interface 接口层,Application 应用层,Domain 模型层,Infrastructure 基础射设施层。

  • Interface 层:和 MVC 中 的 C 控制层的功能比较类似。在本文的框架中将对应的目录命名为 interface 也是受其影响。

  • Application 层: 这里的应用层和 MVC 中的 M 层 或 CLD 中 L 层负责功能有一定差异,他只是负责协调各个领域对象,组件之间的业务流程的,并不负责实现各个领域的业务逻辑,相对来说是很薄的一层。

  • Domain 层:领域层是个整个 DDD 架构的核心,负责实现领域内的 entity 实体定义,数据的存取管理,领域内业务逻辑实现等一系列功能,有点类似 MVC 中 M 层完成的功能。

  • Infrastructure 层:基础设施层,负责为其他各层以接口的形式提供具体的数据存储,消息发送等一些通用技术能力,因此其他层不用关心服务用了 MySQL 数据库还是 Postgresql 数据库,消息中间件是用了 NSQ 还是 RabbitMQ。

现在有很多 DDD 开发框架,为了比较完全的践行 DDD 的设计理念,将这个框架层级设计的非常复杂。
在 Interface, Application, Domain, Infrastructure 层下还抽象了很多层,比如在 Domain 层下设立了 Service 逻辑层, Entity 实体层,Repository 数据交互层。
在 Application 层下有设立了 service 逻辑层,model 数据模型层,DTO 请求数据和模型转换层。
go-ddd-sample 是个比较典型的按 DDD 分层模型构建的一个 Golang 示例工程。

1.2 DDD 分层对比分析

谈谈对于 DDD 分层模型的使用,笔者曾参考了一个比较完整践行 DDD 分层理念的项目框架,并将其应用于自己的新项目中。
从好的方面来说:1、使用 DDD 分层模式开发时,在项目中各层之间的定义更加清晰,各个层之间都通过接口的形式耦合,这种耦合方式更加松散,可替换。2、领域(domain) 内的业务逻辑在 domain 层内自己实现,application 层则是各个领域内的逻辑串联起来,而不是像 MVC 或 CLD 分层模型一样,不同领域的业务逻辑都放在 M(模型) 或 L(逻辑)层 实现,这样慢慢的 业务层会变得混乱而且臃肿。

不好的方面:使用的 DDD 开发实现一个功能时特别繁琐,有时实现某一个功能时,一半的代码都是为了遵从 DDD 分层概念而编写的套路代码,工程代码阅读起来并不是非常的紧凑简约。如果是在一个项目快速迭代的情况下,DDD 分层模式一开始就会是开发者的负担,不管是从代码开发和调试的角度考虑。
此外,对于一个微服务项目而言,其项目本身关注的已经是一个相对较小,边界清晰的领域,M 或 L 层逻辑代码不会像单体应用一样无节制膨胀,所以是否需要像 DDD 分层一样使用 Domain 层和 Application 层共同配合完成整个业务逻辑,值得商榷。
对于一个微服务项目,也不太会存在跨团队维护的情况出现,小概率会出现 团队A 只维护 Infrastructure 层实现,团队B 维护 Domain, Application 层, 团队C 维护 Interface 层。所以采用 MVC 或 CLD 分层,会增加各层之间的耦合性,但是在一个团队内良好的约定下,这个不会成为一个后期难以维护的问题。

1.3 本框架的分层结构介绍

虽然 DDD (领域驱动开发)看起来是一个更加优秀的分层框架,领域、职能区分清晰,在很多介绍微服务的书中都将其作为一个推荐的分层结构。但是,经典的 MVC 或 LCD 能够成为最为大家熟知。MVC 作为广泛采用的服务分层结构也是有其生命力的,在保证分层清晰的情况下,不会引入太多的开发量。

本框架的分层结构主要是基于 LCD 分层,结合 DDD 中的 Entity(实体), Aggregate(聚合)的概念,我们再在 LCD 基础上分出 model(模型层), object(聚合层)。聚合层是使用英文单词 Object 还是 Aggregate,或者其他的,这个可以根据个人对这层的理解来命名。分层结构如图2所示。

alt 图2

图2中,共有 Model, Object, Dao, Service, Interface 五层,刚好对应我们 《基于 echo 搭建的简单 http 服务和工程组织结构》一文中的 图1* 工程组织结构 internal/ 目录下对应的 model/, object/, dao/, service/, interface/ 五个子目录(conf/ 路径跟分层无关,非必须目录)。

1.3.1 Model 层

先从 Model 层聊起, model 层主要用于定义应用中最基础的对象结构,推荐同数据库中的表结构一一对应。

加入数据库中有 account 表,如图3,总有 id, nickname, mobile 等字段。

alt=account 图3

对应的,我们可以再 model/ 目录下创建 account.go 文件,在该文件下定义一个 struct, 以对应 account 表中字段。如 code1 所示

// Account
type Account struct {
	Id int64 `json:"nickname" sql:"id""`
	// 昵称
	Nickname string `json:"nickname" sql:"nickname"`
	// 头像地址
	Avatar sqly.NullString `json:"avatar" sql:"avatar"`
	// 性别
	Gender int8 `json:"gender" sql:"gender"`
	// 生日
	Birthday sqly.NullTime `json:"birthday" sql:"birthday"`
	// 手机号
	Mobile string `json:"mobile" sql:"mobile"`
	// 密码
	Password string `json:"password" sql:"password"`
	// 创建时间
	CreateTime time.Time `json:"create_time" sql:"create_time"`
}

code1

code1中,在 struct Account 中我们定义了 Id,Nickname 等八个字段,刚好对应图3表结构中的 id, nickname 八个字段。 每个字段的数据类型和是数据库中字段类型一致的,account 表建表 mysql 数据如 code2所示。

DROP TABLE IF EXISTS account;
CREATE TABLE `account` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `nickname` varchar(32) NOT NULL,
  `avatar` varchar(200) DEFAULT NULL COMMENT 'avatar url',
  `birthday` date DEFAULT NULL,
  `gender` tinyint(2) NOT NULL COMMENT 'gender 0 unkown, 1 female, 2 male',
  `mobile` varchar(16) NOT NULL COMMENT 'mobile number',
  `password` varchar(64)  NOT NULL DEFAULT '' COMMENT 'password',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `mobile_index` (`mobile`)
) CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

code2

比如:account 表中的 id 字段是 int 类型,所以在 model/ 中定义的 Account struct 中的 Id 字段使用的是 int64 类型。 create_time 在表中的数据类型是 datetime,与之对应的 Account 中的 CreateTime 就是 Golang 中的时间类型 time.Time。

但是需要注意的是,表中的 avatar 字段类型是 avatar 对应 Golang 中的数据类型是 string, 但是 Golang 中的字符串类型是无法接收空值的, 此时我们就需要定义个特殊的可 scan 对象, 其既能接收空值也能接收正常的字符串值。

在 Golang 官方的 database/sql 库中,定义了 NullString 来接收允许未空的字段类型。除了 NullString 外,还有 NullBool, NullTime, NullInt32, ...... 等 Golang 基础数据类型的对应的控制接收对象。

code1 中使用了由笔者自己开发的 Golang 数据库操作库 sqly 中定义的控制接收类型, 同 database/sql 库一样,sqly 中也定义了对应的控制接收对象。

基于 Golang 官方库 database/sql 开发的,用于方便数据库数反序列化成 Golang struct 对象的一个库, 但其定位不是 ORM。当然,类似的库还有 sqlx 等。

但是,在 Account 中的 Id 字段是怎么样和数据库中的 id 字段对应起来的,我们可以观察在 Account 中定义的字段后面定义的 tag 中除了常见的 json 还有一个 sql。 sql 中对应的值就是数据库中的字段名。

注意:采用是 sql 作为 tag 名是 sqly 这个库中定义的,其在反序列化时会读取 sql 这个 tag 对应的值作为数据库字段名去匹配。采用其他的库就不是 sql 这个 tag 了, 例如 sqlx 中采用的就是 db 这个 tag 名。

code1 我们可以进一步为 Account struct 定义一些方法或函数,这样既能保证数据准确性,又能方便使用。让 Account struct 不仅仅只有一个单调的定义,更有丰富的成员方法。如 code3 所示。

const (
	UnKnow int8 = 0
	Female int8 = 1
	Male int8 = 2
)
// ......
// 初始化 Account
func NewAccount(nickname, avatar, mobile string, birthday time.Time) *Account {
	a := sqly.NullString{}
	if avatar != "" {
		a = sqly.NullString{String: avatar, Valid: true}
	}
	b := sqly.NullTime{}
	if !birthday.IsZero() {
		b = sqly.NullTime{Time: birthday, Valid: true}
	}
	return &Account{
		Nickname:   nickname,
		Avatar:     a,
		Birthday:   b,
		Mobile:     mobile,
		CreateTime: time.Now(),
	}
}
// 设置性别
func (a *Account) SetGender(g int8) {
	switch g {
	case Female, Male:
		a.Gender = g
	default:
		a.Gender = UnKnow
	}
}

code3

函数 NewAccount 用于初始化一个新的 Account 对象, 方法 SetGender(g int8) 用于设置用户性别,这样可以保证数据一致性,不会输入出 0, 1, 2 之外的无意义的数据。这样将与 Account 对象相关的业务逻辑都抽象成 Account 的成员方法是一个非常有效的选择。即丰富了 Account 结构体,使其功能不是非常单一,同时也简化了调用方的判断和操作,让代码更加整洁,更重要的是保持的业务逻辑的一致性。

Model 层是对基本数据结构的抽象,与数据库表结构一一映射。其定位有点类似于 ORM 中的 O(object), 但是,model 层无数据库,缓存等基础设施无关。所有与基础设施相关的耦合的都放置于 Dao 层。

1.3.2 Dao 层

Dao (Data Access Object) 数据接入对象层,又可以定义成 Repository 资源层。其作用是与数据库,缓存,消息系统,第三方服务等服务基础设施进行数据交互。例如:数据库增、删、改、查;系统消息的发送;缓存的存、取操作都在放置在 dao 层抽象实现。如 code4 所示。

package dao

import (
	"context"
	"github.com/FeifeiyuM/sqly"
	"github.com/go-redis/redis/v7"
	"gmb/internal/model"
)

type AccountRepo interface {
	// 新建账户
	CreateAccount(ctx context.Context, acc *model.Account) (int64, error)
	// 获取账户
	GetAccountById(ctx context.Context, id int64) (*model.Account, error)
}
var accountRepo AccountRepo
// 初始化 account dao 层
func InitAccountRepo(a AccountRepo) {
	accountRepo = a
}
// 获取 account repo
func GetAccountRepo() AccountRepo {
	return accountRepo
}
// 定义 accountRepo 实现对象
type aRepoImpl struct {
	db *sqly.SqlY
	cache *redis.Client
}
// 实例化 accountRepo 实现对象
func NewAccountDao(db *sqly.SqlY, cache *redis.Client) *aRepoImpl {
	return &aRepoImpl{
		db: db,
		cache: cache,
	}
}
// 新建账户
func (a *aRepoImpl) CreateAccount(ctx context.Context, acc *model.Account) (int64, error) {
	if acc == nil {
		return 0, nil
	}
	param := []interface{}{
		acc.Nickname,
		acc.Avatar,
		acc.Gender,
		acc.Mobile,
		acc.Password,
		acc.CreateTime,
	}
	query := "INSERT INTO `account` (`nickname`, `avatar`, `birthday`, `gender`, `mobile`, `password`, `create_time`) VALUES " +
		"(?,?,?,?,?,?,?)"
	aff, err := a.db.InsertCtx(ctx, query, param...)
	if err != nil {
		return 0, err
	}
	lastId, _ := aff.GetLastId()
	return lastId, nil
}
// 获取账户
func (a *aRepoImpl) GetAccountById(ctx context.Context, id int64) (*model.Account, error) {
	acc := &model.Account{}
	query := "SELECT * FROM `account` WHERE `id`=?"
	err := a.db.Get(acc, query, id)
	return acc, err
}

code4

code4 中,

首先将 Account 相关的数据操作都抽象成接口 AccountRepo, 在通过结构体 aRepoImpl 去实现接口。当然也可以不抽象成接口只用结构体实现。
数据库,缓存的客户端连接对象(例如:数据库连接对象 sqly.SqlY, 缓存连接对象 redis.Client) 初始化后封装在结构体 aRepoImpl 中,而不是在每次调用的时候再去初始化连接对象。因为创建连接需要网络I/O是一个成本相对较高的操作, 应该避免重复创建。
通过在结构体 aRepoImpl 实现对应的接口方法,去具体实现服务与基础设施的数据交互。 例如方法:
CreateAccount(ctx context.Context, acc *model.Account) (int64, error) 实现了将 account 数据存入数据库的操作;
方法:GetAccountById(ctx context.Context, id int64) (*model.Account, error) 实现了从数据库获取 account 数据,并且同时将数据库中的数据反序列化成 Account 对象。方序列化操作是由 sqly.SqlY 封装实现。

因此,Dao 层主要用户实现服务于其他基础设施或其他服务的数据交互,不应该将于业务逻辑相关的操作在该层实现,尽量做到与业务无关或弱相关。 为了使代码组织更加清晰,我们可以为每个 Model 层中定义的模型对象建立与之一一对应的 Dao 层接口。

1.3.3 Service 层

Service 业务层(或者称Logic 逻辑层)是服务的业务逻辑实现层。所有的和业务相关的逻辑和和相关的数据处理都在改层实现。例如,我们再创建账号 Account 时,需要检查账号使用的手机号是否已经被使用过,或者需要检验设置的密码是否已经使用过,是否过于简单都可以在该层实现。

Service 层同时也是一个聚合层,在完成一个复杂业务逻辑时,我们可能需要将多个模型聚合起来实现。 如何理解聚合的呢,我们可以通过下面的假设来解释:

场景:我们假设需要完成一个给账号充值的业务逻辑。 模型分析:我们需要用到的模型大致有三个,Account(账号), Order(订单), Property(资产)。 业务流程:1、检查对应的 Account 是否存在,不存在返回错误。2、检查充值订单 Order 是否重复,存在返回错误。3、开启事务,锁定 Property, 并更新对应的资产,将订单持久化。 4、事务完整结束后返回成功,否则返回失败。
code5 中简单模拟完成账号充值业务的业务逻辑,

package service

import (
	"context"
	"fmt"
	"gmb/internal/conf"
	"gmb/internal/dao"
	"gmb/internal/model"
	"gmb/pkg/gmberror"
	"gmb/pkg/log"
)

type OrderSrv struct {
	cfg *conf.Config
	logger log.Factory
}

var orderSrv *OrderSrv
// 初始化
func InitOrderSrv(cfg *conf.Config, logger log.Factory) {
	accSrv = &AccountSrv{
		cfg: cfg,
		logger: logger,
	}
}
// 获取服务
func GetOrderSrv() *AccountSrv {
	return accSrv
}
// 账户检查
func (srv *OrderSrv) checkAccount(ctx context.Context, accId int64) (*model.Property, gmberror.GMBError) {
	// 账户检查
	acc, err := dao.GetAccountRepo().GetAccountById(ctx, accId)
	if err != nil {
		return nil, gmberror.DBError(err)
	}
	if acc == nil {
		return nil, gmberror.InvalidAccount(fmt.Errorf("accout: %d not available", accId))
	}
	// 账户资产信息检查
	pro, err := dao.GetPropertyRepo().GetPropertyByAccId(ctx, acc.Id)
	if err != nil {
		return nil, gmberror.DBError(err)
	}
	if pro == nil {
		return nil, gmberror.InvalidAccount(fmt.Errorf("account: %d property not available", accId))
	}
	return pro, nil
}

// 创建订单
func (srv *OrderSrv) createOrder(ctx context.Context, accId, amount int64, payOrderId string) (*model.Order, gmberror.GMBError) {
	// 检查订单是否已经存在,(假设我们可以根据支付单判断订单是否重复
	order, err := dao.GetOrderRepo().GetOrderByPayOrder(ctx, accId, payOrderId)
	if err != nil {
		return nil, gmberror.DBError(err)
	}
	if order != nil {
		return nil, gmberror.InvalidOrder(fmt.Errorf("order has created"))
	}
	// 生成订单
	order = model.NewOrder(accId, amount, payOrderId)
	order.GenOrderNum()
	order.Id, err = dao.GetOrderRepo().CreateOrder(ctx, order)
	if err != nil {
		return nil, gmberror.DBError(err)
	}
	return order, nil
}

func (srv *OrderSrv) updateProperty(ctx context.Context, pro *model.Property, amt int64) gmberror.GMBError {
	pro.AddBalance(amt)
	err := dao.GetPropertyRepo().UpdateProperty(ctx, pro)
	if err != nil {
		return gmberror.DBError(err)
	}
	return nil
}
// 账户充值
func (srv *OrderSrv) AccountRecharge(ctx context.Context, accId, amount int64, payOrderId string) gmberror.GMBError {
	// TODO 开启事务
	// 1、 检查账户
	pro, GErr := srv.checkAccount(ctx, accId)
	if GErr != nil {
		return GErr
	}
	// 2、检查并创建订单
	_, GErr = srv.createOrder(ctx, pro.AccId, amount, payOrderId)
	if GErr != nil {
		return GErr
	}
	// 3、更新资产
	GErr = srv.updateProperty(ctx, pro, amount)
	if GErr != nil {
		return GErr
	}
	// TODO 结束事务
	return nil
}

code5

从上例中,我们可以发现,一个相对复杂的业务操作,往往都会涉及到多个 model 对象。需要将他们组合在一起共同完成。所以,在 Service 层定义抽象的时候,我们就不能像 Model 和 Dao 一样以最小的模型对象(最小领域单元)这样定义,需要从业务角度出发,将某一类动作抽象归为某一领域的业务逻辑。

例如:对于账号的注册,登陆,更新等操作,我们可以将其归为账号管理领域逻辑;对于账号的充值,消费,退款等,我们可将其归为账号订单管理领域逻辑。并为其各自定义一个结构体或接口,围绕其开发,例如在 code5 中定义的 OrderSrv 就是用于封装管理订单相关的逻辑。

为了减少循环应用问题的产生,在各个领域逻辑间尽量做到不相互应用。假如有类似的逻辑,我们可以在各个领域内各自实现。例如:code5 的方法账号检查 checkAccount, 在账号管理领域必然也会用到,我们可以在账号管理领域内重新实现一个类似方法,而不是直接引用订单领域内的方法。

1.3.4 object(对象聚合) 层

object 对象聚合,这里说的聚合不是逻辑层的聚合,而是对象的聚合。model 层定义的对象仅仅只是对数据库中定义的 Schema 字段的一一映射。
在实现业务逻辑时仅仅依赖 model 层定义的对象是难以满足逻辑层多变的业务逻辑的。为了保持开发灵活性,我们定义了一个 object 对象聚合层,或者更准确的是中间过渡对象层。
其定位和功能和有些框架中顶一个的 DTO (Data To Object) 层有一定相似,但又不仅仅是专门服务于业务层数据结构到 model 层数据结构的过渡,而是解决 model 层缺乏灵活性问题的一个补充。

例如:在 code5 中,我们将账号(Account), 账号资产(Property) 分别定义成了两个 model 层对象。然而很多时候,将其拆成两个 model 层对象并不方便我们实现业务逻辑,此时我们就有将他们合成一个对象的述求,如 code6 所示。

// 账号资产
type AccountWithProperty struct {
	Id int64 `json:"id"`
	// 昵称
	Nickname string `json:"nickname"`
	// 头像地址
	Avatar string `json:"avatar"`
	// 性别
	Gender int8 `json:"gender"`
	// 手机号
	Mobile string `json:"mobile"`
	//  余额
	Balance int64 `json:"balance"`
}

code6

有时我们又不需要一个拥有完整字段的 Account 对象,我们可以将原有 model 对象中字段作一定删减,成为一个新的对象。如 code7 所示:

// 账号 simple
type AccountSimple struct {
	Id int64 `json:"id"`
	// 昵称
	Nickname string `json:"nickname"`
	// 头像地址
	Avatar string `json:"avatar"`
	// 性别
	Gender int8 `json:"gender"`
	// 手机号
	Mobile string `json:"mobile"`
}

code7

总得来说,object 层同 model 层功能有一定类似,是个定义数据对象结构(struct)及其方法的层,但该层定义的数据对象不被数据表结构或其他范式所限制,是个相对自由的分层。object 层的引入可以最大限度的保证数据对象定义的灵活度。
从不好额角度来说,object 层中定义的数据对象在工程维护时间久之后,会变得相对混乱,因此需要相对小心的去维护,而不是将其当成一个垃圾桶,将一些临时数据结构都往里塞。

1.3.5 interface 接口层

接口 (interface) 层,是将服务实现的业务逻辑以接口形式向外暴露的一层,其功能是接收客户端传入的请求参数,将参数校验整理,格式化后传入逻辑(service)层,并将逻辑层返回的结果,格式化成客户端能够接受的数据格式返回。简单的来说,接口层的作用是客户端与逻辑层的一个承接层或过渡层。 例如,在 code8 中实现了与 code5 中账户充值方法 AccountRecharge 对应的 gRPC 接口。 首先是定义 gRPC 接口对应的 proto 文件:

// 充值请求
message AccountRechargeReq {
  // 账号id
  int64 acc_id = 1;
  // 充值金额
  uint64 amount = 2;
  // 支付单id
  string pay_order_id = 3;
}
message AccountRechargeReply {
  // message
  string message = 1;
}
// 服务方法定义
service Account {
  // 充值定义
  rpc AccountRecharge (AccountRechargeReq) returns (AccountRechargeReply) {}
}

在 golang 中实现 proto 中定义的 AccountRecharge 处理函数, 在handler 函数中,我们传入的请求对象 in 中的参数整理后,调用逻辑层中实现的 AccountRecharge 方法进行处理,将处理结果返回,没有错误返回成功,有错误返回错误详情。

func (h *rpcHandler) AccountRecharge(ctx context.Context, in *pb.AccountRechargeReq) (*pb.AccountRechargeReply, error) {
	if in == nil {
		return nil, nil
	}
	errG := service.GetOrderSrv().AccountRecharge(ctx, in.AccId, int64(in.Amount), in.PayOrderId)
	if errG != nil {
		return nil, errG
	}
	return &pb.AccountRechargeReply{
		Message: "OK",
	}, nil
}

code8

对于 Rest(http) 接口实现上又有什么差异吗?,如下code9 所示, 与 gRPC handler 函数的差异主要就存在于请求参数解析和返回结果的构建,其他基本没有差异。此外,对于请求参数的结构体,我们可以直接采用 proto 文件生成的对应的 struct 结构体。

// 充值实现
func (h *httpHandler) recharge(c echo.Context) error {
	ctx := c.Request().Context()
	req := &pb.AccountRechargeReq{}
	if err := c.Bind(req); err != nil {
		return gmberror.InvalidRequest(err)
	}
	errG := service.GetOrderSrv().AccountRecharge(ctx, req.AccId, int64(req.Amount), req.PayOrderId)
	if errG != nil {
		return errG
	}
	return c.JSON(http.StatusOK, &pb.AccountRechargeReply{Message:"OK"})
}

code9

对于 MQ(消息)消费而言, 其 handler 函数实现基本也一样 例如 code10,差异就是相比于 Rest, gRPC,Mq handler 函数不需要返回处理结果,只需要返回错误即可

// 充值实现
func (m *mqHandler) recharge(ctx context.Context, msg *nsq.Message) error {
	req := &pb.AccountRechargeReq{}
	err := json.Unmarshal(msg.Body, req)
	if err != nil {
		return gmberror.InvalidRequest(err)
	}
	return service.GetOrderSrv().AccountRecharge(ctx, req.AccId, int64(req.Amount), req.PayOrderId)
}

code10

通过对比,gRpc, Rest, MQ 三种 handler 函数来看,他们承接的基本功能都是,接收请求参数,解析请求参数,将参数校验整理后传入逻辑层处理,再将处理结果返回。我们可以发现,在接口层,基本不涉及业务逻辑的处理,而且三个 handler 函数在处理同一种业务逻辑时,调用的逻辑层方法都是同一个。从另一个层面来说,接口层的目的是为逻辑层屏蔽了不同服务接口间的差异。

接口层的 handler 中除了实现参数层面的处理外,接口认证授权也可以在 handler 函数中实现。因为,不同服务接口的传递认证信息的习惯或方式不一定一致的,在 Rest 接口中习惯于将 token 放在 请求头中的 Authorization 字段中传递,而 gRPC 接口可能就是直接在请求参数或原信息(metadata) 中传递,MQ 消息对应的业务场景可能就没有认证授权的需要了。将认证授权放在接口层中实现,是一种相对合适的选择,逻辑层只需要关注于业务逻辑的实现。

目前,有很多框架中直接通过代码生成的方式实现了接口层对应的 handler 函数,例如:grpc-gateway, kratos,采用这两种框架,我们就无须分别为 gRPC 和 Rest 接口实现一遍 handler 函数了,甚至于 handler 函数都可以不用编写。但是其缺点也比较明显,就是实现自由度相对较低,由于代码是直接生成的,框架侧必然需要对 gRPC 和 Rest 接口作一定的抽象,各自的特点做一定程度的牺牲。 比如有些接口,我只想要 Rest 接口而不需要 gRPC 接口就无法实现。

对比 code8, code9code10 三段代码分析,handler 层的实现工作量相对来说并不高,在良好的封装下代码量会进一步降低,比如参数校验可以统一采用 validator 库。多了这一部分不大的工作量,但我们可以保留 Rest, gRPC 接口各自拥有的全部特性,在处理差异时也更加方便。

二、总结

至此,整个基于 golang 搭建微服务开发框架,或者更务实点说是开发脚手架的系列文章已经结束。本章的整合[示例代码]((https://github.com/FeifeiyuM/go-microservices-boilerplate/tree/main/chapter5)。示例代码不一定可以百分百运行,读者在使用时需要根据自己情况调试。

本系列文章,侧重的并不是实现一个完整的开发脚手架,或者说是一个依赖库,而更像是一个教学系列文章,或者是经验的总结,笔者认为,其最大的特点是对 Rest, RPC, MQ 三大常用的服务类型进行了整合,再保留这个特性的同时,又通过中间件的方式(详见:三、微服务框架中间件(拦截器)的实现)让他们的开发流程或体验最大趋同(最主要体现在 接口层的 handler 函数)。

此外,笔者在文章中多次计提及封装一词,从笔者自身的开发感受来说,在服务中对可能会出现大量重复的代码做一个封装,可以大大减轻开发工作量,提高开发体验。例如:于一些简单的功能,时间格式转换,手机号,邮箱校验等,我们不应是第一时间去找没有对应的第三方依赖对此有封装。
对比于使用第三方依赖库,自己开发的代码在遇到问题时,由于代码对于大家都是可以直接接触到的,自己或同事都可以快速的定位到问题并解决,第三方库就没这么方便了。

欢迎向笔者反馈问题和建议,邮箱:[email protected]