koa源码阅读[2]-koa-router
第三篇,有关koa生态中比较重要的一个中间件:koa-router
koa-router是什么
首先,因为koa是一个管理中间件的平台,而注册一个中间件使用use来执行。
无论是什么请求,都会将所有的中间件执行一遍(如果没有中途结束的话)
所以,这就会让开发者很困扰,如果我们要做路由该怎么写逻辑?
1 | app.use(ctx => { |
诚然,这样是一个简单的方法,但是必然不适用于大型项目,数十个接口通过一个switch来控制未免太繁琐了。
更何况请求可能只支持get或者post,以及这种方式并不能很好的支持URL中包含参数的请求/info/:uid。
在express中是不会有这样的问题的,自身已经提供了get、post等之类的与METHOD同名的函数用来注册回调:
express
1 | const express = require('express') |
但是koa做了很多的精简,将很多逻辑都拆分出来作为独立的中间件来存在。
所以导致很多express项目迁移为koa时,需要额外的安装一些中间件,koa-router应该说是最常用的一个。
所以在koa中则需要额外的安装koa-router来实现类似的路由功能:
koa
1 | const Koa = require('koa') |
看起来代码确实多了一些,毕竟将很多逻辑都从框架内部转移到了中间件中来处理。
也算是为了保持一个简练的koa框架所取舍的一些东西吧。
koa-router的逻辑确实要比koa的复杂一些,可以将koa想象为一个市场,而koa-router则是其中一个摊位
koa仅需要保证市场的稳定运行,而真正和顾客打交道的确是在里边摆摊的koa-router
koa-router的大致结构
koa-router的结构并不是很复杂,也就分了两个文件:
1 | . |
layer主要是针对一些信息的封装,主要路基由router提供:
| File | Description |
|---|---|
layer |
信息存储:路径、METHOD、路径对应的正则匹配、路径中的参数、路径对应的中间件 |
router |
主要逻辑:对外暴露注册路由的函数、提供处理路由的中间件,检查请求的URL并调用对应的layer中的路由处理 |
koa-router的运行流程
可以拿上边所抛出的基本例子来说明koa-router是怎样的一个执行流程:
1 | const router = new Router() // 实例化一个Router对象 |
创建实例时的一些事情
首先,在koa-router实例化的时候,是可以传递一个配置项参数作为初始化的配置信息的。
然而这个配置项在readme中只是简单的被描述为:
| Param | Type | Description |
|---|---|---|
[opts] |
Object |
|
[opts.prefix] |
String |
prefix router paths(路由的前缀) |
告诉我们可以添加一个Router注册时的前缀,也就是说如果按照模块化分,可以不必在每个路径匹配的前端都添加巨长的前缀:
1 | const Router = require('koa-router') |
P.S. 不过要记住,如果prefix以/结尾,则路由的注册就可以省去前缀的/了,不然会出现/重复的情况
实例化Router时的代码:
1 | function Router(opts) { |
可见的只有一个methods的赋值,但是在查看了其他源码后,发现除了prefix还有一些参数是实例化时传递进来的,但是不太清楚为什么文档中没有提到:
| Param | Type | Default | Description |
|---|---|---|---|
sensitive |
Boolean |
false |
是否严格匹配大小写 |
strict |
Boolean |
false |
如果设置为false则匹配路径后边的/是可选的 |
methods |
Array[String] |
['HEAD','OPTIONS','GET','PUT','PATCH','POST','DELETE'] |
设置路由可以支持的METHOD |
routerPath |
String | null |
sensitive
如果设置了sensitive,则会以更严格的匹配规则来监听路由,不会忽略URL中的大小写,完全按照注册时的来匹配:
1 | const Router = require('koa-router') |
strict
strict与sensitive功能类似,也是用来设置让路径的匹配变得更加严格,在默认情况下,路径结尾处的/是可选的,如果开启该参数以后,如果在注册路由时尾部没有添加/,则匹配的路由也一定不能够添加/结尾:
1 | const Router = require('koa-router') |
methods
methods配置项存在的意义在于,如果我们有一个接口需要同时支持GET和POST,router.get、router.post这样的写法必然是丑陋的。
所以我们可能会想到使用router.all来简化操作:
1 | const Router = require('koa-router') |
这简直是太完美了,可以很轻松的实现我们的需求,但是如果再多实验一些其他的methods以后,尴尬的事情就发生了:
1 | > curl -X DELETE /index => pong! |
这显然不是符合我们预期的结果,所以,在这种情况下,基于目前koa-router需要进行如下修改来实现我们想要的功能:
1 | const Koa = require('koa') |
这样的两处修改,就可以实现我们所期望的功能:
1 | > curl -X GET /index => pong! |
我个人觉得这是allowedMethods实现的一个逻辑问题,不过也许是我没有get到作者的点,allowedMethods中比较关键的一些源码:
1 | Router.prototype.allowedMethods = function (options) { |
首先,allowedMethods是作为一个后置的中间件存在的,因为在返回的函数中先调用了next,其次才是针对METHOD的判断,而这样带来的一个后果就是,如果我们在路由的回调中进行类似ctx.body = XXX的操作,实际上会修改本次请求的status值的,使之并不会成为404,而无法正确的触发METHOD检查的逻辑。
想要正确的触发METHOD逻辑,就需要自己在路由监听中手动判断ctx.method是否为我们想要的,然后在跳过当前中间件的执行。
而这一判断的步骤实际上与allowedMethods中间件中的!~implemented.indexOf(ctx.method)逻辑完全是重复的,不太清楚koa-router为什么会这么处理。
当然,allowedMethods是不能够作为一个前置中间件来存在的,因为一个Koa中可能会挂在多个Router,Router之间的配置可能不尽相同,不能保证所有的Router都和当前Router可处理的METHOD是一样的。
所以,个人感觉methods参数的存在意义并不是很大。。
routerPath
这个参数的存在。。感觉会导致一些很诡异的情况。
这就要说到在注册完中间件以后的router.routes()的操作了:
1 | Router.prototype.routes = Router.prototype.middleware = function () { |
因为我们实际上向koa注册的是这样的一个中间件,在每次请求发送过来时,都会执行dispatch,而在dispatch中判断是否命中某个router时,则会用到这个配置项,这样的一个表达式:router.opts.routerPath || ctx.routerPath || ctx.path,router代表当前Router实例,也就是说,如果我们在实例化一个Router的时候,如果填写了routerPath,这会导致无论任何请求,都会优先使用routerPath来作为路由检查:
1 | const router = new Router({ |
如果有这样的代码,无论请求什么URL,都会认为是/index来进行匹配:
1 | > curl http://127.0.0.1:8888 |
巧用routerPath实现转发功能
同样的,这个短路运算符一共有三个表达式,第二个的ctx则是当前请求的上下文,也就是说,如果我们有一个早于routes执行的中间件,也可以进行赋值来修改路由判断所使用的URL:
1 | const router = new Router() |
这样的代码也能够实现相同的效果。
实例化中传入的routerPath让人捉摸不透,但是在中间件中改变routerPath的这个还是可以找到合适的场景,这个可以简单的理解为转发的一种实现,转发的过程是对客户端不可见的,在客户端看来依然访问的是最初的URL,但是在中间件中改变ctx.routerPath可以很轻易的使路由匹配到我们想转发的地方去
1 | // 老版本的登录逻辑处理 |
这样就实现了一个简易的转发:
1 | > curl -X POST http://127.0.0.1:8888/login |
注册路由的监听
上述全部是关于实例化Router时的一些操作,下面就来说一下使用最多的,注册路由相关的操作,最熟悉的必然就是router.get,router.post这些的操作了。
但实际上这些也只是一个快捷方式罢了,在内部调用了来自Router的register方法:
1 | Router.prototype.register = function (path, methods, middleware, opts) { |
该方法在注释中标为了 private 但是其中的一些参数在代码中各种地方都没有体现出来,鬼知道为什么会留着那些参数,但既然存在,就需要了解他是干什么的
这个是路由监听的基础方法,函数签名大致如下:
| Param | Type | Default | Description |
|---|---|---|---|
path |
String/Array[String] |
- | 一个或者多个的路径 |
methods |
Array[String] |
- | 该路由需要监听哪几个METHOD |
middleware |
Function/Array[Function] |
- | 由函数组成的中间件数组,路由实际调用的回调函数 |
opts |
Object |
{} |
一些注册路由时的配置参数,上边提到的strict、sensitive和prefix在这里都有体现 |
可以看到,函数大致就是实现了这样的流程:
- 检查
path是否为数组,如果是,遍历item进行调用自身 - 实例化一个
Layer对象,设置一些初始化参数 - 设置针对某些参数的中间件处理(如果有的话)
- 将实例化后的对象放入
stack中存储
所以在介绍这几个参数之前,简单的描述一下Layer的构造函数是很有必要的:
1 | function Layer(path, methods, middleware, opts) { |
layer是负责存储路由监听的信息的,每次注册路由时的URL,URL生成的正则表达式,该URL中存在的参数,以及路由对应的中间件。
统统交由Layer来存储,重点需要关注的是实例化过程中的那几个数组参数:
- methods
- paramNames
- stack
methods存储的是该路由监听对应的有效METHOD,并会在实例化的过程中针对METHOD进行大小写的转换。paramNames因为用的插件问题,看起来不那么清晰,实际上在pathToRegExp内部会对paramNames这个数组进行push的操作,这么看可能会舒服一些pathToRegExp(path, &this.paramNames, this.opts),在拼接hash结构的路径参数时会用到这个数组stack存储的是该路由监听对应的中间件函数,router.middleware部分逻辑会依赖于这个数组
path
在函数头部的处理逻辑,主要是为了支持多路径的同时注册,如果发现第一个path参数为数组后,则会遍历path参数进行调用自身。
所以针对多个URL的相同路由可以这样来处理:
1 | router.register(['/', ['/path1', ['/path2', 'path3']]], ['GET'], ctx => { |
这样完全是一个有效的设置:
1 | > curl http://127.0.0.1:8888/ |
methods
而关于methods参数,则默认认为是一个数组,即使是只监听一个METHOD也需要传入一个数组作为参数,如果是空数组的话,即使URL匹配,也会直接跳过,执行下一个中间件,这个在后续的router.routes中会提到
middleware
middleware则是一次路由真正执行的事情了,依旧是符合koa标准的中间件,可以有多个,按照洋葱模型的方式来执行。
这也是koa-router中最重要的地方,能够让我们的一些中间件只在特定的URL时执行。
这里写入的多个中间件都是针对该URL生效的。
P.S. 在koa-router中,还提供了一个方法,叫做router.use,这个会注册一个基于router实例的中间件
opts
opts则是用来设置一些路由生成的配置规则的,包括如下几个可选的参数:
| Param | Type | Default | Description |
|---|---|---|---|
name |
String |
- | 设置该路由所对应的name,命名router |
prefix |
String |
- | __非常鸡肋的参数,完全没有卵用__,看似会设置路由的前缀,实际上没有一点儿用 |
sensitive |
Boolean |
false |
是否严格匹配大小写,覆盖实例化Router中的配置 |
strict |
Boolean |
false |
是否严格匹配大小写,如果设置为false则匹配路径后边的/是可选的 |
end |
Boolean |
true |
路径匹配是否为完整URL的结尾 |
ignoreCaptures |
Boolean |
- | 是否忽略路由匹配正则结果中的捕获组 |
name
首先是name,主要是用于这几个地方:
- 抛出异常时更方便的定位
- 可以通过
router.url(<name>)、router.route(<name>)获取到对应的router信息 - 在中间件执行的时候,
name会被塞到ctx.routerName中
1 | router.register('/test1', ['GET'], _ => {}, { |
如果多个router使用相同的命名,则通过router.url调用返回最先注册的那一个:
1 | // route用来获取命名路由 |
跑题说下router.url的那些事儿
如果在项目中,想要针对某些URL进行跳转,使用router.url来生成path则是一个不错的选择:
1 | router.register( |
可以看到,router.url实际上调用的是Layer实例的url方法,该方法主要是用来处理生成时传入的一些参数。
源码地址:layer.js#L116
函数接收两个参数,params和options,因为本身Layer实例是存储了对应的path之类的信息,所以params就是存储的在路径中的一些参数的替换,options在目前的代码中,仅仅存在一个query字段,用来拼接search后边的数据:
1 | const Layer = require('koa-router/lib/layer') |
上述的调用方式都是有效的,在源码中有对应的处理,首先是针对多参数的判断,如果params不是一个object,则会认为是通过layer.url(参数, 参数, 参数, opts)这种方式来调用的。
将其转换为layer.url([参数, 参数], opts)形式的。
这时候的逻辑仅需要处理三种情况了:
- 数组形式的参数替换
hash形式的参数替换- 无参数
这个参数替换指的是,一个URL会通过一个第三方的库用来处理链接中的参数部分,也就是/:XXX的这一部分,然后传入一个hash实现类似模版替换的操作:
1 | // 可以简单的认为是这样的操作: |
然后layer.url的处理就是为了将各种参数生成类似hash这样的结构,最终替换hash获取完整的URL。
prefix
上边实例化Layer的过程中看似是opts.prefix的权重更高,但是紧接着在下边就有了一个判断逻辑进行调用setPrefix重新赋值,在翻遍了整个的源码后发现,这样唯一的一个区别就在于,会有一条debug应用的是注册router时传入的prefix,而其他地方都会被实例化Router时的prefix所覆盖。
而且如果想要路由正确的应用prefix,则需要调用setPrefix,因为在Layer实例化的过程中关于path的存储就是来自远传入的path参数。
而应用prefix前缀则需要手动触发setPrefix:
1 | // Layer实例化的操作 |
这个在暴露给使用者的几个方法中都有体现,类似的get、set以及use。
当然在文档中也提供了可以直接设置所有router前缀的方法,router.prefix:
文档中就这样简单的告诉你可以设置前缀,prefix在内部会循环调用所有的layer.setPrefix:
1 | router.prefix('/things/:thing_id') |
但是在翻看了layer.setPrefix源码后才发现这里其实是含有一个暗坑的。
因为setPrefix的实现是拿到prefix参数,拼接到当前path的头部。
这样就会带来一个问题,如果我们多次调用setPrefix会导致多次prefix叠加,而非替换:
1 | router.register('/index', ['GET'], ctx => { |
prefix方法会叠加前缀,而不是覆盖前缀
sensitive与strict
这俩参数没啥好说的,就是会覆盖实例化Router时所传递的那俩参数,效果都一致。
end
end是一个很有趣的参数,这个在koa-router中引用的其他模块中有体现到,path-to-regexp:
1 | if (end) { |
endWith可以简单地理解为是正则中的$,也就是匹配的结尾。
看代码的逻辑,大致就是,如果设置了end: true,则无论任何情况都会在最后添加$表示匹配的结尾。
而如果end: false,则只有在同时设置了strict: false或者isEndDelimited: false时才会触发。
所以我们可以通过这两个参数来实现URL的模糊匹配:
1 | router.register( |
也就是说上述代码最后生成的用于匹配路由的正则表达式大概是这样的:
1 | /^\/list(?=\/|$)/i |
结尾的$是可选的,这就会导致,我们只要发送任何开头为/list的请求都会被这个中间件所获取到。
ignoreCaptures
ignoreCaptures参数用来设置是否需要返回URL中匹配的路径参数给中间件。
而如果设置了ignoreCaptures以后这两个参数就会变为空对象:
1 | router.register('/list/:id', ['GET'], ctx => { |
这个是在中间件执行期间调用了来自layer的两个方法获取的。
首先调用captures获取所有的参数,如果设置了ignoreCaptures则会导致直接返回空数组。
然后调用params将注册路由时所生成的所有参数以及参数们实际的值传了进去,然后生成一个完整的hash注入到ctx对象中:
1 | // 中间件的逻辑 |
router.param的作用
上述是关于注册路由时的一些参数描述,可以看到在register中实例化Layer对象后并没有直接将其放入stack中,而是执行了这样的一个操作以后才将其推入stack:
1 | Object.keys(this.params).forEach(function (param) { |
这里是用作添加针对某个URL参数的中间件处理的,与router.param两者关联性很强:
1 | Router.prototype.param = function (param, middleware) { |
两者操作类似,前者用于对新增的路由监听添加所有的param中间件,而后者用于针对现有的所有路由添加param中间件。
因为在router.param中有着this.params[param] = XXX的赋值操作。
这样在后续的新增路由监听中,直接循环this.params就可以拿到所有的中间件了。
router.param的操作在文档中也有介绍,文档地址
大致就是可以用来做一些参数校验之类的操作,不过因为在layer.param中有了一些特殊的处理,所以我们不必担心param的执行顺序,layer会保证param一定是早于依赖这个参数的中间件执行的:
1 | router.register('/list/:id', ['GET'], (ctx, next) => { |
最常用的get/post之类的快捷方式
以及说完了上边的基础方法register,我们可以来看下暴露给开发者的几个router.verb方法:
1 | // get|put|post|patch|delete|del |
令人失望的是,verb方法将大量的opts参数都砍掉了,默认只留下了一个name字段。
只是很简单的处理了一下命名name路由相关的逻辑,然后进行调用register完成操作。
router.use-Router内部的中间件
以及上文中也提到的router.use,可以用来注册一个中间件,使用use注册中间件分为两种情况:
- 普通的中间件函数
- 将现有的
router实例作为中间件传入
普通的use
这里是use方法的关键代码:
1 | Router.prototype.use = function () { |
第一种是比较常规的方式,传入一个函数,一个可选的path,来进行注册中间件。
不过有一点要注意的是,.use('path')这样的用法,中间件不能独立存在,必须要有一个可以与之路径相匹配的路由监听存在:
1 | router.use('/list', ctx => { |
原因是这样的:
.use和.get都是基于.register来实现的,但是.use在methods参数中传递的是一个空数组- 在一个路径被匹配到时,会将所有匹配到的中间件取出来,然后检查对应的
methods,如果length !== 0则会对当前匹配组标记一个flag - 在执行中间件之前会先判断有没有这个
flag,如果没有则说明该路径所有的中间件都没有设置METHOD,则会直接跳过进入其他流程(比如allowedMethod)
1 | Router.prototype.match = function (path, method) { |
将其他router实例传递进来
可以看到,如果选择了router.routes()来方式来复用中间件,会遍历该实例的所有路由,然后设置prefix。
并将修改完的layer推出到当前的router中。
那么现在就要注意了,在上边其实已经提到了,Layer的setPrefix是拼接的,而不是覆盖的。
而use是会操作layer对象的,所以这样的用法会导致之前的中间件路径也被修改。
而且如果传入use的中间件已经注册在了koa中就会导致相同的中间件会执行两次(如果有调用next的话):
1 | const middlewareRouter = new Router() |
就像上述代码,实际上会有两个问题:
- 最终有效的访问路径为
/page2/page1/list/1,因为prefix会拼接而非覆盖 - 当我们在中间件中调用
next以后,console.log会连续输出三次,因为所有的routes都是动态的,实际上prefix都被修改为了/page2/page1
一定要小心使用,不要认为这样的方式可以用来实现路由的复用
请求的处理
以及,终于来到了最后一步,当一个请求来了以后,Router是怎样处理的。
一个Router实例可以抛出两个中间件注册到koa上:
1 | app.use(router.routes()) |
routes负责主要的逻辑。allowedMethods负责提供一个后置的METHOD检查中间件。
allowedMethods没什么好说的,就是根据当前请求的method进行的一些校验,并返回一些错误信息。
而上边介绍的很多方法其实都是为了最终的routes服务:
1 | Router.prototype.routes = Router.prototype.middleware = function () { |
首先可以看到,koa-router同时还提供了一个别名middleware来实现相同的功能。
以及函数的调用最终会返回一个中间件函数,这个函数才是真正被挂在到koa上的。koa的中间件是纯粹的中间件,不管什么请求都会执行所包含的中间件。
所以不建议为了使用prefix而创建多个Router实例,这会导致在koa上挂载多个dispatch用来检查URL是否符合规则
进入中间件以后会进行URL的判断,就是我们上边提到的可以用来做foraward实现的地方。
匹配调用的是router.match方法,虽说看似赋值是matched.path,而实际上在match方法的实现中,里边全部是匹配到的Layer实例:
1 | Router.prototype.match = function (path, method) { |
而之所以会存在说判断是否有ctx.matched来进行处理,而不是直接对这个属性进行赋值。
这是因为上边也提到过的,一个koa实例可能会注册多个koa-router实例。
这就导致一个router实例的中间件执行完毕后,后续可能还会有其他的router实例也命中了某个URL,但是这样会保证matched始终是在累加的,而非每次都会覆盖。
path与pathAndMethod都是match返回的两个数组,两者的区别在于path返回的是匹配URL成功的数据,而pathAndMethod则是匹配URL且匹配到METHOD的数据
1 | const router1 = new Router() |
关于中间件的执行,在koa-router中也使用了koa-compose来合并洋葱:
1 | var matchedLayers = matched.pathAndMethod |
这坨代码会在所有匹配到的中间件之前添加一个ctx属性赋值的中间件操作,也就是说reduce的执行会让洋葱模型对应的中间件函数数量至少X2。
layer中可能包含多个中间件,不要忘了middleware,这就是为什么会在reduce中使用concat而非push
因为要在每一个中间件执行之前,修改ctx为本次中间件触发时的一些信息。
包括匹配到的URL参数,以及当前中间件的name之类的信息。
1 | [ |
在routes最后,会调用koa-compose来合并reduce所生成的中间件数组,以及用到了之前在koa-compose中提到了的第二个可选的参数,用来做洋葱执行完成后最终的回调处理。
小记
至此,koa-router的使命就已经完成了,实现了路由的注册,以及路由的监听处理。
在阅读koa-router的源码过程中感到很迷惑:
- 明明代码中已经实现的功能,为什么在文档中就没有体现出来呢。
- 如果文档中不写明可以这样来用,为什么还要在代码中有对应的实现呢?
两个最简单的举证:
- 可以通过修改
ctx.routerPath来实现forward功能,但是在文档中不会告诉你 - 可以通过
router.register(path, ['GET', 'POST'])来快速的监听多个METHOD,但是register被标记为了@private
参考资料:
示例代码在仓库中的位置:learning-koa-router