Koa:核心探秘与入坑指北

2,316 阅读11分钟
  • 框架目录
  • 初识
  • ctx
  • use与中间件
  • ctx.body
  • 请求体
  • static
  • 关于错误捕获
  • 获取demo代码

pre-notify

给最近的koa2学习做个小结,主要分为使用的注意事项以及源码实现两个部分,感觉写得有点啰嗦,以后有空再修正吧~

koa2和promise、async-await密切相关,但碍于篇幅这里并没有对promise部分详细介绍,如果对promise、async-await还不是很清楚的同学可以参考我的这篇文章

异步发展简明指北

(づ ̄ 3 ̄)づ

框架目录

koa/
|
| - context.js
| 
| - request.js
|
| - response.js
|
·- application.js

初识

介绍

首先我们通过Koa包导入的是一个类(Express中是一个工厂函数),我们可以通过new这个类来创建一个app

let Koa = require('koa');

let app = new Koa();

这个app对象上就两个方法

listen 用来启动一个http服务器

app.listen(8080);

use用来注册一个中间件

app.use((ctx,next)=>{
	...
})

// 一般我们将(ctx,next)=>{}包装成一个异步函数
//async (ctx,next)=>{}

可以发现这个use方法接收一个函数作为参数,这个函数又接收两个参数ctxnext

其中ctx是koa自己封装的一个上下文对象,这个对象你可以看做是原生http中req和res的集合。

而next和Express中的next一样,可以在注册的函数中调用用以执行下一个中间件。

框架搭建

/* application.js */

class Koa extends EventEmitter{
    constructor(){
    	super();
        this.middlewares = [];
        this.context = context;
        this.request = request;
        this.response = response;
    }
    
    //监听&&启动http服务器
    listen(){
    	const server = http.createServer(this.handleRequest());
     	return server.listen(...arguments);
    }
    
    //注册中间件
    use(fn){
    	this.middlewares.push(fn);
    }
    
    //具体的请求处理方法
    handleRequest(){
    	return (req,res)=>{...}
    }
   
   //创建上下文对象
    createContext(req,res){
    	...
    }
    
    //将中间件串联起来的方法
    compose(ctx,middlewares){
    	...
    }
    
}

ctx

用法

ctx,即context,大多数人称之为上下文对象。

这个对象下有4个主要的属性,它们分别是

  • ctx.req:原生的req对象
  • ctx.res:原生的res对象
  • ctx.request:koa自己封装的request对象
  • ctx.response:koa自己封装的response对象

其中koa自己封装的和原生的最大的区别在于,koa自己封装的请求和响应对象的内容不仅囊括原生的还有一些其独有的东东

...
console.log(ctx.query); //原生中需要经过url.parse(p,true).query才能得到的query对象
console.log(ctx.path); //原生中需要经过url.parse(p).pathname才能得到的路径(url去除query部分)
...

除此之外,ctx本身还代理了ctx.request和ctx.response身上的属性,So以上还能简化为

...
console.log(ctx.query);
console.log(ctx.path);
...

原理

首先我们要创建三个模块来代表三个对象

ctx对象/模块

//context.js
let proto = {};
module.exports = proto;

请求对象/模块

let request = {};
module.export = request;

响应对象/模块

let response = {};
module.exports = response;

然后在application.js中引入

let context = require('./context');
let request = require('./request');
let response = require('./response');

并在constructor中挂载

this.context = context;
this.request = request;
this.response = response;

接下来我们来理一理流程,ctx.request/response是koa自己封装的,那么什么时候生成的呢?肯定是得到原生的req、res之后才能进行加工吧。

So,我们在专门处理请求的handleRequest方法中来创建我们的ctx

handleRequest(){
    return (req,res)=>{
    	let ctx = this.createContext(req,res);
        ...
    }
}

createContext

为了使我们的每次请求都拥有一个全新的ctx对象,我们在createContext方法中采用Object.create来创建一个继承this.context的对象。

这样即使我们在每一次请求中改变了ctx,例如ctx.x = xxx,那么也只会在本次的ctx中创建一个私有属性而不会影响到下一次请求中的ctx。(response也是同理)

createContext(req,res){
    let ctx = Object.create(this.context); //ctx.__proto__ = this.context
    ctx.response = Object.create(this.response);
}

呃,说回我们最初的目的,我们要创建一个ctx对象,这个ctx对象下有4个主要的属性:ctx.reqctx.resctx.requestctx.response

其中ctx.request/response囊括ctx.req/res的所有属性,那么我们要怎么将原本req和res下的属性赋给koa自己创建的请求和响应对象呢?这么多属性,难道要一个一个for过去吗?显然这样操作太重了。

我们能不能想个办法当我们访问ctx.request.xx属性的时候其实就是访问ctx.req.xx属性呢?

get/set

of coures,we can!

//application.js

createContext(req,res){
...
    ctx.req = ctx.request.req = req;
    ctx.res = ctx.response.res = res;
    return ctx;
}

// --- --- ---

//request.js
let request = {
    get method(){
    	return this.req.method
    }
}

