- 框架目录
- 初识
- 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方法接收一个函数作为参数,这个函数又接收两个参数ctx
、next
,
其中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.jslet 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.req
、ctx.res
、ctx.request
、ctx.response
。
其中ctx.request/response
囊括ctx.req/res
的所有属性,那么我们要怎么将原本req和res下的属性赋给koa自己创建的请求和响应对象呢?这么多属性,难道要一个一个for过去吗?显然这样操作太重了。
我们能不能想个办法当我们访问ctx.request.xx属性的时候其实就是访问ctx.req.xx属性呢?
get/set
of coures,we can!
//application.jscreateContext(req,res){... ctx.req = ctx.request.req = req; ctx.res = ctx.response.res = res; return ctx;}// --- --- ---//request.jslet 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);});<<<1342复制代码
嗯,第一次接触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';});<<
需要注意的是,ctx.body
可以被多次连续调用,但只有最后被调用的会生效
...ctx.body = 'hello';ctx.body = 'world';...<<
ctx.body
支持以流、object作为响应值。
ctx.body = {...}复制代码
ctx.body = require('fs').createReadStream(...);复制代码
原理
我们调用ctx.body实际上调用的是ctx.response.body(参考ctx代理部分),并且我们只是给这个属性赋值,这仅仅是个属性并不会立马调用res.end等来进行响应
而我们真正响应的时候是在所有中间件都执行完毕以后
//application.jshandleRequest(){ 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.jshandleRequest(){ ... composeMiddleWare.then(function(){ ... }).catch(e=>{ this.emit('error',e); res.end('Internal Server Error'); }) ...}复制代码
获取demo代码
仓库: