上篇文章基本讲清楚了 Web Server 如何接收客户端请求,以及如何将请求流转到 gin 的逻辑。
gin 原理剖析说到这里,已经拿到 http 请求了,第一件重要的事情肯定就是匹配查找路由了,所以本节内容主要是分析 gin 的路由相关的内容。
其实 gin 的路由也不是完全自己写的,其实很重要的一部分代码是使用的开源的 julienschmidt/httprouter,当然 gin 也添加了部分自己独有的功能,如:routergroup。
什么是路由?
这个其实挺容易理解的,就是根据不同的 URL 找到对应的处理函数即可。
目前业界 Server 端 API 接口的设计方式一般是遵循 RESTful 风格的规范。当然我也见过某些大公司为了降低开发人员的心智负担和学习成本,接口完全不区分 GET/POST/DELETE 请求,完全靠接口的命名来表示。
举个简单的例子,如:"删除用户"
RESTful: DELETE /user/hhf
No RESTful: GET /deleteUser?name=hhf
这种 No RESTful 的方式,有的时候确实减少一些沟通问题和学习成本,但是只能内部使用了。这种不区分 GET/POST 的 Web 框架一般设计的会比较灵活,但是开发人员水平参差不齐,会导致出现很多“接口毒瘤”,等你发现的时候已经无可奈何了,如下面这些接口:
GET /selectUserList?userIds=[1,2,3] -> 参数是否可以是数组?
GET /getStudentlist?skuIdCntMap={"200207366":1} -> 参数是否可以是字典?
这样的接口设计会导致开源的框架都是解析不了的,只能自己手动一层一层 decode 字符串,这里就不再详细铺开介绍了,等下一节说到 gin Bind 系列函数时再详细说一下。
继续回到上面 RESTful 风格的接口上面来,拿下面这些简单的请求来说:
GET /user/{userID} HTTP/1.1
POST /user/{userID} HTTP/1.1
PUT /user/{userID} HTTP/1.1
DELETE /user/{userID} HTTP/1.1
这是比较规范的 RESTful API设计,分别代表:
获取 userID 的用户信息 更新 userID 的用户信息(当然还有其 json body,没有写出来) 创建 userID 的用户(当然还有其 json body,没有写出来) 删除 userID 的用户
可以看到同样的 URI,不同的请求 Method,最终其他代表的要处理的事情也完全不一样。
看到这里你可以思考一下,假如让你来设计这个路由,要满足上面的这些功能,你会如何设计呢?
gin 路由设计
如何设计不同的 Method ?
通过上面的介绍,已经知道 RESTful 是要区分方法的,不同的方法代表意义也完全不一样,gin 是如何实现这个的呢?
其实很简单,不同的方法就是一棵路由树,所以当 gin 注册路由的时候,会根据不同的 Method 分别注册不同的路由树。
GET /user/{userID} HTTP/1.1
POST /user/{userID} HTTP/1.1
PUT /user/{userID} HTTP/1.1
DELETE /user/{userID} HTTP/1.1
如这四个请求,分别会注册四颗路由树出来。
func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
//....
root := engine.trees.get(method)
if root == nil {
root = new(node)
root.fullPath = "/"
engine.trees = append(engine.trees, methodTree{method: method, root: root})
}
root.addRoute(path, handlers)
// ...
}
其实代码也很容易看懂,
拿到一个 method 方法时,去 trees slice 中遍历 如果 trees slice 存在这个 method, 则这个URL对应的 handler 直接添加到找到的路由树上 如果没有找到,则重新创建一颗新的方法树出来, 然后将 URL对应的 handler 添加到这个路由 树上
gin 路由的注册过程
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run() // listen and serve on 0.0.0.0:8080
}
这段简单的代码里,r.Get 就注册了一个路由 /ping 进入 GET tree 中。这是最普通的,也是最常用的注册方式。
不过上面这种写法,一般都是用来测试的,正常情况下我们会将 handler 拿到 Controller 层里面去,注册路由放在专门的 route 管理里面,这里就不再详细拓展,等后面具体说下 gin 的架构分层设计。
//controller/somePost.go
func SomePostFunc(ctx *gin.Context) {
// do something
context.String(http.StatusOK, "some post done")
}
// route.go
router.POST("/somePost", controller.SomePostFunc)
使用 RouteGroup
v1 := router.Group("v1")
{
v1.POST("login", func(context *gin.Context) {
context.String(http.StatusOK, "v1 login")
})
}
RouteGroup 是非常重要的功能,举个例子:一个完整的 server 服务,url 需要分为鉴权接口和非鉴权接口,就可以使用 RouteGroup 来实现。其实最常用的,还是用来区分接口的版本升级。这些操作, 最终都会在反应到gin的路由树上
gin 路由的具体实现
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run() // listen and serve on 0.0.0.0:8080
}
还是从这个简单的例子入手。我们只需要弄清楚下面三个问题即可:
URL->ping 放在哪里了? handler-> 放在哪里了? URL 和 handler 是如何关联起来的?
1. GET/POST/DELETE/..的最终归宿
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
return group.handle(http.MethodGet, relativePath, handlers)
}
在调用POST
, GET
, HEAD
等路由HTTP相关函数时, 会调用handle
函数。handle 是 gin 路由的统一入口。
// routergroup.go:L72-77
func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
absolutePath := group.calculateAbsolutePath(relativePath)
handlers = group.combineHandlers(handlers)
group.engine.addRoute(httpMethod, absolutePath, handlers)
return group.returnObj()
}
2. 生成路由树
下面考虑一个情况,假设有下面这样的路由,你会怎么设计这棵路由树?
GET /abc
GET /abd
GET /af
当然最简单最粗暴的就是每个字符串占用一个树的叶子节点,不过这种设计会带来的问题:占用内存会升高,我们看到 abc, abd, af 都是用共同的前缀的,如果能共用前缀的话,是可以省内存空间的。
gin 路由树是一棵前缀树. 我们前面说过 gin 的每种方法(POST, GET ...)都有自己的一颗树,当然这个是根据你注册路由来的,并不是一上来把每种方式都注册一遍。gin 每棵路由大概是下面的样子
这个流程的代码太多,这里就不再贴出具体代码里,有兴趣的同学可以按照这个思路看下去即可。
3. handler 与 URL 关联
type node struct {
path string
indices string
wildChild bool
nType nodeType
priority uint32
children []*node // child nodes, at most 1 :param style node at the end of the array
handlers HandlersChain
fullPath string
}
node 是路由树的整体结构
children 就是一颗树的叶子结点。每个路由的去掉前缀后,都被分布在这些 children 数组里 path 就是当前叶子节点的最长的前缀 handlers 里面存放的就是当前叶子节点对应的路由的处理函数
当收到客户端请求时,如何找到对应的路由的handler?
说到 net/http 非常重要的函数 ServeHTTP,当 server 收到请求时,必然会走到这个函数里。由于 gin 实现这个 ServeHTTP,所以流量就转入 gin 的逻辑里面。
// gin.go:L439-443
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := engine.pool.Get().(*Context)
c.writermem.reset(w)
c.Request = req
c.reset()
engine.handleHTTPRequest(c)
engine.pool.Put(c)
}
所以,当 gin 收到客户端的请求时, 第一件事就是去路由树里面去匹配对应的 URL,找到相关的路由, 拿到相关的处理函数。其实这个过程就是 handleHTTPRequest 要干的事情。
func (engine *Engine) handleHTTPRequest(c *Context) {
// ...
t := engine.trees
for i, tl := 0, len(t); i < tl; i++ {
if t[i].method != httpMethod {
continue
}
root := t[i].root
// Find route in tree
value := root.getValue(rPath, c.params, unescape)
if value.params != nil {
c.Params = *value.params
}
if value.handlers != nil {
c.handlers = value.handlers
c.fullPath = value.fullPath
c.Next()
c.writermem.WriteHeaderNow()
return
}
if httpMethod != "CONNECT" && rPath != "/" {
if value.tsr && engine.RedirectTrailingSlash {
redirectTrailingSlash(c)
return
}
if engine.RedirectFixedPath && redirectFixedPath(c, root, engine.RedirectFixedPath) {
return
}
}
break
}
// ...
}
从代码上看这个过程其实也很简单:
遍历所有的路由树,找到对应的方法的那棵树 匹配对应的路由 找到对应的 handler
总结
说到这里,基本上把 gin 路由的整个流程说清楚了,写文章不易,如果你觉得本篇文章还不错,请大家帮忙 点赞、在看、分享,感谢感谢。
最后推荐一下我的Go项目实战专栏
本课程是教大家用Go语言从零开始搭建项目和做需求开发的实战课程,使用的技术栈均为实际开发所常用的组件和框架如:Gin、Viper、Zap、GORM、go-redis 、lo 等等。
课程分为五大部分:
第一部分介绍让框架变得好用的诸多实战技巧,比如通过自定义日志门面让项目日志更简单易用、支持自动记录请求的追踪信息和程序位置信息、通过自定义Error在实现Go error接口的同时支持给给错误添加错误链,方便追溯错误源头。 第二部分:讲解项目分层架构的设计和划分业务模块的方法和标准,让你以后无论遇到什么项目都能按这套标准自己划分出模块和逻辑分层。后面几个部分均是该部分所讲内容的实践。 第三部分:设计实现一个套支持多平台登录,Token泄露检测、同平台多设备登录互踢功能的用户认证体系,这套用户认证体系既可以在你未来开发产品时直接应用 第四部分:商城app C端接口功能的实现,强化分层架构实现的讲解,这里还会讲解用责任链、策略和模版等设计模式去解决订单结算促销、支付方式支付场景等多种多样的实际问题。 第五部分:单元测试、项目Docker镜像、K8s部署和服务保障相关的一些基础内容和注意事项
具体的章节想去课扫码上方的海报二维码或者访问
https://xiaobot.net/p/golang
点击下方阅读原文即可跳转。