通过以上代码,我们在访问ctx.response.method的时候其实访问的就是ctx.req.method,而ctx.req.method其实就是req.method。

其中的get method(){}这样的语法时es5里的特性,当我们访问该对象下的method属性时就会执行该方法并以这个方法中的返回值作为我们访问到的值。

我们还能通过在get中做一些处理来为ctx.request创建一些原生的req对象没有的属性

let request = {
...
  get query(){
    return url.parse(this.req.url,true).query;
  }
};

delateGetter

除了通过ctx.request.query拿到query对象,我们还能通过ctx.query这样简写的方式直接拿到原本在request下的所有属性。这又是怎么实现的呢?

很简单,我们只需要用ctx来代理ctx.request即可

// context.js
...
function delateGetter(property,name){
    proto.__defineGetter__(name,function(){
    	return this[property][name];
    });
}

delateGetter('request','query');
...

通过proto.__defineGetter__(name,function(){})代理(和上一节所展示的get/set是一样的功能)

当我们访问proto.name的时候其实就是访问的proto.property.name

也就是说ctx.query的值即为ctx.request.query的值。

注意: 这里get/set,delateGetter/Setter都只演示了一两个属性,想要更多,就得添加更多的get()/set(),delateGetter/Setter(),嗯源码就这么干的。

use与中间件

我们通过use方法注册中间件,这些中间件会根据注册时的先后顺序,被依次注册到一个数组当中,并且当一个请求来临时,这些中间件会按照注册时的顺序依次执行。

但这些中间件并不是自动依次执行的,我们需要在中间件callback中手动调用next方法执行下一个中间件callback(和express中一样),并且最后的显示的结果是有点微妙的。

next与洋葱模型

我们来看下面这样一个栗子

app.use(async (ctx,next)=>{
  console.log(1);
  await next();
  console.log(2);
});

app.use(async (ctx,next)=>{
  console.log(3);
  await next();
  console.log(4);
});

<<<
1
3
4
2

嗯,第一次接触koa的同学肯定很纳闷,what the fk???这是什么鬼?

嗯,我们先记住这个现象先不急探究,再接着往下看看中间件其它需要注意的事项。

中间件与异步

我们在注册中间件时,通常会将回调包装成一个async函数,这样,假若我们的回调中存在异步代码,就能不写那冗长的回调而通过await关键字像写同步代码一样写异步回调。

app.use(async (ctx,next)=>{
    let result = await read(...); //promisify的fs.read
    console.log(result);
})

包装成promise

需要补充的一点时,要让await有效,就需要将异步函数包装成一个promise,通常我们直接使用promisify方法来promise化一个异步函数。

next也要使用await

还需要注意的是假若下一个要执行的中间件回调中也存在异步函数,我们就需要在调用next时也使用await关键字

app.use(async (ctx,next)=>{
    let result = await read(...); //promisify的fs.read
    console.log(result);
    await next(); //本身async函数也是一个promise对象,故使用await有效
    console.log('1');
})

不使用awiat的话,假若下一个中间件中存在异步就不会等待这个异步执行完就会打印1

原理

接下来我们来看怎么实现中间件洋葱模型。

如果一个中间件回调中没有异步的话其实很简单

let fns = [fn1,fn2,fn3];
function dispatch(index){
    let middle = fns[index];
    if(fns.length === index)return;
    middle(ctx,()=>dispatch(index+1));
}

我们只需要有一个dispatch方法来遍历存放中间件回调函数的数组。并将这个dispatch方法作为next参数传给本次执行的中间件回调。

这样我们就能在一个回调中通过调用next来执行下一次遍历(dispatch)。

但一个中间件回调中往往存在异步代码,如果我们像上面这样写是达不到我们想要的效果的。

那么,要怎样做呢?我们需要借助promise的力量,将每个中间件回调串联起来。

handleRequest(){
    ...
    let composeMiddleWare = this.compose(ctx,this.middlewares)
    ...
}
compose(ctx,middlewares){
    function dispatch(index){
    	let middleware = middlewares[index];
        if(middlewares.length === index)return Promise.resolve();
        return Promise.resolve(middleware(ctx,()=>dispatch(index+1)));
    }
    return dispatch(0);
}

其中一个middleware即是一个async fn,而每一个async fn都是一个promise,

在上面的代码中我们让这个promise转换为成功态后才会去遍历下一个middleware,而什么时候promise才会转为成功态呢?

嗯,只有当一个async fn执行完毕后,async fn这个promise才会转为成功态,而每一个async fn在内部若存在异步函数的话又可以使用await,

SO,我们就这样将各个middleware串联了起来,即使其内部存在异步代码,也会按照洋葱模型执行。

ctx.body

使用

ctx.body即是koa中对于原生res的封装。

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

<<<
hello

需要注意的是,ctx.body可以被多次连续调用,但只有最后被调用的会生效

...
ctx.body = 'hello';
ctx.body = 'world';
...

<<<
world

ctx.body支持以流、object作为响应值。

ctx.body = {...}
ctx.body = require('fs').createReadStream(...);

