快捷搜索:

send与它的衍生,koa2入门笔记

作者: w88官方网站手机版  发布:2019-05-03

koa源码阅读的第四篇,涉及到向接口请求方提供文件数据。

koa源码阅读[2]-koa-router

第三篇,有关koa生态中比较重要的一个中间件:koa-router

第一篇:koa源码阅读-0
第二篇:koa源码阅读-1-koa与koa-compose

环境准备:安装node.js,建议版本号>=7.6,否则需额外安装插件。
  • 直接安装 node.js :node.js官网地址 https://nodejs.org
  • nvm管理多版本 node.js :可以用nvm 进行node版本进行管理
    • Mac 系统安装 nvm https://github.com/creationix/nvm#manual-install
    • windows 系统安装 nvm https://github.com/coreybutler/nvm-windows
    • Ubuntu 系统安装 nvm https://github.com/creationix/nvm
  • 新建项目,使用npm init初始化,目录如下
├── app.js
├── package.json
  • 安装 koa,并将版本信息保存在 package.json 中
cnpm i koa -S

第一篇:koa源码阅读-0
第二篇:koa源码阅读-1-koa与koa-compose
第三篇:koa源码阅读-2-koa-router

koa-router是什么

首先,因为koa是一个管理中间件的平台,而注册一个中间件使用use来执行。
无论是什么请求,都会将所有的中间件执行一遍(如果没有中途结束的话)
所以,这就会让开发者很困扰,如果我们要做路由该怎么写逻辑?

app.use(ctx => {
  switch (ctx.url) {
    case '/':
    case '/index':
      ctx.body = 'index'
      break
    case 'list':
      ctx.body = 'list'
      break
    default:
      ctx.body = 'not found'
  }
})

 

诚然,这样是一个简单的方法,但是必然不适用于大型项目,数十个接口通过一个switch来控制未免太繁琐了。
更何况请求可能只支持get或者post,以及这种方式并不能很好的支持URL中包含参数的请求/info/:uid
express中是不会有这样的问题的,自身已经提供了getpost等之类的与METHOD同名的函数用来注册回调:
express

const express = require('express')
const app = express()

app.get('/', function (req, res) {
  res.send('hi there.')
})

 

但是koa做了很多的精简,将很多逻辑都拆分出来作为独立的中间件来存在。
所以导致很多express项目迁移为koa时,需要额外的安装一些中间件,koa-router应该说是最常用的一个。
所以在koa中则需要额外的安装koa-router来实现类似的路由功能:
koa

const Koa = require('koa')
const Router = require('koa-router')

const app = new Koa()
const router = new Router()

router.get('/', async ctx => {
  ctx.body = 'hi there.'
})

app.use(router.routes())
  .use(router.allowedMethods())

 

看起来代码确实多了一些,毕竟将很多逻辑都从框架内部转移到了中间件中来处理。
也算是为了保持一个简练的koa框架所取舍的一些东西吧。
koa-router的逻辑确实要比koa的复杂一些,可以将koa想象为一个市场,而koa-router则是其中一个摊位
koa仅需要保证市场的稳定运行,而真正和顾客打交道的确是在里边摆摊的koa-router

一、认识middleware中间件

在HelloWorld的demo中,代码如下

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx,next) => {
  await next()
  ctx.body = 'Hello World';
});

app.listen(3000);

它的作用是:每收到一个 http 请求,Koa 都会调用通过 app.use() 注册的 async 函数,同时为该函数传入 ctxnext 两个参数,最后给页面返回一个`Hello World'.

上述代码中,由 async 标记的函数称为『异步函数』,在异步函数中,可以用 await 调用另一个异步函数,asyncawait 这两个关键字将在 ES7 中引入。参数 ctx 是由 koa 传入的,我们可以通过它来访问 requestresponsenextkoa 传入的将要处理的下一个异步函数。
这里的 async 函数就是我们所说的中间件,正是因为中间件的扩展性才使得 Koa 的代码简单灵活。

下面我们简单介绍一下传入中间件的两个参数。

  • ctx : ctx 作为上下文使用,包含了基本的 ctx.requestctx.response。另外,还对 Koa 内部一些常用的属性或者方法做了代理操作,使得我们可以直接通过 ctx 获取。比如,ctx.request.url 可以写成 ctx.url。 -- 除此之外,Koa 还约定了一个中间件的存储空间 ctx.state。通过 state 可以存储一些数据,比如用户数据,版本信息等。如果你使用 webpack 打包的话,可以使用中间件,将加载资源的方法作为 ctx.state 的属性传入到 view 层,方便获取资源路径。

  • next : next 参数的作用是将处理的控制权转交给下一个中间件,而 next() 后面的代码,将会在下一个中间件及后面的中间件(如果有的话)执行结束后再执行。

所以: 中间件的顺序很重要!

// 按照官方示例
const Koa = require('koa')
const app = new Koa()

// 记录执行的时间
app.use(async (ctx, next) => {
  let stime = new Date().getTime()
  await next()
  let etime = new Date().getTime()
  ctx.response.type = 'text/html'
  ctx.response.body = '<h1>Hello World</h1>'
  console.log(`请求地址: ${ctx.path},响应时间:${etime - stime}ms`)
});

app.use(async (ctx, next) => {
  console.log('中间件1 doSoming')
  await next();
  console.log('中间件1 end')
})

app.use(async (ctx, next) => {
  console.log('中间件2 doSoming')
  await next();
  console.log('中间件2 end')
})

app.use(async (ctx, next) => {
  console.log('中间件3 doSoming')
  await next();
  console.log('中间件3 end')
})

app.listen(3000, () => {
  console.log('server is running at http://localhost:3000')
})

运行起来后,控制台显示:

server is running at http://localhost:3000

然后打开浏览器,访问 http://localhost:3000,控制台显示内容更新为:

server is running at http://localhost:3000
中间件1 doSoming
中间件2 doSoming
中间件3 doSoming
中间件3 end
中间件2 end
中间件1 end
请求地址: /,响应时间:2ms

从结果上可以看到,流程是一层层的打开,然后一层层的闭合,像是剥洋葱一样 —— 洋葱模型。

此外,如果一个中间件没有调用 await next(),会怎样呢?答案是『后面的中间件将不会执行』。 如果await next()后面没有中间件了,那么也将结束执行。

处理静态文件是一个繁琐的事情,因为静态文件都是来自于服务器上,肯定不能放开所有权限让接口来读取。
各种路径的校验,权限的匹配,都是需要考虑到的地方。
koa-sendkoa-static就是帮助我们处理这些繁琐事情的中间件。
koa-sendkoa-static的基础,可以在NPM的界面上看到,staticdependencies中包含了koa-send

koa-router的大致结构

koa-router的结构并不是很复杂,也就分了两个文件:

.
├── layer.js
└── router.ja

 

layer主要是针对一些信息的封装,主要路基由router提供:

tag desc
layer 信息存储:路径、METHOD、路径对应的正则匹配、路径中的参数、路径对应的中间件
router 主要逻辑:对外暴露注册路由的函数、提供处理路由的中间件,检查请求的URL并调用对应的layer中的路由处理

二、路由koa-router

路由是用于描述 URL 与处理函数之间的对应关系的。比如用户访问 http://localhost:3000/,那么浏览器就会显示 index 页面的内容,如果用户访问的是 http://localhost:3000/home,那么浏览器应该显示 home 页面的内容。

要实现上述功能,如果不借助 koa-router 或者其他路由中间件,而是自己去处理路由,那么写法可能如下所示:

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
    if (ctx.request.path === '/') {
        ctx.response.body = '<h1>index page</h1>';
    } else {
        await next();
    }
});
app.use(async (ctx, next) => {
    if (ctx.request.path === '/home') {
        ctx.response.body = '<h1>home page</h1>';
    } else {
        await next();
    }
});
app.use(async (ctx, next) => {
    if (ctx.request.path === '/404') {
        ctx.response.body = '<h1>404 Not Found</h1>';
    } else {
        await next();
    }
});

app.listen(3000, ()=>{
  console.log('server is running at http://localhost:3000')
})

这样的写法能够处理简单的应用,但是,一旦要处理的 URL 多起来的话就会显得特别笨重。所以我们可以借助 koa-router 来更简单的实现这一功能。
下面来介绍一下如何正确的使用 koa-router

  • 安装 koa-router
cnpm i koa-router -S
  • 基本使用方法

如果要在 app1.js 中使用 koa-router 来处理 URL,可以通过以下代码来实现:

const Koa = require('koa')
// 注意 require('koa-router') 返回的是函数:
const router = require('koa-router')()
const app = new Koa()

 // 添加路由
 router.get('/', async (ctx, next) => {
    ctx.response.body = `<h1>index page</h1>`
})

router.get('/home', async (ctx, next) => {
    ctx.response.body = '<h1>HOME page</h1>'
})

router.get('/404', async (ctx, next) => {
    ctx.response.body = '<h1>404 Not Found</h1>'
})

 // 调用路由中间件
 app.use(router.routes())

app.listen(3000, ()=>{
  console.log('server is running at http://localhost:3000')
})

通过上面的例子,我们可以看到和之前不使用 koa-router 的显示效果是一样的。不过使用了 koa-router 之后,代码稍微简化了一些,而且少了 if 判断,还有省略了 await next()(因为没有其他中间件需要执行,所以这里就先省略了)。

当然,除了 GET 方法,koa-router 也支持处理其他的请求方法,比如:

//支持这种链式写法
router
  .get('/', async (ctx, next) => {
    ctx.body = 'Hello World!';
  })
  .post('/users', async (ctx, next) => {
    // ... 
  })
  .put('/users/:id', async (ctx, next) => {
    // ... 
  })
  .del('/users/:id', async (ctx, next) => {
    // ... 
  })
  .all('/users/:id', async (ctx, next) => {
    // ... 
  });

上述代码中有一个all 方法。all 方法用于处理上述方法无法匹配的情况,或者你不确定客户端发送的请求方法类型。比如有一个GET请求,优先匹配和router.get方法中url规则一样的请求,如果匹配不到的话就匹配router.all方法中url规则一样的请求。
当请求都无法匹配的时候,我们可以跳转到自定义的 404 页面,比如:

//这个放在路由的最后
router.all('/*', async (ctx, next) => {
  ctx.response.status = 404;
  ctx.response.body = '<h1>404 Not Found</h1>';
});

* 号是一种通配符,表示匹配任意 URL。这里的返回是一种简化的写法,真实开发中,我们肯定要去读取 HTML 文件或者其他模板文件的内容,再响应请求。关于这部分的内容后面的章节中会详细介绍。

图片 1

koa-router的运行流程

可以拿上边所抛出的基本例子来说明koa-router是怎样的一个执行流程:

const router = new Router() // 实例化一个Router对象

// 注册一个路由的监听
router.get('/', async ctx => {
  ctx.body = 'hi there.'
})

app
  .use(router.routes()) // 将该Router对象的中间件注册到Koa实例上,后续请求的主要处理逻辑
  .use(router.allowedMethods()) // 添加针对OPTIONS的响应处理,一些预检请求会先触发 OPTIONS 然后才是真正的请求

 

其他特性
  • 命名路由:在开发过程中我们能够根据路由名称和参数很方便的生成路由 URL
router.get('user', '/users/:id', async (ctx, next)=>{
  // ... 
});

router.url('user', 3);
// => 生成路由 "/users/3" 

router.url('user', { id: 3 });
// => 生成路由 "/users/3" 

router.use(async (ctx, next) {
  // 重定向到路由名称为 “sign-in” 的页面 
  ctx.redirect(ctx.router.url('sign-in'));
})

router.url 方法方便我们在代码中根据路由名称和参数(可选)去生成具体的 URL,而不用采用字符串拼接的方式去生成 URL 了。

  • 多中间件:koa-router 也支持单个路由多中间件的处理。通过这个特性,我们能够为一个路由添加特殊的中间件处理。也可以把一个路由要做的事情拆分成多个步骤去实现,当路由处理函数中有异步操作时,这种写法的可读性和可维护性更高。比如下面的示例代码所示:
router.get(
    '/users/:id',
    async (ctx, next) => {
        ctx.body=`<h1>user:${ctx.params.id}</h1>`;
        ctx.user='xiaoming';
        next();
    },
    async (ctx, next) => {
        console.log(ctx.user);
        // 在这个中间件中再对用户信息做一些处理
        // => { id: 17, name: "Alex" }
    }
);
  • 嵌套路由:我们可以在应用中定义多个路由,然后把这些路由组合起来用,这样便于我们管理多个路由,也简化了路由的写法。
const Router=require('koa-router')

const forums = new Router();
const posts = new Router();

posts.get('/', async (ctx, next)=>{
    ctx.body=`fid:${ctx.params.fid}`
});
posts.get('/:pid', async (ctx, next)=>{
    ctx.body=`pid:${ctx.params.pid}`
});
forums.use('/forums/:fid/posts', posts.routes(), posts.allowedMethods());

// 可以匹配到的路由为 "/forums/123/posts" 或者 "/forums/123/posts/123"
app.use(forums.routes());
  • 路由前缀:通过 prefix 这个参数,我们可以为一组路由添加统一的前缀,和嵌套路由类似,也方便我们管理路由和简化路由的写法。不同的是,前缀是一个固定的字符串,不能添加动态参数。
const Router=require('koa-router')
const router = new Router({
  prefix: '/users'
});

router.get('/', ...); // 匹配路由 "/users" 
router.get('/:id', ...); // 匹配路由 "/users/:id" 

一般在更新版本号的时候很方便。

  • URL 参数:koa-router 也支持参数,参数会被添加到 ctx.params 中。参数也可以是一个正则表达式,这个功能的实现是通过 path-to-regexp 来实现的。原理是把 URL 字符串转化成正则对象,然后再进行正则匹配,之前的例子中的 * 通配符就是一种正则表达式。
router.get('/:category/:title', function (ctx, next) {
  console.log(ctx.params);
  // => { category: 'programming', title: 'how-to-node' } 
});

koa-send主要是用于更方便的处理静态文件,与koa-router之类的中间件不同的是,它并不是直接作为一个函数注入到app.use中的。
而是在某些中间件中进行调用,传入当前请求的Context及文件对应的位置,然后实现功能。

创建实例时的一些事情

首先,在koa-router实例化的时候,是可以传递一个配置项参数作为初始化的配置信息的。
然而这个配置项在readme中只是简单的被描述为:

Param Type Description
[opts] Object  
[opts.prefix] String prefix router paths(路由的前缀)

告诉我们可以添加一个Router注册时的前缀,也就是说如果按照模块化分,可以不必在每个路径匹配的前端都添加巨长的前缀:

const Router = require('koa-router')
const router = new Router({
  prefix: '/my/awesome/prefix'
})

router.get('/index', ctx => { ctx.body = 'pong!' })

// curl /my/awesome/prefix/index => pong!

 

P.S. 不过要记住,如果prefix/结尾,则路由的注册就可以省去前缀的/了,不然会出现/重复的情况

实例化Router时的代码:

function Router(opts) {
  if (!(this instanceof Router)) {
    return new Router(opts)
  }

  this.opts = opts || {}
  this.methods = this.opts.methods || [
    'HEAD',
    'OPTIONS',
    'GET',
    'PUT',
    'PATCH',
    'POST',
    'DELETE'
  ]

  this.params = {}
  this.stack = []
}

 

可见的只有一个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  

三、解析请求参数

当我们捕获到请求后,一般都需要把请求传递过来的数据解析出来。数据传递过来的方式一般有三种:

  • get请求,请求参数为URL路径后面以?开头的查询参数:如http://localhost:3000/home?id=1&name=hfimy。使用ctx.request.queryctx.request.querystring可以获取到查询参数。不同的是query返回的是对象,querystring返回的是字符串。
  router.get('/home', async(ctx, next) => {
    console.log('query:',ctx.request.query)
    console.log('querystring:',ctx.request.querystring)
    ctx.response.body = '<h1>HOME page</h1>'
  })

访问http://localhost:3000/home?id=1&name=hfimy,控制台输出如下

query: {id:'1',name:'hfimy'}
querystring: id=1&name=hfimy
  • get请求,请求参数放在URL路径里面,如http://localhost:3000/home/1/hfimy。这种情况下,koa-router会把请求参数解析在params对象上,通过ctx.params可以获取到这个对象。
  router.get('/home/:id/:name', async(ctx, next) => {
    console.log(ctx.params)
    ctx.response.body = '<h1>HOME page</h1>'
  })

访问http://localhost:3000/home/1/hfimy,控制台输出如下

{id:'1',name:'hfimy'}
  • post请求,请求参数放在body里面。当用 post 方式请求时,我们会遇到一个问题:post 请求通常都会通过表单或 JSON 形式发送,而无论是 Node 还是 Koa,都 没有提供 解析 post 请求参数的功能。这里,我们将引入一个koa-bodyparser包,安装完成之后,我们需要在 app.js 中引入中间件并应用:
  const Koa = require('koa')
  const router = require('koa-router')()
  const bodyParser = require('koa-bodyparser')
  const app = new Koa()

  app.use(bodyParser())

不管是通过表单提交还是以JSON形式发送,我们都可以通过ctx.request.body获取到提交的数据。

koa-send的GitHub地址

sensitive

如果设置了sensitive,则会以更严格的匹配规则来监听路由,不会忽略URL中的大小写,完全按照注册时的来匹配:

const Router = require('koa-router')
const router = new Router({
  sensitive: true
})

router.get('/index', ctx => { ctx.body = 'pong!' })

// curl /index => pong!
// curl /Index => 404

 

四、指定静态资源目录

这里介绍一个在koa中提供静态资源访问的第三方中间件:koa-static,用法与express中的express-static基本一致,指定静态文件目录即可。一般在app.js同级目录下创建一个public目录,用来存放静态文件。

...
const static = require('koa-static')
...
//注意,提供静态资源访问的中间件需要放在路由中间件的前面使用
app.use(static(path.resolve(__dirname, "./public")))

原生的文件读取、传输方式

Node中,如果使用原生的fs模块进行文件数据传输,大致是这样的操作:

const fs      = require('fs')
const Koa     = require('koa')
const Router  = require('koa-router')

const app     = new Koa()
const router  = new Router()
const file    = './test.log'
const port    = 12306

router.get('/log', ctx => {
  const data = fs.readFileSync(file).toString()
  ctx.body = data
})

app.use(router.routes())
app.listen(port, () => console.log(`Server run as http://127.0.0.1:${port}`))

 

