09|自研or借力(下):集成Gin替换已有核心

思考并回答以下问题:

借鉴和使用Gin框架作为我们的底层框架基本思路是,以复制的形式将Gin框架引入到我们的自研框架中,替换和修改之前实现的几个核心模块。

如何将Gin迁移进入我们的框架

Gin的版本确定为1.7.3。

在Golang中,要在一个项目中引入另外一个项目,一般有两种做法,一种做法是把要引用的项目通过go mod引入到目标库中,而另外一种做法则费劲的多,使用复制源码的方式引入要引用的项目

go mod是Go官方提供的第三方库管理工具。go mod的使用方式是在代码方面,也就是在你要使用第三方库功能的时候,使用import关键字进行引用。它的好处是简单方便,我们直接使用官方提供的go get方法,就能将某个框架进行使用和升级。

但是如果希望对第三方库进行源码级别的修改,go mod的方式就会受到限制了。比如我们使用了Gin项目,想扩展项目中的Context这个结构,为其增加一个函数方法A,这个需求用go mod的方式是做不到的。

go mod的方式提倡的是“使用第三方库”而不是“定制第三方库”。所以对于很强的定制第三方库的需求,我们只能选择复制源码的方式

首先将Gin1.7.3的源码完整复制进入项目,存放在framework目录中创建一个gin目录。

复制之后需要做两步操作:

  • 将Gin目录下的go.mod的内容放在项目目录下

既然我们以复制源码的方式引入了Gin,那么Gin的地址就成为我们项目中的一部分,它就不是一个有go.mod的第三方库了,而Gin原本引入的第三方库,成为了我们项目所需要的第三方库。所以第一步需要将Gin目录下的go.mod和go.sum删除,并且将go.mod的内容复制到项目的根目录中。

  • 将Gin中原有Gin库的引用地址,统一替换为当前项目的地址

go.mod中的module代表了当前项目的模块名称,而在项目的go.mod中,我们将我们这个框架的模块名称从“coredemo”修改为“github.com/gohade/hade”。在项目的go.mod文件:

1
2
3
4
5
6
7
module github.com/gohade/hade

go 1.15

require (
...
)

开源项目的普遍做法是,将模块名定义为你的开源地址名称,这样能让开源项目使用者在导入包的时候,直接根据模块名查找到对应文件。

所有第三方引用在查询时,go.mod中的当前模块名称为github.com/gohade/hade,那么复制过来之后,对应的Gin框架的引用地址就是github.com/gohade/hade/framework/gin,而Gin框架之前go.mod中的模块名称为github.com/gin-gonic/gin。

所以我们这里要做一次统一替换,将之前Gin框架中的所有引用github.com/gin-gonic/gin的地方替换为github.com/gohade/hade/framework/gin。

比如:

1
2
"github.com/gin-gonic/gin/binding"  替换为 "github.com/gohade/hade/framework/gin/binding"
"github.com/gin-gonic/gin/render" 替换为 "github.com/gohade/hade/framework/gin/render"

这里就要借用IDE的强大替换工具了,进行一次批量替换。

做完上述两步的操作之后,我们的项目github.com/gohade/hade就包含了Gin1.7.3了。下面就是重头戏了,需要思考如何将之前研发的定制化功能迁移到Gin上。

如何迁移

首先,梳理下目前已经实现的模块:

  • Context:请求控制器,控制每个请求的超时等逻辑;
  • 路由:让请求更快寻找目标函数,并且支持通配符、分组等方式制定路由规则;
  • 中间件:能将通用逻辑转化为中间件,并串联中间件实现业务逻辑;
  • 封装:提供易用的逻辑,把request和response封装在Context结构中;
  • 重启:实现优雅关闭机制,让服务可以重启。

在Gin的框架中,Context、路由、中间件,都已经有了Gin自己的实现,而且我们从源码上分析了细节。

Context方面,Gin的实现基本和我们之前的实现是一致的。之前实现的Core数据结构对应Gin中的Engine,Group数据结构对应Gin的Group结构,Context数据结构对应Gin的Context数据结构。

路由方面,Gin的路由实现得比我们要好,这一点上一节课详细分析了Gin路由就不再赘述。

中间件方面,Gin的中间件实现和我们之前的没有什么太大的差别,只有一点,我们定义的Handler为:

1
type ControllerHandler func(c *Context) error

而Gin定义的Handler为:
1
type HandlerFunc func(*Context)

可以看到相比Gin,我们多定义了一个error返回值。因为Gin的作者认为,中断一个请求返回一个error并没有什么用,他希望中断一个请求的时候直接操作Response,比如设置返回状态码、设置返回错误信息,而不希望用error来进行返回,所以框架也不会用这个error来操作任何的返回信息。这一点我认为Gin框架的考量是有道理的,所以我们也沿用这种方式。而对于Request和Response的封装,Gin的实现比较简陋。Gin对Request并没有以接口的方式,

将Request支持哪些接口展示出来;并且在参数查询的时候,返回类型并不多,比如从Form中获取参数的系列方法,Gin只实现了几个方法:

1
2
3
4
5
6
7
PostForm
DefaultPostForm
GetPostForm
PostFormArray
GetPostFormArray
PostFormMap
GetPostFormMap

但是在我们定义的Request中,我们实现了按照不同类型获取参数的方法。
1
2
3
4
5
6
7
8
9
10
// form表单中带的参数
DefaultFormInt(key string, def int) (int, bool)
DefaultFormInt64(key string, def int64) (int64, bool)
DefaultFormFloat64(key string, def float64) (float64, bool)
DefaultFormFloat32(key string, def float32) (float32, bool)
DefaultFormBool(key string, def bool) (bool, bool)
DefaultFormString(key string, def string) (string, bool)
DefaultFormStringSlice(key string, def []string) ([]string, bool)
DefaultFormFile(key string) (*multipart.FileHeader, error)
DefaultForm(key string) interface{}

而且在Response中,我们的设计是带有链式调用方法的,而Gin中没有。
1
c.SetOkStatus().Json("ok, UserLoginController: " + foo)

这两点在使用者使用框架开发具体业务的时候会非常便利,所以我们将这些功能集成到Gin中,迁移这部分Request和Response的封装。

最后一个优雅关闭的逻辑,我们和Gin都是直接使用HTTP库的server.Shutdown实现的,不受Gin代码迁移的影响。

所以再明确下,context、路由、中间件、重启机制,我们都不需要迁移,唯一需要迁移的是对Request和Response的封装。

使用加法最小化和定制化我们的需求

对于request和response的封装,涉及易用性,我们希望能保留自己的定制化需求,同时又不希望影响Gin原有的代码逻辑。所以,可以用加法最小化的方式迁移这个封装。

为了尽量不侵入原有的文件,我们创建两个新的文件hade_request.go、hade_response.go来存放之前对request和response的封装。

回顾下第五节课封装的request,定义的IRequest接口包含:通过地址URL获取参数的QueryXXX系列接口、通过路由匹配获取参数的ParamXXX系列接口、通过Form表单获取参数的FormXXX系列接口,以及一些基础接口。

所以现在的目标是,要让Gin框架的Context也实现这些接口。对比之前写的和Gin框架原有的实现方法,可以发现,接口存在下列三种情况:

1,Gin框架中已经存在相同参数、相同返回值、相同接口名的接口
2,Gin框架中不存在相同接口名的接口
3,Gin框架中存在相同接口名,但是不同返回值的接口

第一种情况,由于Gin框架原先就已经有了相同的接口,所以不需要做任何迁移动作,Gin的Context就已经具备了我们设计的封装。对第二种情况来说,由于Gin框架没有对应接口,我们把之前实现的接口原封不动迁移过来即可。

对于第三种情况则棘手一些。以Gin中已经有的QueryXXX系列接口为例,它的QueryXXX系列接口和我们想要的有一定差别,比如它的Query不支持多种数据类型的直接获取。怎么办?

可以选择将QueryXXX系列接口重新命名,又因为Query接口都带有一个默认值,所以我们将其重新命名为DefaultQueryXXX。

经过上述三种情况的修改,IRequest的定义修改为(在框架目录的framework/gin/hade_request.go中):

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
47
48
49
50
51
52
53
54
55
56
57
// 代表请求包含的方法
type IRequest interface {

// 请求地址url中带的参数
// 形如: foo.com?a=1&b=bar&c[]=bar
DefaultQueryInt(key string, def int) (int, bool)
DefaultQueryInt64(key string, def int64) (int64, bool)
DefaultQueryFloat64(key string, def float64) (float64, bool)
DefaultQueryFloat32(key string, def float32) (float32, bool)
DefaultQueryBool(key string, def bool) (bool, bool)
DefaultQueryString(key string, def string) (string, bool)
DefaultQueryStringSlice(key string, def []string) ([]string, bool)

// 路由匹配中带的参数
// 形如 /book/:id
DefaultParamInt(key string, def int) (int, bool)
DefaultParamInt64(key string, def int64) (int64, bool)
DefaultParamFloat64(key string, def float64) (float64, bool)
DefaultParamFloat32(key string, def float32) (float32, bool)
DefaultParamBool(key string, def bool) (bool, bool)
DefaultParamString(key string, def string) (string, bool)
DefaultParam(key string) interface{}

// form表单中带的参数
DefaultFormInt(key string, def int) (int, bool)
DefaultFormInt64(key string, def int64) (int64, bool)
DefaultFormFloat64(key string, def float64) (float64, bool)
DefaultFormFloat32(key string, def float32) (float32, bool)
DefaultFormBool(key string, def bool) (bool, bool)
DefaultFormString(key string, def string) (string, bool)
DefaultFormStringSlice(key string, def []string) ([]string, bool)
DefaultFormFile(key string) (*multipart.FileHeader, error)
DefaultForm(key string) interface{}

// json body
BindJson(obj interface{}) error

// xml body
BindXml(obj interface{}) error

// 其他格式
GetRawData() ([]byte, error)

// 基础信息
Uri() string
Method() string
Host() string
ClientIp() string

// header
Headers() map[string]string
Header(key string) (string, bool)

// cookie
Cookies() map[string]string
Cookie(key string) (string, bool)
}

