「Go框架」深入理解iris框架的路由底层结构

yudotyang · · 2266 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

大家好,我是渔夫子。本号新推出「Go工具箱」系列,意在给大家分享使用go语言编写的、实用的、好玩的工具。同时了解其底层的实现原理,以便更深入地了解Go语言。 iris框架号称是最快的web框架。今天就来深入的研究下iris框架路由的底层实现原理。 那为什么需要深入了解web框架的路由呢?路由是web框架的核心。在业务开发中,我们在使用框架时,基本就是在注册路由、使用中间件、然后写对应的业务逻辑。那么注册路由、使用中间件都跟路由实现有关。所以,理解了一个web框架的路由底层实现逻辑,基本也就掌握了该框架的实现原理。 ## 一、iris的基本使用 我们先来看下使用iris框架如何注册路由以及启动服务。如下代码: ```go package main import ( "github.com/kataras/iris/v12" ) func main() { app := iris.New() app.Get("/home", Home) app.Listen(":8080") } func Home(ctx iris.Context) { ctx.Write([]byte("Hi, this is iris home")) } ``` 代码很简单,最基本的使用有三步:通过iris.New()构建一个iris的application、注册路由、启动服务。在浏览器中输入` http://localhost:8080/home `,即可输出 `"Hi, this is iris home" `。 ## 二、iris路由实现原理 ### 2.1 从iris.New说起 首先,我们看`iris.New`函数的作用。该函数就是创建了一个`Application`结构体的实例` app`。然后后面的操作都是基于该实例 `app `进行的操作。下面是该Application结构体的主要字段,这里只列出了和路由相关的主要字段,忽略其他字段。 ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673160167294-a8868e25-cccc-41a5-add7-f1d4bca6c270.png#averageHue=%23bababa&clientId=u74ed42a0-996f-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=236&id=u60c33f61&margin=%5Bobject%20Object%5D&name=image.png&originHeight=944&originWidth=884&originalType=binary&ratio=1&rotation=0&showTitle=false&size=63315&status=done&style=none&taskId=u4dcd38c6-1687-4cb6-bf1d-ca595978805&title=&width=221) 在Application的字段中,从名字上看有两个字段是和路由相关的:` router.APIBuilder `和 ` router.Router `。那我们接着再分别看下这两个结构体的主要构成。 如下是`router.APIBuilder`结构体的主要字段及其相关联的结构体: ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673162437708-3148bb53-025b-4f48-9017-d4a993eaba19.png#averageHue=%23747272&clientId=u74ed42a0-996f-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=366&id=ubee63c78&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1464&originWidth=2444&originalType=binary&ratio=1&rotation=0&showTitle=false&size=196255&status=done&style=none&taskId=uc6529f8f-389b-46ab-a54d-fe767699b36&title=&width=611) 从`router.APIBuilder`结构体及其相关的`reporitory`和`Route`结构体可以看到,这里包含了路由的相关信息。例如,在`repository`中的`routes`字段,代表所有的路由,可以简单理解为一个建议的路由表。在`Route`结构体中包含了请求方法`Method`字段、请求路径字段`Path`、对应的请求处理函数`Handlers`字段。其中还有`macro.Template`类型的`Tmp`字段是针对正则路由的正则表达式。 在`Application`结构体中还有一个字段是`router.Router`字段,看名字这个是路由表,但实际上该字段中没有包含任何路由相关的信息。我们通过该结构体相关的方法列表可以发现,该结构体中有一个`ServeHTTP`方法,在`web`框架的请求流程一文中我们讲解过该方法是`go`中处理`HTTP` 请求的入口方法。如下: ```go func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { router.mainHandler(w, r) } ``` 也就是说,`router.Router`主要功能是`iris`框架处理`http`请求的入口,核心是基于路由表进行路由匹配,并执行对应的请求处理函数。以下是`router.Router`结构体的主要字段: ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673163679181-a476592e-9bc6-4cfa-bef6-4b3236b4d41e.png#averageHue=%237fa1b1&clientId=u74ed42a0-996f-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=150&id=u60feaa38&margin=%5Bobject%20Object%5D&name=image.png&originHeight=600&originWidth=888&originalType=binary&ratio=1&rotation=0&showTitle=false&size=41140&status=done&style=none&taskId=uf7916676-daaf-4168-819c-710230fa1a2&title=&width=222) 最后,我们看下通过`iris.New`构造的`Application`对象包含了存储路由相关的信息。如下: ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673163713258-b4aaeaa1-bef3-45ef-a4b5-19f98be018c5.png#averageHue=%233f3e3e&clientId=u74ed42a0-996f-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=1162&id=uce003dbd&margin=%5Bobject%20Object%5D&name=image.png&originHeight=2324&originWidth=4124&originalType=binary&ratio=1&rotation=0&showTitle=false&size=407199&status=done&style=none&taskId=u3d0cdbe8-d75f-4c3c-b357-f2c02d98cfe&title=&width=2062) 由此可见,通过`iris.New`构建的`Application`对象实际上包含了处理请求的`router.Router`以及管理路由的信息`router.APIBuiler`。后续的路由注册以及启动服务都是基于这个`Application`对象的。 当然,这里的`router.APIBuilder`中的`routes`并非是最终的路由表。在`iris`中,会在服务的启动阶段,即`app.Run`函数中将`APIBuilder.routes`中的路由再转换成基于前缀树结构的路由表,以提高检索的速度。这个咱们在启动服务部分再仔细讲解。 接下来我们看路由注册部分。 ### 2.2 路由注册 实例化完`Application`对象,接着就是路由注册了。也就是类似下面的代码: ```go app := iris.New() app.Get("/home", HomeHandler) // 这里就是按照iris的路由规则定义的请求处理函数 func HomeHandler(ctx iris.Context) { ctx.Write([]byte("Hi, this is iris home")) } ``` 我们主要看` app.Get("/home", HomeHandler) `这个函数的实现。进入该`Get`函数的源码,发现调用者是`APIBuilder`结构体,如下: ```go func (api *APIBuilder) Get(relativePath string, handlers ...context.Handler) *Route { return api.Handle(http.MethodGet, relativePath, handlers...) } ``` 这是因为在`Application`结构体中嵌套了`router.APIBuilder`结构体,所以`Application`自然也就嵌套了`APIBuilder`结构体的所有方法。 在`Get`的这个方法中,我们看第二个参数`handlers`的类型是`context.Handler`,其定义如下是` type Handler func(*Context)�`,这就是为什么我们把`HomeHandler`定义这种类型的原因。本质上也可以说没有为什么,就是iris框架这么规定的。 我们再接着源代码往下看,会看到如下代码,根据请求的方法、路径以及请求处理函数创建一个路由对象,然后将该路由对象加入到`APIBuilder`的路由表`routes`中。 ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673242717187-1dfdd62c-511c-4b2b-a939-4e7527a0d17d.png#averageHue=%23030303&clientId=u74ed42a0-996f-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=152&id=ua3a7212b&margin=%5Bobject%20Object%5D&name=image.png&originHeight=608&originWidth=1288&originalType=binary&ratio=1&rotation=0&showTitle=false&size=51565&status=done&style=none&taskId=u0859aae2-abd7-4299-b9b0-5b0462653f6&title=&width=322) 如下是对应的源码: ```go func (api *APIBuilder) handle(errorCode int, method string, relativePath string, handlers ...context.Handler) *Route { // 创建路由,返回来的是一个路由数组 // 因为传递的请求方法也是一个数据,一个请求方法对应一个路由, // 所有返回的routes就是数组。这里method只有一个方法,所以routes数组就只有一个元素 routes := api.createRoutes(errorCode, []string{method}, relativePath, handlers...) var route *Route // the last one is returned. var err error for _, route = range routes { if route == nil { continue } // global route.topLink = api.routes.getRelative(route) // 将route添加到路由表 if route, err = api.routes.register(route, api.routeRegisterRule); err != nil { api.logger.Error(err) break } } return route } ``` 在第 18 行中,`api.routes.register`方法就是将路由加入到路由切片中的操作。只不过里面包含了一些对路由进行去重的逻辑。本质上就是` append(api.routes, route) `操作。 咱们重点看下创建路由的过程。iris的路由分固定路由、正则路由。同时还支持路由分组、子域名路由等。 #### 2.2.1 固定路由 固定路由也叫全匹配路由。像` app.Get("/home", HomeHandler) `就是一个固定路由。也就是只有 `"/home" `路径才能匹配到`HomeHandler`处理器。以下是该路由最终构建的`route`结构体如下: ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673267901328-c41aec7f-e916-431b-a57d-9c82271915d8.png#averageHue=%23616161&clientId=ue561dcc2-9cfc-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=365&id=u909a18da&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1460&originWidth=2884&originalType=binary&ratio=1&rotation=0&showTitle=false&size=226943&status=done&style=none&taskId=u6a7fc72a-506d-4025-b248-11b9fef4c5a&title=&width=721) #### 2.2.2 正则路由 正则路由就是在路径中可以指定正则表达式,只要符合该正则表达式的路径都可以匹配到该路径及对应的请求处理函数。比如定义如下路由: ```go app.Get("/home/{username:string}", HomeHandler) ``` 路径的中的`{username:string}`部分,其中花括号`{ }` 代表是正则部分。`username`是占位符,说明这部分可以通过`username`名字获取到具体的参数值。另外的`string`是限定了`username`的类型是字符串。当然,iris框架中共计包含20个这样的类型,称为微指令。在源文件中iris/macro/macros.go中的Defaults变量列表,有兴趣可以继续深入研究。 路径 `"/home/yufuzi"`,`"/home/goxuetang"`等都可以匹配到该路由。因为在路由中指定了`username`为`string`类型,所以路径中的这部分都作为字符串类型看待。指定类型的另外一个作用就是在路由匹配中对路径的这部分内容做对应的类型校验。比如` app.Get("/home/{username:email}") `,那么路径中的这部分username就必须是一个邮件格式,否则就匹配不到该路径。 这里的`username`只是参数的变量名,可以通过`ctx.Params().Get("username"))� `来获取具体的参数值。 那么,该路径生成的对应的路由对象如下: ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673351363213-8bbab655-04d7-4e16-9b42-835cfa42f6f5.png#averageHue=%23626060&clientId=ue561dcc2-9cfc-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=365&id=u465390b0&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1460&originWidth=4324&originalType=binary&ratio=1&rotation=0&showTitle=false&size=354557&status=done&style=none&taskId=u92a17f45-4541-456c-b861-0b5b8ef580c&title=&width=1081) 我们看到红色部分是和第一个路由的主要区别。这里主要关注Tmp字段,发现Tmp字段中Param有了对应的指令,这里是`"string"`。 #### 2.2.3 路由分组 对路由进行分组也是在路由注册时常用的路由注册方法。在iris中使用以下代码对路由进行分组: ```go app := iris.New() // user分组 userGroup := app.Party("/user") userGroup.Get("/login", Home) //最终的路径实际上是/user/login ``` 这里通过使用`app.Party`方法对路由进行了分组。在上文咱们说过,Party方法实际上返回的是一个APIBuilder对象。大家还记的吗,`app`里也是嵌套了`APIBuilder`结构的,那么`app.Party`实际上是给`app`中的`APIBuilder`创建了一个子`APIBuilder`对象,同时给子`APIBuilder`中的`relativePath`设置成了 `"/user"`。也就是通过该子`APIBuilder`对象注册的路由,路径都是相对于`relativePath`的,即 `"/user"`设置的。如下是APIBuilder中的父子关系: ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673354241678-fbca2634-a50b-4834-a149-bce910d38ecb.png#averageHue=%23706e6e&clientId=ue561dcc2-9cfc-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=195&id=u3d8429e4&margin=%5Bobject%20Object%5D&name=image.png&originHeight=780&originWidth=2324&originalType=binary&ratio=1&rotation=0&showTitle=false&size=93900&status=done&style=none&taskId=u734a03e5-52ec-47fd-9d7e-98681764e37&title=&width=581) 当然,每个分组的`APIBuilder`中还可以设置自己的中间件函数。这也就实现了针对不同的分组使用不同的中间。 接下来,我们再看看针对 `"/user" `分组设置的` "/login" `生成的路由结构体。如下: ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673354952707-ac8e5e0c-90cd-4190-8670-0341c365905d.png#averageHue=%23565555&clientId=ue561dcc2-9cfc-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=375&id=uead591bf&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1500&originWidth=3388&originalType=binary&ratio=1&rotation=0&showTitle=false&size=223412&status=done&style=none&taskId=u884c4370-faf9-41dd-8cf8-715e577b528&title=&width=847) 这里主要的区别就是路由中的`Party`字段指向不一样。这里的`Party`字段指向的是分组的`APIBuilder`。 #### 2.2.4 子域名路由 在iris框架中,还支持子域名路由。通过以下方式就可以支持子域名路由: ```go adminDomain := app.Subdomain("admin") adminDomain.Get("/home", Home) ``` 通过`app.Subdomain`函数就可以指定子域名。`Subdomain`的实现其实还是调用了`APIBuilder.Party`函数。所以本质上也是一个分组。只不过是按子域名进行分组的。如下是通过`app.Subdomain("admin")`生成的`APIBuilder`的结构体实例: ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673364949213-f17634b1-92c4-41bd-806c-055eefca29da.png#averageHue=%23716e6e&clientId=ue561dcc2-9cfc-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=195&id=u70382cca&margin=%5Bobject%20Object%5D&name=image.png&originHeight=780&originWidth=2324&originalType=binary&ratio=1&rotation=0&showTitle=false&size=106837&status=done&style=none&taskId=udf8b39f1-a039-4364-99c0-13b62be91dc&title=&width=581) 通过`Subdomain`函数生成的依然是一个`APIBuilder`实例,只不过该实例中`relativePath`的值是子域名的值而已。 那么,`adminDomain.Get("/home", Home)`就是相对于子域名分组下生成的路由,其对应的`Route`实例如下: ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673364666219-dc3e1f35-56f0-49d4-a79b-c7ab585a403f.png#averageHue=%23c4c4c4&clientId=ue561dcc2-9cfc-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=366&id=uafbfbe51&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1464&originWidth=888&originalType=binary&ratio=1&rotation=0&showTitle=false&size=89384&status=done&style=none&taskId=u45a0a7dd-d18e-4ce1-820b-2973091319c&title=&width=222) 这里可以看到,在`Route`结构体的`Subdomain`字段中,有了具体的子域名的值。其他字段和普通的路由是一致的。 iris框架中注册的路由,最终都是基于`Route`结构体的,其他更多的特性也是这样。但这里还并不是最终的路由,因为我们知道如果每次请求是基于该切片进行搜索匹配路由的话,那效率就极低了。 接下来我们看`iris.Run`函数中,iris是如何基于上述的路由表将路由编译成基于前缀树结构的。 ### 2.3 基于前缀树结构的路由表 为了提高路由的匹配效率,大多数框架都基于前缀树结构构件路由表。`iris`框架也不例外。但是,`iris`框架是在服务启动阶段才对已注册的路由进行转换的,即在`iris.Run`函数中。 ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673686417200-bcc87ed1-111a-439b-a2f2-c38ee9c2ce59.png#averageHue=%23383737&clientId=u4dd2bcdf-5080-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=132&id=ufdc4641a&margin=%5Bobject%20Object%5D&name=image.png&originHeight=528&originWidth=1492&originalType=binary&ratio=1&rotation=0&showTitle=false&size=85595&status=done&style=none&taskId=u53ef6289-f7b3-4715-b406-1a69633f3c4&title=&width=373) 在前缀树路由结构中,子域名和请求方法唯一确定一棵树。也就是子域名相同且方法也相同,则在同一个树结构下。以下是前缀树路由表的大体数据结构及核心字段说明: ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673665670503-71872f14-a943-4d64-b8d7-74514854cac1.png#averageHue=%234e4b4b&clientId=u4dd2bcdf-5080-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=558&id=u6f468bde&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1116&originWidth=4724&originalType=binary&ratio=1&rotation=0&showTitle=false&size=279915&status=done&style=none&taskId=u8dcd4cf4-2c60-4ca3-8708-5b0f913c16f&title=&width=2362) 我们以下面三个路由为例,来看看最终生成的路由前缀树。 ```go app.Get("/home", Home) app.Get("/home/{userid:int}", Home) adminDomain := app.Subdomain("admin") adminDomain.Get("/home", Home) adminDomain.Get("/home/{userid:int}", Home) ``` 根据上面刚分析过的,请求方法`method`和子域名`subdomain`两者唯一确定一棵树。即同样method和同样的subdomain的路由在同一棵树下。 所以,上面的路由就有两棵树:`Get方法+空子域名`和`Get方法+admin子域名`。同时我们看到,在每一棵树中都有共同的前缀`/home`,所以会形成`home->{userid:int}`这样的父子关系。 以下是最终生成的前缀树路由: ![image.png](https://cdn.nlark.com/yuque/0/2023/png/25702311/1673685640703-450b8283-f8f1-445c-a71d-5e2fc53f0091.png#averageHue=%23595858&clientId=u4dd2bcdf-5080-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=1320&id=u1f5edf34&margin=%5Bobject%20Object%5D&name=image.png&originHeight=2640&originWidth=7084&originalType=binary&ratio=1&rotation=0&showTitle=false&size=726843&status=done&style=none&taskId=u0f4b5154-499c-419b-b7e3-ad9475455bf&title=&width=3542) 上面图看着挺多,其实很简单,就是通过`trieNode`中的`children`字段组成的一个属性结构,同时通过`parent`指向父节点。 ## 三、总结 本文通过从iris的启动,到路由注册以及转换成基于前缀树结构的路由表三个方面讲述了iris路由的生成过程。iris路由表的生成和其他web框架不同的是在`app.Run`阶段才生成,而其他web框架是在注册过程中就直接生成了树形结构。以上希望对大家有所帮助。 ---特别推荐--- 特别推荐:一个专注go项目实战、项目中踩坑经验及避坑指南、各种好玩的go工具的公众号,「Go学堂」,专注实用性,非常值得大家关注。点击下方公众号卡片,直接关注。关注送《100个go常见的错误》pdf文档。

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

2266 次点击  ∙  1 赞  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传