或者用createReadStream代替readFileSync也是可行的,区别会在下边提到

这个简单的示例仅针对一个文件进行操作,而如果我们要读取的文件是有很多个,甚至于可能是通过接口参数传递过来的。
所以很难保证这个文件一定是真实存在的,而且我们可能还需要添加一些权限设置,防止一些敏感文件被接口返回。

router.get('/file', ctx => {
  const { fileName } = ctx.query
  const path = path.resolve('./XXX', fileName)
  // 过滤隐藏文件
  if (path.startsWith('.')) {
    ctx.status = 404
    return
  }

  // 判断文件是否存在
  if (!fs.existsSync(path)) {
    ctx.status = 404
    return
  }

  // balabala

  const rs = fs.createReadStream(path)
  ctx.body = rs // koa做了针对stream类型的处理,详情可以看之前的koa篇
})

 

添加了各种逻辑判断以后,读取静态文件就变得安全不少,可是这也只是在一个router中做的处理。
如果有多个接口都会进行静态文件的读取,势必会存在大量的重复逻辑,所以将其提炼为一个公共函数将是一个很好的选择。

strict

strictsensitive功能类似,也是用来设置让路径的匹配变得更加严格,在默认情况下,路径结尾处的/是可选的,如果开启该参数以后,如果在注册路由时尾部没有添加/,则匹配的路由也一定不能够添加/结尾:

const Router = require('koa-router')
const router = new Router({
  strict: true
})

router.get('/index', ctx => { ctx.body = 'pong!' })

// curl /index  => pong!
// curl /Index  => pong!
// curl /index/ => 404

 

五、常用中间件

koa-send的方式

这就是koa-send做的事情了,提供了一个封装非常完善的处理静态文件的中间件。
这里是两个最基础的使用例子:

const path = require('path')
const send = require('koa-send')

// 针对某个路径下的文件获取
router.get('/file', async ctx => {
  await send(ctx, ctx.query.path, {
    root: path.resolve(__dirname, './public')
  })
})

// 针对某个文件的获取
router.get('/index', async ctx => {
  await send(ctx, './public/index.log')
})

 

假设我们的目录结构是这样的,simple-send.js为执行文件:

.
├── public
│   ├── a.log
│   ├── b.log
│   └── index.log
└── simple-send.js

 

使用/file?path=XXX就可以很轻易的访问到public下的文件。
以及访问/index就可以拿到/public/index.log文件的内容。

methods

methods配置项存在的意义在于,如果我们有一个接口需要同时支持GETPOSTrouter.getrouter.post这样的写法必然是丑陋的。
所以我们可能会想到使用router.all来简化操作:

const Router = require('koa-router')
const router = new Router()

router.all('/ping', ctx => { ctx.body = 'pong!' })

// curl -X GET  /index  => pong!
// curl -X POST /index  => pong!

 

这简直是太完美了,可以很轻松的实现我们的需求,但是如果再多实验一些其他的methods以后,尴尬的事情就发生了:

> curl -X DELETE /index  => pong!
> curl -X PUT    /index  => pong!

 

这显然不是符合我们预期的结果,所以,在这种情况下,基于目前koa-router需要进行如下修改来实现我们想要的功能:

const Koa = require('koa')
const Router = require('router')

const app = new Koa()
// 修改处1
const methods = ['GET', 'POST']
const router = new Router({
  methods
})

// 修改处2
router.all('/', async (ctx, next) => {
  // 理想情况下,这些判断应该交由中间件来完成
  if (!~methods.indexOf(ctx.method)) {
    return await next()
  }

  ctx.body = 'pong!'
})

 

这样的两处修改,就可以实现我们所期望的功能:

> curl -X GET    /index  => pong!
> curl -X POST   /index  => pong!
> curl -X DELETE /index  => Not Implemented
> curl -X PUT    /index  => Not Implemented

 

我个人觉得这是allowedMethods实现的一个逻辑问题,不过也许是我没有get到作者的点,allowedMethods中比较关键的一些源码:

Router.prototype.allowedMethods = function (options) {
  options = options || {}
  let implemented = this.methods

  return function allowedMethods(ctx, next) {
    return next().then(function() {
      let allowed = {}

      // 如果进行了ctx.body赋值,必然不会执行后续的逻辑
      // 所以就需要我们自己在中间件中进行判断
      if (!ctx.status || ctx.status === 404) {
        if (!~implemented.indexOf(ctx.method)) {
          if (options.throw) {
            let notImplementedThrowable
            if (typeof options.notImplemented === 'function') {
              notImplementedThrowable = options.notImplemented() // set whatever the user returns from their function
            } else {
              notImplementedThrowable = new HttpError.NotImplemented()
            }
            throw notImplementedThrowable
          } else {
            ctx.status = 501
            ctx.set('Allow', allowedArr.join(', '))
          }
        } else if (allowedArr.length) {
          // ...
        }
      }
    })
  }
}

 

首先,allowedMethods是作为一个后置的中间件存在的,因为在返回的函数中先调用了next,其次才是针对METHOD的判断,而这样带来的一个后果就是,如果我们在路由的回调中进行类似ctx.body = XXX的操作,实际上会修改本次请求的status值的,使之并不会成为404,而无法正确的触发METHOD检查的逻辑。
想要正确的触发METHOD逻辑,就需要自己在路由监听中手动判断ctx.method是否为我们想要的,然后在跳过当前中间件的执行。
而这一判断的步骤实际上与allowedMethods中间件中的!~implemented.indexOf(ctx.method)逻辑完全是重复的,不太清楚koa-router为什么会这么处理。

当然,allowedMethods是不能够作为一个前置中间件来存在的,因为一个Koa中可能会挂在多个RouterRouter之间的配置可能不尽相同,不能保证所有的Router都和当前Router可处理的METHOD是一样的。
所以,个人感觉methods参数的存在意义并不是很大。。

1. 返回json格式的数据

如果需要响应返回json数据,我们只需要设置响应数据类型为json格式,并把json数据挂载在响应体body上即可实现返回json数据。

ctx.set("Content-Type", "application/json")
ctx.body = JSON.stringify(jsonData)

但是这样每次返回响应都需要写重复的代码,我们再次引入一个koa-json中间件,它会自动将我们返回的数据转换为json格式。

const Koa = require('koa');
const json = require('koa-json');
const app = new Koa();

app.use(json());

app.use((ctx) => {
  ctx.body = { name: 'hfimy',age:23 };
});

$ GET /

{
  "name": "ht",
  "age": 23
}

koa-send提供的功能

koa-send提供了很多便民的选项,除去常用的root以外,还有大概小十个的选项可供使用:

options type default desc
maxage Number 0 设置浏览器可以缓存的毫秒数
对应的HeaderCache-Control: max-age=XXX
immutable Boolean false 通知浏览器该URL对应的资源不可变,可以无限期的缓存
对应的HeaderCache-Control: max-age=XXX, immutable
hidden Boolean false 是否支持隐藏文件的读取
.开头的文件被称为隐藏文件
root String - 设置静态文件路径的根目录,任何该目录之外的文件都是禁止访问的。
index String - 设置一个默认的文件名,在访问目录的时候生效,会自动拼接到路径后边 (此处有一个小彩蛋)
gzip Boolean true 如果访问接口的客户端支持gzip,并且存在.gz后缀的同名文件的情况下会传递.gz文件
brotli Boolean true 逻辑同上,如果支持brotli且存在.br后缀的同名文件
format Boolean true 开启以后不会强要求路径结尾的//path/path/表示的是一个路径 (仅在path是一个目录的情况下生效)
extensions Array false 如果传递了一个数组,会尝试将数组中的所有item作为文件的后缀进行匹配,匹配到哪个就读取哪个文件
setHeaders Function - 用来手动指定一些Headers,意义不大

routerPath

这个参数的存在。。感觉会导致一些很诡异的情况。
这就要说到在注册完中间件以后的router.routes()的操作了:

Router.prototype.routes = Router.prototype.middleware = function () {
  let router = this
  let dispatch = function dispatch(ctx, next) {
    let path = router.opts.routerPath || ctx.routerPath || ctx.path
    let matched = router.match(path, ctx.method)
    // 如果匹配到则执行对应的中间件
    // 执行后续操作
  }
  return dispatch
}

 

因为我们实际上向koa注册的是这样的一个中间件,在每次请求发送过来时,都会执行dispatch,而在dispatch中判断是否命中某个router时,则会用到这个配置项,这样的一个表达式:router.opts.routerPath || ctx.routerPath || ctx.pathrouter代表当前Router实例,也就是说,如果我们在实例化一个Router的时候,如果填写了routerPath,这会导致无论任何请求,都会优先使用routerPath来作为路由检查:

const router = new Router({
  routerPath: '/index'
})