IRequest的封装就迁移完成了,对于我们封装的IResponse结构,也是同样的思路。

和Gin的response实现对比之后,我们发现由于设计了一个链式调用,很多方法的返回值使用IResponse接口本身,所以大部分定义的IResponse的接口,在Gin中都有同样接口名,但是返回值不同。所以我们可以用同样的方式来修改接口名。因为大都返回IResponse接口,那么可以在所有接口名前面,加一个I字母作为区分。在framework/gin/hade_response.go中:

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
// IResponse代表返回方法
type IResponse interface {
// Json输出
IJson(obj interface{}) IResponse

// Jsonp输出
IJsonp(obj interface{}) IResponse

//xml输出
IXml(obj interface{}) IResponse

// html输出
IHtml(template string, obj interface{}) IResponse

// string
IText(format string, values ...interface{}) IResponse

// 重定向
IRedirect(path string) IResponse

// header
ISetHeader(key string, val string) IResponse

// Cookie
ISetCookie(key string, val string, maxAge int, path, domain string, secure, httpOnly bool) IResponse

// 设置状态码
ISetStatus(code int) IResponse

// 设置200状态
ISetOkStatus() IResponse
}

现在IRequest和IResponse接口的修改已经完成了。下面我们就应该迁移每个接口的具体实现。这里接口的实现比较多,就不一一赘述,Request和Response我们分别举其中一个接口例子进行说明,其他的接口迁移可以具体参考GitHub仓库的geekbang/09分支。在Request中我们定义了一个DefaultQueryInt方法,是Gin框架中没有的,怎么迁移这个接口呢?首先将之前定义的QueryInt迁移过来,并重新命名为DefaultQueryInt。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 获取请求地址中所有参数
func (ctx *Context) QueryAll() map[string][]string {
if ctx.request != nil {
return map[string][]string(ctx.request.URL.Query())
}
return map[string][]string{}
}

// 获取Int类型的请求参数
func (ctx *Context) DefaultQueryInt(key string, def int) (int, bool) {
params := ctx.QueryAll()
if vals, ok := params[key]; ok {
if len(vals) > 0 {
// 使用cast库将string转换为Int
return cast.ToInt(vals[0]), true
}
}
return def, false
}

然后这里看QueryAll函数,其实是可以优化的。Gin框架在获取QueryAll的时候,使用了一个QueryCache,实现了在第一次获取参数的时候,用方法initQueryCache将ctx.request.URL.Query()缓存在内存中。所以既然已经源码引入Gin框架了,我们也是可以使用这个方法来优化QueryAll()方法的,先调用initQueryCache,再直接从queryCache中返回参数:
1
2
3
4
5
// 获取请求地址中所有参数
func (ctx *Context) QueryAll() map[string][]string {
ctx.initQueryCache()
return map[string][]string(ctx.queryCache)
}

这样QueryAll方法和DefaultQueryInt方法就都迁移改造完成了。在Response中,我们没有需要优化的点,只要将代码迁移就行。比如原先定义的Jsonp方法:
1
2
3
4
// Jsonp输出
func (ctx *Context) Jsonp(obj interface{}) IResponse {
...
}

直接修改函数名为IJsonp即可:
1
2
3
4
// Jsonp输出
func (ctx *Context) IJsonp(obj interface{}) IResponse {
...
}

接口和实现都修改了,最后肯定还要对应修改下业务代码中之前定义的一些控制器。第一个是控制器的参数,从framework.Context修改为gin.Context,这里的gin是引用github.com/gohade/hade/framework/gin,还有把之前定义的Handler的error返回值删除。第二个是修改里面的调用,因为现在的Response方法都带上了一个前缀I。比如在业务目录下subject_controller.go中,把原先的SubjectListController:
1
2
3
4
func SubjectListController(c *framework.Context) error {
c.SetOkStatus().Json("ok, SubjectListController")
return nil
}

修改为:
1
2
3
func SubjectListController(c *gin.Context) {
c.ISetOkStatus().IJson("ok, SubjectListController")
}

验证

所有修改完成之后,我们可以通过test来进行验证,调用go test ./...来运行Gin程序的所有测试用例,显示成功则表示我们的迁移成功。

并且我们通过go build && ./hade可以看到熟悉的gin调试模式的输出:

小结

今天我们讨论几个开源项目的许可协议,比如Gin框架使用的MIT协议,在明确修改权限后,我们将Gin框架迁移进自己手写的hade框架,替换前面开发的Context、路由等模块,为后续拓展做好准备。

在迁移的过程中,我们选择使用复制源码的方式进行替换,并且用了加法最小化的方法,尽量保留了我们的定制化接口。可能有的同学会觉得这种方式比较暴力,但是后续随着我们对框架的改造需求不断增加,这种方式会越来越体现出其优势。

思考题

我们的hade框架也希望用MIT协议进行开源,你知道如何改造来将它开源么?

0%