原理

我们调用ctx.body实际上调用的是ctx.response.body(参考ctx代理部分),并且我们只是给这个属性赋值,这仅仅是个属性并不会立马调用res.end等来进行响应

而我们真正响应的时候是在所有中间件都执行完毕以后

//application.js

handleRequest(){
  let composeMiddleWare = this.compose(ctx,this.middlewares);
    composeMiddleWare.then(function(){
        let body = ctx.body;
        if(body == undefined){
          return res.end('Not Found');
        }
        if(body instanceof Stream){ //如果ctx.body是一个流
          return body.pipe(res);
        }
        if(typeof body === 'object'){ //如果ctx.body是一个对象
          return res.end(JSON.stringify(body));
        }
        res.end(ctx.body); //ctx.body是字符串和buffer
    })
}

请求体

上面我们说过在async fn中我们能使用await来"同步"异步方法。

其实除了一些异步方法需要await外,请求体的接收也需要await

app.use(async (ctx,next)=>{
    ctx.req.on('data',function(data){ //异步的
      buffers.push(data);
    });
    ctx.req.on('end',function(){
      console.log(Buffer.concat(buffers));
    });
});

app.use(async (ctx,next)=>{
	console.log(1);
})

像上面这样的例子1是会被先打印的,这意味着如果我们想要在一个中间件中获取完请求体并在下一个中间件中使用它,是做不到。

那么要怎样才能达到我们预期的效果呢?在await一节中我们讲过,我们可以将代码封装成一个promise然后再去await就能达到同步的效果。

我们可以通过npm下载到这样的一个库——koa-bodyparser

let bodyparser = require('koa-bodyparser');
app.use(bodyparser());

这样,我们就能在任何中间件回调中通过ctx.request.body获取到请求体

app.use(async (ctx,next)=>{
	console.log(ctx.request.body);
})

但需要注意的是,koa-bodyparser并不支持文件上传,如果要支持文件上传,可以使用better-body-parser这个包。

body-parser 实现

function bodyParser(options={}){
  let {uploadDir} = options;
  return async (ctx,next)=>{
    await new Promise((resolve,reject)=>{
      let buffers = [];
      ctx.req.on('data',function(data){
        buffers.push(data);
      });
      ctx.req.on('end',function(){
        let type = ctx.get('content-type');
        // console.log(type);//multipart/form-data; boundary=----WebKitFormBoundary8xKcmy8E9DWgqZT3
        let buff = Buffer.concat(buffers);
        let fields = {};

        if(type.includes('multipart/form-data')){
          //有文件上传的情况
        }else if(type === 'application/x-www-form-urlencoded'){
          // a=b&&c=d
          fields = require('querystring').parse(buff.toString());
        }else if(type === 'application/json'){
          fields = JSON.parse(buff.toString());
        }else{
          // 是个文本
          fields = buff.toString();
        }
        ctx.request.fields = fields;
        resolve();
      });
    });
    await next();
  };
}

可以发现 bodyParser本身即是一个async fn,它将on data on end接收请求体部分代码封装成了一个promise,并且await这个promise,这意味着只有当这个promise转换为成功态时,才会走next(遍历下一个中间件)。

而我们什么时候将这个promise转换为成功态的呢?是在将请求体解析完毕封装成一个fields对象并挂载到ctx.request.fields之后,我们才resolve了这个promise。

以上就是bodyParser实现的大体思路,还有一点我们没有详细解释的部分既是有文件上传的情况。

当我们将enctype设置为multipart/form-data,我们就可以通过表单上传文件了,此时请求体的样子是长这样的

嗯。。。其实接下来要干的的事情即是对这个请求体进行拆分拼接。。一顿字符串操作,这里就不再展开啦

有兴趣的朋友可以到我的仓库中查看完整代码示例点我~

static

Koa中为我们提供了静态服务器的功能,不过需要额外引一个包

let static = require('koa-static');
let path = require('path');
app.use(static(path.join(__dirname,'public')));
app.listen(8000);

只需三行代码,咳咳,静态服务器你值得拥有。

原理

原理也很简单啦,static首先它也是一个async fn

function static(p){

  return async(ctx,next)=>{
    try{
      p = path.join(p,'.'+ctx.path);
      let statObj = await stat(p);
      if(statObj.isDirectory()){
		...
      }else{
        ctx.body = fs.createReadStream(p); //在body上挂载可读流,会在所有中间件执行完毕后以pipe形式输出到客户端
      }
    }catch(e) {
      await next();
    }
  }
}

关于错误捕获

最后,koa还允许我们在一个async fn中抛出一个异常,此时它会返回个客户端一串字符串Internal Server Error,并且它还会触发一个error事件

app.use(async (ctx,next)=>{
  throw Error('something wrong');
});

app.on('error',function(err){
  console.log('e',err);
});

原理

// application.js
handleRequest(){
	...
    composeMiddleWare.then(function(){
    	...
    }).catch(e=>{
    	this.emit('error',e);
        res.end('Internal Server Error');
    })
    ...
}

获取demo代码

仓库:点我