router.all('/index', async (ctx, next) => {
  ctx.body = 'pong!'
})
app.use(router.routes())

app.listen(8888, _ => console.log('server run as http://127.0.0.1:8888'))

 

如果有这样的代码,无论请求什么URL,都会认为是/index来进行匹配:

> curl http://127.0.0.1:8888
pong!
> curl http://127.0.0.1:8888/index
pong!
> curl http://127.0.0.1:8888/whatever/path
pong!

 

2. 记录日志

log4js 是 Node.js 中一个成熟的记录日志的第三方模块。

  • 日志分类 :日志可以大体上分为访问日志和应用日志。访问日志一般记录客户端对项目的访问,主要是 http 请求。这些数据属于运营数据,也可以反过来帮助改进和提升网站的性能和用户体验;应用日志是项目中需要特殊标记和记录的位置打印的日志,包括出现异常的情况,方便开发人员查询项目的运行状态和定位 bug 。应用日志包含了debuginfowarnerror等级别的日志。

  • 日志等级:log4js 中的日志输出可分为如下7个等级:

    ALL、TRACE、DEBUG、INFO、WARN、ERROR、FATAL、MARK、OFF

    ALL:输出所有的日志

    OFF:所有日志都不输出

    其它:输出级别相等或者高级别的日志。

在应用中按照级别记录了日志之后,可以按照指定级别输出高于指定级别的日志。

log4js 官方简单示例

const log4js = require('log4js');
const logger = log4js.getLogger();
logger.level = 'debug';
logger.debug("Some debug messages");

运行该代码,可以在终端看到如下输出:

[2017-12-24T16:45:45.101] [DEBUG] default - Some debug messages

一段带有日期、时间、日志级别和调用 debug 方法时传入的字符串的文本日志。实现了简单的终端日志输出。

log4js 官方复杂示例

const log4js = require('log4js');
log4js.configure({
  appenders: { cheese: { type: 'file', filename: 'cheese.log' } },
  categories: { default: { appenders: ['cheese'], level: 'error' } }
});
const logger = log4js.getLogger('cheese');
logger.trace('Entering cheese testing');
logger.debug('Got cheese.');
logger.info('Cheese is Gouda.');
logger.warn('Cheese is quite smelly.');
logger.error('Cheese is too ripe!');
logger.fatal('Cheese was breeding ground for listeria.');

再次运行,在当前目录下会生成一个日志文件cheese.log文件,文件中有两条日志并记录了error及以上级别的信息,如下

[2017-12-24T17:03:42.761] [ERROR] cheese - Cheese is too ripe!
[2017-12-24T17:03:42.761] [FATAL] cheese - Cheese was breeding ground for listeria.

我们可以通过自定义实现日志中间件,把logger对象挂载到ctx上下文中,从而在应用的其它地方都可以输出日志。

日志切割:当我们的项目在线上环境稳定运行后,访问量会越来越大,日志文件也会越来越大。日益增大的文件对查看和跟踪问题带来了诸多不便,同时增大了服务器的压力。虽然可以按照类型将日志分为两个文件,但并不会有太大的改善。所以我们按照日期将日志文件进行分割。比如:今天将日志输出到 task-2017-12-24.log 文件,明天会输出到 task-2017-12-25.log 文件。减小单个文件的大小不仅方便开发人员按照日期排查问题,还方便对日志文件进行迁移。因此,我们修改日志类型为日期文件,按照日期切割日志输出,以减小单个日志文件的大小。如下,修改代码:

log4js.configure({
    appenders: {
        cheese: {
            type: 'dateFile', // 日志类型 
            filename: `log/task`,  // 输出的文件名
            pattern: '-yyyy-MM-dd.log',  // 文件名增加后缀
            alwaysIncludePattern: true   // 是否总是有后缀名
        }
    },
    categories: { default: { appenders: ['cheese'], level: 'error' } }
});

这样,在当前目录下会生成一个log目录,并生成一个task-2017-12-24.log日志文件。

除了log4js以外,还有十分简洁的koa-logger日志中间件,直接在控制台中输出

const logger = require('koa-logger')
const Koa = require('koa')

const app = new Koa()
app.use(logger())

参数们的具体表现

有些参数的搭配可以实现一些神奇的效果,有一些参数会影响到Header,也有一些参数是用来优化性能的,类似gzipbrotli的选项。

koa-send的主要逻辑可以分为这几块:

  1. path路径有效性的检查
  2. gzip等压缩逻辑的应用
  3. 文件后缀、默认入口文件的匹配
  4. 读取文件数据

在函数的开头部分有这样的逻辑:

const resolvePath = require('resolve-path')
const {
  parse
} = require('path')

async function send (ctx, path. opts = {}) {
  const trailingSlash = path[path.length - 1] === '/'
  const index = opts.index

  // 此处省略各种参数的初始值设置

  path = path.substr(parse(path).root.length)

  // ...

  // normalize path
  path = decode(path) // 内部调用的是`decodeURIComponent`
  // 也就是说传入一个转义的路径也是可以正常使用的

  if (index && trailingSlash) path  = index

  path = resolvePath(root, path)

  // hidden file support, ignore
  if (!hidden && isHidden(root, path)) return
}

function isHidden (root, path) {
  path = path.substr(root.length).split(sep)
  for (let i = 0; i < path.length; i  ) {
    if (path[i][0] === '.') return true
  }
  return false
}

 

巧用routerPath实现转发功能

同样的,这个短路运算符一共有三个表达式,第二个的ctx则是当前请求的上下文,也就是说,如果我们有一个早于routes执行的中间件,也可以进行赋值来修改路由判断所使用的URL

const router = new Router()

router.all('/index', async (ctx, next) => {
  ctx.body = 'pong!'
})

app.use((ctx, next) => {
  ctx.routerPath = '/index' // 手动改变routerPath
  next()
})
app.use(router.routes())

app.listen(8888, _ => console.log('server run as http://127.0.0.1:8888'))

 

这样的代码也能够实现相同的效果。
实例化中传入的routerPath让人捉摸不透,但是在中间件中改变routerPath的这个还是可以找到合适的场景,这个可以简单的理解为转发的一种实现,转发的过程是对客户端不可见的,在客户端看来依然访问的是最初的URL,但是在中间件中改变ctx.routerPath可以很轻易的使路由匹配到我们想转发的地方去

// 老版本的登录逻辑处理
router.post('/login', ctx => {
  ctx.body = 'old login logic!'
})

// 新版本的登录处理逻辑
router.post('/login-v2', ctx => {
  ctx.body = 'new login logic!'
})

app.use((ctx, next) => {
  if (ctx.path === '/login') { // 匹配到旧版请求,转发到新版
    ctx.routerPath = '/login-v2' // 手动改变routerPath
  }
  next()
})
app.use(router.routes())

 

这样就实现了一个简易的转发:

> curl -X POST http://127.0.0.1:8888/login
new login logic!

 

3. 错误处理

错误处理是应用健壮性非常重要的一部分。koa 里面提供了 error 事件,当发生错误时,可以通过监听该事件,对错误进行统一的处理。

const koa = require('koa');
const app = koa();
//当发生错误的时候可以将错误信息写入日志
app.on('error', (err,ctx)=>{
    if (process.env.NODE_ENV != 'test') {
        console.log(err.message);
        console.log(err);
        //ctx.logger.error(err)
    }
});   

这里我们引入一个koa-onerror中间件,优化错误处理信息。

const fs = require('fs');
const koa = require('koa');
const onerror = require('koa-onerror');

const app = new koa();

onerror(app);

app.use(ctx => {
  // foo();
  ctx.body = fs.createReadStream('not exist');
});

路径检查

首先是判断传入的path是否为一个目录,(结尾为/会被认为是一个目录)
如果是目录,并且存在一个有效的index参数,则会将index拼接到path后边。
也就是大概这样的操作:

send(ctx, './public/', {
  index: 'index.js'
})

// ./public/index.js

 

resolve-path 是一个用来处理路径的包,用来帮助过滤一些异常的路径,类似path//file/etc/XXX 这样的恶意路径,并且会返回处理后绝对路径。

isHidden用来判断是否需要过滤隐藏文件。
因为但凡是.开头的文件都会被认为隐藏文件,同理目录使用.开头也会被认为是隐藏的,所以就有了isHidden函数的实现。

其实我个人觉得这个使用一个正则就可以解决的问题。。为什么还要分割为数组呢?

function isHidden (root, path) {
  path = path.substr(root.length)

  return new RegExp(`${sep}\.`).test(path)
}

 

已经给社区提交了PR

注册路由的监听

上述全部是关于实例化Router时的一些操作,下面就来说一下使用最多的,注册路由相关的操作,最熟悉的必然就是router.getrouter.post这些的操作了。
但实际上这些也只是一个快捷方式罢了,在内部调用了来自Routerregister方法:

Router.prototype.register = function (path, methods, middleware, opts) {
  opts = opts || {}

  let router = this
  let stack = this.stack

  // support array of paths
  if (Array.isArray(path)) {
    path.forEach(function (p) {
      router.register.call(router, p, methods, middleware, opts)
    })

    return this
  }

  // create route
  let route = new Layer(path, methods, middleware, {
    end: opts.end === false ? opts.end : true,
    name: opts.name,
    sensitive: opts.sensitive || this.opts.sensitive || false,
    strict: opts.strict || this.opts.strict || false,
    prefix: opts.prefix || this.opts.prefix || '',
    ignoreCaptures: opts.ignoreCaptures
  })

  if (this.opts.prefix) {
    route.setPrefix(this.opts.prefix)
  }

  // add parameter middleware
  Object.keys(this.params).forEach(function (param) {
    route.param(param, this.params[param])
  }, this)

  stack.push(route)

  return route
}

 

该方法在注释中标为了 private 但是其中的一些参数在代码中各种地方都没有体现出来,鬼知道为什么会留着那些参数,但既然存在,就需要了解他是干什么的
这个是路由监听的基础方法,函数签名大致如下:

Param Type Default Description
path String/Array[String] - 一个或者多个的路径
methods Array[String] - 该路由需要监听哪几个METHOD
middleware Function/Array[Function] - 由函数组成的中间件数组,路由实际调用的回调函数
opts Object {} 一些注册路由时的配置参数,上边提到的strictsensitiveprefix在这里都有体现

可以看到,函数大致就是实现了这样的流程:

  1. 检查path是否为数组,如果是,遍历item进行调用自身
  2. 实例化一个Layer对象,设置一些初始化参数
  3. 设置针对某些参数的中间件处理(如果有的话)
  4. 将实例化后的对象放入stack中存储

所以在介绍这几个参数之前,简单的描述一下Layer的构造函数是很有必要的:

function Layer(path, methods, middleware, opts) {
  this.opts = opts || {}
  this.name = this.opts.name || null
  this.methods = []
  this.paramNames = []
  this.stack = Array.isArray(middleware) ? middleware : [middleware]

  methods.forEach(function(method) {
    var l = this.methods.push(method.toUpperCase());
    if (this.methods[l-1] === 'GET') {
      this.methods.unshift('HEAD')
    }
  }, this)

  // ensure middleware is a function
  this.stack.forEach(function(fn) {
    var type = (typeof fn)
    if (type !== 'function') {
      throw new Error(
        methods.toString()   " `"   (this.opts.name || path)  "`: `middleware` "
          "must be a function, not `"   type   "`"
      )
    }
  }, this)

  this.path = path
  this.regexp = pathToRegExp(path, this.paramNames, this.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部分逻辑会依赖于这个数组

4. 视图view

可以根据选择的模板引擎来定义视图。下面简单介绍如何引入模板引擎

  • 使用ejs模板引擎:koa-ejs
const Koa = require('koa');
const render = require('koa-ejs');
const path = require('path');

const app = new Koa();
render(app, {
  root: path.join(__dirname, 'view'),
  layout: 'template',
  viewExt: 'html',
  cache: false,
  debug: true
});

app.use(async function (ctx) {
  await ctx.render('user');
});

app.listen(7001);
  • 使用xtemplate模板引擎:koa-xtpl
const path = require('path')
const Koa = require('koa')
const xtpl = require('koa-xtpl')
const app = new Koa()

// root 
app.use(xtpl(path.join(__dirname, 'views')))
// or options 
app.use(xtpl({
  root: path.join(__dirname, 'views'),
  extname: 'xtpl',
  commands: {}
}))

app.use(async ctx => {
  await ctx.render('demo', { title: new Date() })
})

app.listen(3000)
  • kow-views:可以自定义使用不同的模板
var views = require('koa-views');

// Must be used before any router is used
app.use(views(__dirname   '/views', {
  map: {
    html: 'underscore'
  }
}));

app.use(async function (ctx, next) {
  ctx.state = {
    session: this.session,
    title: 'app'
  };

  await ctx.render('user', {
    user: 'John'
  });
});

压缩的开启与文件夹的处理

在上边的这一坨代码执行完以后,我们就得到了一个有效的路径,(如果是无效路径,resolvePath会直接抛出异常)
接下来做的事情就是检查是否有可用的压缩文件使用,此处没有什么逻辑,就是简单的exists操作,以及Content-Encoding的修改 (用于开启压缩)

后缀的匹配:

if (extensions && !/.[^/]*$/.exec(path)) {
  const list = [].concat(extensions)
  for (let i = 0; i < list.length; i  ) {
    let ext = list[i]
    if (typeof ext !== 'string') {
      throw new TypeError('option extensions must be array of strings or false')
    }
    if (!/^./.exec(ext)) ext = '.'   ext
    if (await fs.exists(path   ext)) {
      path = path   ext
      break
    }
  }
}

 

可以看到这里的遍历是完全按照我们调用send是传入的顺序来走的,并且还做了.符号的兼容。
也就是说这样的调用都是有效的:

await send(ctx, 'path', {
  extensions: ['.js', 'ts', '.tsx']
})

 

如果在添加了后缀以后能够匹配到真实的文件,那么就认为这是一个有效的路径,然后进行了break的操作,也就是文档中所说的:First found is served.

在结束这部分操作以后会进行目录的检测,判断当前路径是否为一个目录:

let stats
try {
  stats = await fs.stat(path)

  if (stats.isDirectory()) {
    if (format && index) {
      path  = '/'   index
      stats = await fs.stat(path)
    } else {
      return
    }
  }
} catch (err) {
  const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']
  if (notfound.includes(err.code)) {
    throw createError(404, err)
  }
  err.status = 500
  throw err
}

 

path

在函数头部的处理逻辑,主要是为了支持多路径的同时注册,如果发现第一个path参数为数组后,则会遍历path参数进行调用自身。
所以针对多个URL的相同路由可以这样来处理:

router.register(['/', ['/path1', ['/path2', 'path3']]], ['GET'], ctx => {
  ctx.body = 'hi there.'
})

 

这样完全是一个有效的设置:

> curl http://127.0.0.1:8888/
hi there.
> curl http://127.0.0.1:8888/path1
hi there.
> curl http://127.0.0.1:8888/path3
hi there.

 

六、代码目录结构

在真正的应用开发中,我们不可能将所有代码都写在app.js中,一般会将代码进行分层。

一个小彩蛋

可以发现一个很有意思的事情,如果发现当前路径是一个目录以后,并且明确指定了format,那么还会再尝试拼接一次index
这就是上边所说的那个彩蛋了,当我们的public路径结构长得像这样的时候:

└── public
    └── index
        └── index # 实际的文件 hello

 

我们可以通过一个简单的方式获取到最底层的文件数据:

router.get('/surprises', async ctx => {
  await send(ctx, '/', {
    root: './public',
    index: 'index'
  })
})

// > curl http://127.0.0.1:12306/surprises
// hello

 

这里就用到了上边的几个逻辑处理,首先是trailingSlash的判断,如果以/结尾会拼接index,以及如果当前path匹配为是一个目录以后,又会拼接一次index
所以一个简单的/加上index的参数就可以直接获取到/index/index
一个小小的彩蛋,实际开发中应该很少会这么玩

methods

而关于methods参数,则默认认为是一个数组,即使是只监听一个METHOD也需要传入一个数组作为参数,如果是空数组的话,即使URL匹配,也会直接跳过,执行下一个中间件,这个在后续的router.routes中会提到

1. 分离路由

我们将所有的router抽离出来,在app.js同级目录创建一个router目录,并在index.js文件中暴露接口,这样可以进一步将对应的路由处理逻辑放在不同的文件里。然后只需要在app.js中引入路由主文件,将app传入即可

// app.js 
const router=require('./router/index')
...
router(app)

// router/index.js
const router = require('koa-router')()
module.export=(app)=>{
  router.get('/',async (ctx,next)=>{
    ...
    await next();
    ...
  })
  ...
  app.use(router.routes(),router.allowedMethods())

}

最终的读取文件操作

最后终于来到了文件读取的逻辑处理,首先就是调用setHeaders的操作。

因为经过上边的层层筛选,这里拿到的path和你调用send时传入的path不是同一个路径。
不过倒也没有必要必须在setHeaders函数中进行处理,因为可以看到在函数结束时,将实际的path返回了出来。
我们完全可以在send执行完毕后再进行设置,至于官方readme中所写的and doing it after is too late because the headers are already sent.
这个不需要担心,因为koa的返回数据都是放到ctx.body中的,而body的解析是在所有的中间件全部执行完以后才会进行处理。
也就是说所有的中间件都执行完以后才会开始发送http请求体,在此之前设置Header都是有效的。

if (setHeaders) setHeaders(ctx.res, path, stats)

// stream
ctx.set('Content-Length', stats.size)
if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString())
if (!ctx.response.get('Cache-Control')) {
  const directives = ['max-age='   (maxage / 1000 | 0)]
  if (immutable) {
    directives.push('immutable')
  }
  ctx.set('Cache-Control', directives.join(','))
}
if (!ctx.type) ctx.type = type(path, encodingExt) // 接口返回的数据类型,默认会取出文件后缀
ctx.body = fs.createReadStream(path)

return path

 

以及包括上边的maxageimmutable都是在这里生效的,但是要注意的是,如果Cache-Control已经存在值了,koa-send是不会去覆盖的。

middleware

middleware则是一次路由真正执行的事情了,依旧是符合koa标准的中间件,可以有多个,按照洋葱模型的方式来执行。
这也是koa-router中最重要的地方,能够让我们的一些中间件只在特定的URL时执行。
这里写入的多个中间件都是针对该URL生效的。

P.S. 在koa-router中,还提供了一个方法,叫做router.use,这个会注册一个基于router实例的中间件

2. 分离controller层,新增一个controller文件夹,将router对应路由的业务处理逻辑提取出来,如下
// controller/home.js
module.export={
  index:async (ctx,next)=>{
    ...
  },
  home:async (ctx,next)=>{
    ctx.body='<h1>Home Page</h1>'
  }
}

// router/index.js
const router = require('koa-router')()
const HomeController = require('../controller/home')
module.export=(app)=>{
  router.get('/',HomeController.home)
  ...
  app.use(router.routes(),router.allowedMethods())

}

目前的代码结构目录已经比较清晰了,适用于以 node 作为中间层的项目。如果想要把 node 作为真正的后端去操作数据库等,建议再分出一层 service,用于处理数据层面的交互,比如调用 model 处理数据库,调用第三方接口等,而controller 里面只做一些简单的参数处理。

使用Stream与使用readFile的区别

在最后给body赋值的位置可以看到,是使用的Stream而并非是readFile,使用Stream进行传输能带来至少两个好处:

  1. 第一种方式,如果是大文件,在读取完成后会临时存放到内存中,并且toString是有长度限制的,如果是一个巨大的文件,toString调用会抛出异常的。
  2. 采用第一种方式进行读取文件,是要在全部的数据都读取完成后再返回给接口调用方,在读取数据的期间,接口都是处于Wait的状态,没有任何数据返回。

可以做一个类似这样的Demo:

const http      = require('http')
const fs        = require('fs')
const filePath  = './test.log'

http.createServer((req, res) => {
  if (req.url === '/') {
    res.end('<html></html>')
  } else if (req.url === '/sync') {
    const data = fs.readFileSync(filePath).toString()

    res.end(data)
  } else if (req.url === '/pipe') {
    const rs = fs.createReadStream(filePath)

    rs.pipe(res)
  } else {
    res.end('404')
  }
}).listen(12306, () => console.log('server run as http://127.0.0.1:12306'))

 

首先访问首页http://127.0.0.1:12306/进入一个空的页面 (主要是懒得搞CORS了),然后在控制台调用两个fetch就可以得到这样的对比结果了:

图片 2
图片 3

可以看出在下行传输的时间相差无几的同时,使用readFileSync的方式会增加一定时间的Waiting,而这个时间就是服务器在进行文件的读取,时间长短取决于读取的文件大小,以及机器的性能。

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 - 是否忽略路由匹配正则结果中的捕获组
3. 分离中间件

此外,随着项目的增大,中间件的数量也越来越多,建议可以把所有的中间件抽出来放在一个middleware文件夹下,不管是第三方中间件,还是自定义的中间件,统一放在此处处理。

koa-static

koa-static是一个基于koa-send的浅封装。
因为通过上边的实例也可以看到,send方法需要自己在中间件中调用才行。
手动指定send对应的path之类的参数,这些也是属于重复性的操作,所以koa-static将这些逻辑进行了一次封装。
让我们可以通过直接注册一个中间件来完成静态文件的处理,而不再需要关心参数的读取之类的问题:

const Koa = require('koa')
const app = new Koa()
app.use(require('koa-static')(root, opts))

 

opts是透传到koa-send中的,只不过会使用第一个参数root来覆盖opts中的root
并且添加了一些细节化的操作:

  • 默认添加一个index.html

    if (opts.index !== false) opts.index = opts.index || 'index.html'
    

     

  • 默认只针对HEADGET两种METHOD

    if (ctx.method === 'HEAD' || ctx.method === 'GET') {
    // ...
    }
    

     

  • 添加一个defer选项来决定是否先执行其他中间件。
    如果deferfalse,则会先执行send,优先匹配静态文件。
    否则则会等到其余中间件先执行,确定其他中间件没有处理该请求才会去寻找对应的静态资源。
    只需指定root,剩下的工作交给koa-static,我们就无需关心静态资源应该如何处理了。

name

首先是name,主要是用于这几个地方:

  1. 抛出异常时更方便的定位
  2. 可以通过router.url(<name>)router.route(<name>)获取到对应的router信息
  3. 在中间件执行的时候,name会被塞到ctx.routerName

    router.register('/test1', ['GET'], _ => {}, { name: 'module' })

    router.register('/test2', ['GET'], _ => {}, { name: 'module' })

    console.log(router.url('module') === '/test1') // true

    try { router.register('/test2', ['GET'], null, {

    name: 'error-module'
    

    }) } catch (e) { console.error(e) // Error: GET error-module: middleware must be a function, not object }

 

如果多个router使用相同的命名,则通过router.url调用返回最先注册的那一个:

// route用来获取命名路由
Router.prototype.route = function (name) {
  var routes = this.stack

  for (var len = routes.length, i=0; i<len; i  ) {
    if (routes[i].name && routes[i].name === name) {
      return routes[i] // 匹配到第一个就直接返回了
    }
  }

  return false
}

// url获取该路由对应的URL,并使用传入的参数来生成真实的URL
Router.prototype.url = function (name, params) {
  var route = this.route(name)

  if (route) {
    var args = Array.prototype.slice.call(arguments, 1)
    return route.url.apply(route, args)
  }

  return new Error('No route found for name: '   name)
}

 

4. view

提供视图,根据选择的模板引擎定义视图,可以通过render渲染后作为响应主体返回给前端,也可以定义一些错误页面如404等。

小结

koa-sendkoa-static算是两个非常轻量级的中间件了。
本身没有太复杂的逻辑,就是一些重复的逻辑被提炼成的中间件。
不过确实能够减少很多日常开发中的任务量,可以让人更专注的关注业务,而非这些边边角角的功能。

跑题说下router.url的那些事儿

如果在项目中,想要针对某些URL进行跳转,使用router.url来生成path则是一个不错的选择:

router.register(
  '/list/:id', ['GET'], ctx => {
    ctx.body = `Hi ${ctx.params.id}, query: ${ctx.querystring}`
  }, {
    name: 'list'
  }
)

router.register('/', ['GET'], ctx => {
  // /list/1?name=Niko
  ctx.redirect(
    router.url('list', { id: 1 }, { query: { name: 'Niko' } })
  )
})

// curl -L http://127.0.0.1:8888 => Hi 1, query: name=Niko

 

可以看到,router.url实际上调用的是Layer实例的url方法,该方法主要是用来处理生成时传入的一些参数。
源码地址:layer.js#L116
函数接收两个参数,paramsoptions,因为本身Layer实例是存储了对应的path之类的信息,所以params就是存储的在路径中的一些参数的替换,options在目前的代码中,仅仅存在一个query字段,用来拼接search后边的数据:

const Layer = require('koa-router/lib/layer')
const layer = new Layer('/list/:id/info/:name', [], [_ => {}])

console.log(layer.url({ id: 123, name: 'Niko' }))
console.log(layer.url([123, 'Niko']))
console.log(layer.url(123, 'Niko'))
console.log(
  layer.url(123, 'Niko', {
    query: {
      arg1: 1,
      arg2: 2
    }
  })
)

 

上述的调用方式都是有效的,在源码中有对应的处理,首先是针对多参数的判断,如果params不是一个object,则会认为是通过layer.url(参数, 参数, 参数, opts)这种方式来调用的。
将其转换为layer.url([参数, 参数], opts)形式的。
这时候的逻辑仅需要处理三种情况了:

  1. 数组形式的参数替换
  2. hash形式的参数替换
  3. 无参数

这个参数替换指的是,一个URL会通过一个第三方的库用来处理链接中的参数部分,也就是/:XXX的这一部分,然后传入一个hash实现类似模版替换的操作:

// 可以简单的认为是这样的操作:
let hash = { id: 123, name: 'Niko' }
'/list/:id/:name'.replace(/(?:/:)(w )/g, (_, $1) => `/${hash[$1]}`)

 

然后layer.url的处理就是为了将各种参数生成类似hash这样的结构,最终替换hash获取完整的URL

5. public静态文件目录以及log日志文件目录。

最终的应用结构如下:

图片 4

co3.png

prefix

上边实例化Layer的过程中看似是opts.prefix的权重更高,但是紧接着在下边就有了一个判断逻辑进行调用setPrefix重新赋值,在翻遍了整个的源码后发现,这样唯一的一个区别就在于,会有一条debug应用的是注册router时传入的prefix,而其他地方都会被实例化Router时的prefix所覆盖。

而且如果想要路由正确的应用prefix,则需要调用setPrefix,因为在Layer实例化的过程中关于path的存储就是来自远传入的path参数。
而应用prefix前缀则需要手动触发setPrefix

// Layer实例化的操作
function Layer(path, methods, middleware, opts) {
  // 省略不相干操作
  this.path = path
  this.regexp = pathToRegExp(path, this.paramNames, this.opts)
}

// 只有调用setPrefix才会应用前缀
Layer.prototype.setPrefix = function (prefix) {
  if (this.path) {
    this.path = prefix   this.path
    this.paramNames = []
    this.regexp = pathToRegExp(this.path, this.paramNames, this.opts)
  }

  return this
}

 

这个在暴露给使用者的几个方法中都有体现,类似的getset以及use
当然在文档中也提供了可以直接设置所有router前缀的方法,router.prefix
文档中就这样简单的告诉你可以设置前缀,prefix在内部会循环调用所有的layer.setPrefix

router.prefix('/things/:thing_id')

 

但是在翻看了layer.setPrefix源码后才发现这里其实是含有一个暗坑的。
因为setPrefix的实现是拿到prefix参数,拼接到当前path的头部。
这样就会带来一个问题,如果我们多次调用setPrefix会导致多次prefix叠加,而非替换:

router.register('/index', ['GET'], ctx => {
  ctx.body = 'hi there.'
})

router.prefix('/path1')
router.prefix('/path2')

// > curl http://127.0.0.1:8888/path2/path1/index
// hi there.

 

prefix方法会叠加前缀,而不是覆盖前缀

七、运行部署

  • 运行:采用 nodemon 来代替 node 以启动应用。当代码发生变化时候,nodemon 会帮我们自动重启。
cnpm i nodemon -g
...
nodemon app.js
  • 部署:使用 pm2,pm2 是一个带有负载均衡功能的Node应用的进程管理器。
cnpm i pm2 -g
...
pm2 start app.js
sensitive与strict

这俩参数没啥好说的,就是会覆盖实例化Router时所传递的那俩参数,效果都一致。

end

end是一个很有趣的参数,这个在koa-router中引用的其他模块中有体现到,path-to-regexp:

if (end) {
  if (!strict) route  = '(?:'   delimiter   ')?'

  route  = endsWith === '$' ? '$' : '(?='   endsWith   ')'
} else {
  if (!strict) route  = '(?:'   delimiter   '(?='   endsWith   '))?'
  if (!isEndDelimited) route  = '(?='   delimiter   '|'   endsWith   ')'
}

return new RegExp('^'   route, flags(options))

 

endWith可以简单地理解为是正则中的$,也就是匹配的结尾。
看代码的逻辑,大致就是,如果设置了end: true,则无论任何情况都会在最后添加$表示匹配的结尾。
而如果end: false,则只有在同时设置了strict: false或者isEndDelimited: false时才会触发。
所以我们可以通过这两个参数来实现URL的模糊匹配:

router.register(
  '/list', ['GET'], ctx => {
    ctx.body = 'hi there.'
  }, {
    end: false,
    strict: true
  }
)

 

也就是说上述代码最后生成的用于匹配路由的正则表达式大概是这样的:

/^/list(?=/|$)/i

// 可以通过下述代码获取到正则
require('path-to-regexp').tokensToRegExp('/list/', {end: false, strict: true})

 

结尾的$是可选的,这就会导致,我们只要发送任何开头为/list的请求都会被这个中间件所获取到。

ignoreCaptures

ignoreCaptures参数用来设置是否需要返回URL中匹配的路径参数给中间件。
而如果设置了ignoreCaptures以后这两个参数就会变为空对象:

router.register('/list/:id', ['GET'], ctx => {
  console.log(ctx.captures, ctx.params)
  // ['1'], { id: '1' }
})

// > curl /list/1

router.register('/list/:id', ['GET'], ctx => {
  console.log(ctx.captures, ctx.params)
  // [ ], {  }
}, {
  ignoreCaptures: true
})
// > curl /list/1

 

这个是在中间件执行期间调用了来自layer的两个方法获取的。
首先调用captures获取所有的参数,如果设置了ignoreCaptures则会导致直接返回空数组。
然后调用params将注册路由时所生成的所有参数以及参数们实际的值传了进去,然后生成一个完整的hash注入到ctx对象中:

// 中间件的逻辑
ctx.captures = layer.captures(path, ctx.captures)
ctx.params = layer.params(path, ctx.captures, ctx.params)
ctx.routerName = layer.name
return next()
// 中间件的逻辑 end

// layer提供的方法
Layer.prototype.captures = function (path) {
  if (this.opts.ignoreCaptures) return []
  return path.match(this.regexp).slice(1)
}

Layer.prototype.params = function (path, captures, existingParams) {
  var params = existingParams || {}

  for (var len = captures.length, i=0; i<len; i  ) {
    if (this.paramNames[i]) {
      var c = captures[i]
      params[this.paramNames[i].name] = c ? safeDecodeURIComponent(c) : c
    }
  }

  return params
}

// 所做的事情大致如下:
// [18, 'Niko']   ['age', 'name']
// =>
// { age: 18, name: 'Niko' }

 

router.param的作用

上述是关于注册路由时的一些参数描述,可以看到在register中实例化Layer对象后并没有直接将其放入stack中,而是执行了这样的一个操作以后才将其推入stack

Object.keys(this.params).forEach(function (param) {
  route.param(param, this.params[param])
}, this)

stack.push(route) // 装载

 

这里是用作添加针对某个URL参数的中间件处理的,与router.param两者关联性很强:

Router.prototype.param = function (param, middleware) {
  this.params[param] = middleware
  this.stack.forEach(function (route) {
    route.param(param, middleware)
  })
  return this
}

 

两者操作类似,前者用于对新增的路由监听添加所有的param中间件,而后者用于针对现有的所有路由添加param中间件。
因为在router.param中有着this.params[param] = XXX的赋值操作。
这样在后续的新增路由监听中,直接循环this.params就可以拿到所有的中间件了。

router.param的操作在文档中也有介绍,文档地址
大致就是可以用来做一些参数校验之类的操作,不过因为在layer.param中有了一些特殊的处理,所以我们不必担心param的执行顺序,layer会保证param一定是早于依赖这个参数的中间件执行的:

router.register('/list/:id', ['GET'], (ctx, next) => {
  ctx.body = `hello: ${ctx.name}`
})

router.param('id', (param, ctx, next) => {
  console.log(`got id: ${param}`)
  ctx.name = 'Niko'
  next()
})

router.param('id', (param, ctx, next) => {
  console.log('param2')
  next()
})


// > curl /list/1
// got id: 1
// param2
// hello: Niko

 

最常用的get/post之类的快捷方式

以及说完了上边的基础方法register,我们可以来看下暴露给开发者的几个router.verb方法:

// get|put|post|patch|delete|del
// 循环注册多个METHOD的快捷方式
methods.forEach(function (method) {
  Router.prototype[method] = function (name, path, middleware) {
    let middleware

    if (typeof path === 'string' || path instanceof RegExp) {
      middleware = Array.prototype.slice.call(arguments, 2)
    } else {
      middleware = Array.prototype.slice.call(arguments, 1)
      path = name
      name = null
    }

    this.register(path, [method], middleware, {
      name: name
    })

    return this
  }
})

Router.prototype.del = Router.prototype['delete'] // 以及最后的一个别名处理,因为del并不是有效的METHOD

 

令人失望的是,verb方法将大量的opts参数都砍掉了,默认只留下了一个name字段。
只是很简单的处理了一下命名name路由相关的逻辑,然后进行调用register完成操作。

router.use-Router内部的中间件

以及上文中也提到的router.use,可以用来注册一个中间件,使用use注册中间件分为两种情况:

  1. 普通的中间件函数
  2. 将现有的router实例作为中间件传入
普通的use

这里是use方法的关键代码:

Router.prototype.use = function () {
  var router = this
  middleware.forEach(function (m) {
    if (m.router) { // 这里是通过`router.routes()`传递进来的
      m.router.stack.forEach(function (nestedLayer) {
        if (path) nestedLayer.setPrefix(path)
        if (router.opts.prefix) nestedLayer.setPrefix(router.opts.prefix) // 调用`use`的Router实例的`prefix`
        router.stack.push(nestedLayer)
      })

      if (router.params) {
        Object.keys(router.params).forEach(function (key) {
          m.router.param(key, router.params[key])
        })
      }
    } else { // 普通的中间件注册
      router.register(path || '(.*)', [], m, { end: false, ignoreCaptures: !hasPath })
    }
  })
}

// 在routes方法有这样的一步操作
Router.prototype.routes = Router.prototype.middleware = function () {
  function dispatch() {
    // ...
  }

  dispatch.router = this // 将router实例赋值给了返回的函数

  return dispatch
}

 

第一种是比较常规的方式,传入一个函数,一个可选的path,来进行注册中间件。
不过有一点要注意的是,.use('path')这样的用法,中间件不能独立存在,必须要有一个可以与之路径相匹配的路由监听存在:

router.use('/list', ctx => {
  // 如果只有这么一个中间件,无论如何也不会执行的
})

// 必须要存在相同路径的`register`回调
router.get('/list', ctx => { })

app.use(router.routes())

 

原因是这样的:

  1. .use.get都是基于.register来实现的,但是.usemethods参数中传递的是一个空数组
  2. 在一个路径被匹配到时,会将所有匹配到的中间件取出来,然后检查对应的methods,如果length !== 0则会对当前匹配组标记一个flag
  3. 在执行中间件之前会先判断有没有这个flag,如果没有则说明该路径所有的中间件都没有设置METHOD,则会直接跳过进入其他流程(比如allowedMethod

    Router.prototype.match = function (path, method) { var layers = this.stack var layer var matched = {

    path: [],
    pathAndMethod: [],
    route: false
    

    }

    for (var len = layers.length, i = 0; i < len; i ) {

    layer = layers[i]
    
    if (layer.match(path)) {
      matched.path.push(layer)
    
      if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) {
        matched.pathAndMethod.push(layer)
    
        // 只有在发现不为空的`methods`以后才会设置`flag`
        if (layer.methods.length) matched.route = true
      }
    }
    

    }

    return matched }

    // 以及在routes中有这样的操作 Router.prototype.routes = Router.prototype.middleware = function () { function dispatch(ctx, next) {

    // 如果没有`flag`,直接跳过
    if (!matched.route) return next()
    

    }

    return dispatch }

 

将其他router实例传递进来

可以看到,如果选择了router.routes()来方式来复用中间件,会遍历该实例的所有路由,然后设置prefix
并将修改完的layer推出到当前的router中。
那么现在就要注意了,在上边其实已经提到了,LayersetPrefix是拼接的,而不是覆盖的。
use是会操作layer对象的,所以这样的用法会导致之前的中间件路径也被修改。
而且如果传入use的中间件已经注册在了koa中就会导致相同的中间件会执行两次(如果有调用next的话):

const middlewareRouter = new Router()
const routerPage1 = new Router({
  prefix: '/page1'
})

const routerPage2 = new Router({
  prefix: '/page2'
})

middlewareRouter.get('/list/:id', async (ctx, next) => {
  console.log('trigger middleware')
  ctx.body = `hi there.`
  await next()
})

routerPage1.use(middlewareRouter.routes())
routerPage2.use(middlewareRouter.routes())

app.use(middlewareRouter.routes())
app.use(routerPage1.routes())
app.use(routerPage2.routes())

 

就像上述代码,实际上会有两个问题:

  1. 最终有效的访问路径为/page2/page1/list/1,因为prefix会拼接而非覆盖
  2. 当我们在中间件中调用next以后,console.log会连续输出三次,因为所有的routes都是动态的,实际上prefix都被修改为了/page2/page1

一定要小心使用,不要认为这样的方式可以用来实现路由的复用

请求的处理

以及,终于来到了最后一步,当一个请求来了以后,Router是怎样处理的。
一个Router实例可以抛出两个中间件注册到koa上:

app.use(router.routes())
app.use(router.allowedMethods())

 

routes负责主要的逻辑。
allowedMethods负责提供一个后置的METHOD检查中间件。

allowedMethods没什么好说的,就是根据当前请求的method进行的一些校验,并返回一些错误信息。
而上边介绍的很多方法其实都是为了最终的routes服务:

Router.prototype.routes = Router.prototype.middleware = function () {
  var router = this

  var dispatch = function dispatch(ctx, next) {
    var path = router.opts.routerPath || ctx.routerPath || ctx.path
    var matched = router.match(path, ctx.method)
    var layerChain, layer, i

    if (ctx.matched) {
      ctx.matched.push.apply(ctx.matched, matched.path)
    } else {
      ctx.matched = matched.path
    }

    ctx.router = router

    if (!matched.route) return next()

    var matchedLayers = matched.pathAndMethod
    var mostSpecificLayer = matchedLayers[matchedLayers.length - 1]
    ctx._matchedRoute = mostSpecificLayer.path
    if (mostSpecificLayer.name) {
      ctx._matchedRouteName = mostSpecificLayer.name
    }

    layerChain = matchedLayers.reduce(function(memo, layer) {
      memo.push(function(ctx, next) {
        ctx.captures = layer.captures(path, ctx.captures)
        ctx.params = layer.params(path, ctx.captures, ctx.params)
        ctx.routerName = layer.name
        return next()
      })
      return memo.concat(layer.stack)
    }, [])

    return compose(layerChain)(ctx, next)
  };

  dispatch.router = this

  return dispatch
}

 

首先可以看到,koa-router同时还提供了一个别名middleware来实现相同的功能。
以及函数的调用最终会返回一个中间件函数,这个函数才是真正被挂在到koa上的。
koa的中间件是纯粹的中间件,不管什么请求都会执行所包含的中间件。
所以不建议为了使用prefix而创建多个Router实例,这会导致在koa上挂载多个dispatch用来检查URL是否符合规则

进入中间件以后会进行URL的判断,就是我们上边提到的可以用来做foraward实现的地方。
匹配调用的是router.match方法,虽说看似赋值是matched.path,而实际上在match方法的实现中,里边全部是匹配到的Layer实例:

Router.prototype.match = function (path, method) {
  var layers = this.stack // 这个就是获取的Router实例中所有的中间件对应的layer对象
  var layer
  var matched = {
    path: [],
    pathAndMethod: [],
    route: false
  }

  for (var len = layers.length, i = 0; i < len; i  ) {
    layer = layers[i]

    if (layer.match(path)) { // 这里就是一个简单的正则匹配
      matched.path.push(layer)

      if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) {
        // 将有效的中间件推入
        matched.pathAndMethod.push(layer)

        // 判断是否存在METHOD
        if (layer.methods.length) matched.route = true
      }
    }
  }

  return matched
}

// 一个简单的正则匹配
Layer.prototype.match = function (path) {
  return this.regexp.test(path)
}

 

而之所以会存在说判断是否有ctx.matched来进行处理,而不是直接对这个属性进行赋值。
这是因为上边也提到过的,一个koa实例可能会注册多个koa-router实例。
这就导致一个router实例的中间件执行完毕后,后续可能还会有其他的router实例也命中了某个URL,但是这样会保证matched始终是在累加的,而非每次都会覆盖。

pathpathAndMethod都是match返回的两个数组,两者的区别在于path返回的是匹配URL成功的数据,而pathAndMethod则是匹配URL且匹配到METHOD的数据

const router1 = new Router()
const router2 = new Router()

router1.post('/', _ => {})

router1.get('/', async (ctx, next) => {
  ctx.redirectBody = 'hi'
  console.log(`trigger router1, matched length: ${ctx.matched.length}`)
  await next()
})

router2.get('/', async (ctx, next) => {
  ctx.redirectBody = 'hi'
  console.log(`trigger router2, matched length: ${ctx.matched.length}`)
  await next()
})

app.use(router1.routes())
app.use(router2.routes())

// >  curl http://127.0.0.1:8888/
// => trigger router1, matched length: 2
// => trigger router2, matched length: 3

 

关于中间件的执行,在koa-router中也使用了koa-compose来合并洋葱:

var matchedLayers = matched.pathAndMethod

layerChain = matchedLayers.reduce(function(memo, layer) {
  memo.push(function(ctx, next) {
    ctx.captures = layer.captures(path, ctx.captures)
    ctx.params = layer.params(path, ctx.captures, ctx.params)
    ctx.routerName = layer.name
    return next()
  })
  return memo.concat(layer.stack)
}, [])

return compose(layerChain)(ctx, next)

 

这坨代码会在所有匹配到的中间件之前添加一个ctx属性赋值的中间件操作,也就是说reduce的执行会让洋葱模型对应的中间件函数数量至少X2
layer中可能包含多个中间件,不要忘了middleware,这就是为什么会在reduce中使用concat而非push
因为要在每一个中间件执行之前,修改ctx为本次中间件触发时的一些信息。
包括匹配到的URL参数,以及当前中间件的name之类的信息。

[
  layer1[0], // 第一个register中对应的中间件1
  layer1[1], // 第一个register中对应的中间件2
  layer2[0]  // 第二个register中对应的中间件1
]

// =>

[
  (ctx, next) => {
    ctx.params = layer1.params // 第一个register对应信息的赋值  
    return next()
  },
  layer1[0], // 第一个register中对应的中间件1
  layer1[1], // 第一个register中对应的中间件2
  (ctx, next) => {
    ctx.params = layer2.params // 第二个register对应信息的赋值  
    return next()
  },
  layer2[0]  // 第二个register中对应的中间件1
]

 

routes最后,会调用koa-compose来合并reduce所生成的中间件数组,以及用到了之前在koa-compose中提到了的第二个可选的参数,用来做洋葱执行完成后最终的回调处理。


小记

至此,koa-router的使命就已经完成了,实现了路由的注册,以及路由的监听处理。
在阅读koa-router的源码过程中感到很迷惑:

  • 明明代码中已经实现的功能,为什么在文档中就没有体现出来呢。
  • 如果文档中不写明可以这样来用,为什么还要在代码中有对应的实现呢?

两个最简单的举证:

  1. 可以通过修改ctx.routerPath来实现forward功能,但是在文档中不会告诉你
  2. 可以通过router.register(path, ['GET', 'POST'])来快速的监听多个METHOD,但是register被标记为了@private

参考资料:

  • koa-router | docs
  • path-to-regexp | docs

示例代码在仓库中的位置:learning-koa-router

本文由www.w88985.com发布于w88官方网站手机版,转载请注明出处:send与它的衍生,koa2入门笔记

关键词: www.w88985.c

上一篇:哥们连学Python
下一篇:没有了