详解KOA2如何手写中间件(装饰器模式)
前言
Koa2.x版本是当下最流行的NodeJS框架,Koa2.0的源码特别精简,不像Express封装的功能那么多,所以大部分的功能都是由Koa开发团队(同Express是一家出品)和社区贡献者针对Koa对NodeJS的封装特性实现的中间件来提供的,用法非常简单,就是引入中间件,并调用Koa的use方法使用在对应的位置,这样就可以通过在内部操作ctx实现一些功能,我们接下来就讨论常用中间件的实现原理以及我们应该如何开发一个Koa中间件供自己和别人使用。
Koa的洋葱模型介绍
我们本次不对洋葱模型的实现原理进行过多的刨析,主要根据API的使用方式及洋葱模型分析中间件是如何工作的。
洋葱模型特点
//引入Koa
constKoa=require("koa");
//创建服务
constapp=newKoa();
app.use(async(ctx,next)=>{
console.log(1);
awaitnext();
console.log(2);
});
app.use(async(ctx,next)=>{
console.log(3);
awaitnext();
console.log(4);
});
app.use(async(ctx,next)=>{
console.log(5);
awaitnext();
console.log(6);
});
//监听服务
app.listen(3000);
//1
//3
//5
//6
//4
//2
我们知道Koa的use方法是支持异步的,所以为了保证正常的按照洋葱模型的执行顺序执行代码,需要在调用next的时候让代码等待,等待异步结束后再继续向下执行,所以我们在Koa中都是建议使用async/await的,引入的中间件都是在use方法中调用,由此我们可以分析出每一个Koa的中间件都是返回一个async函数的。
koa-bodyparser中间件模拟
想要分析koa-bodyparser的原理首先需要知道用法和作用,koa-bodyparser中间件是将我们的post请求和表单提交的查询字符串转换成对象,并挂在ctx.request.body上,方便我们在其他中间件或接口处取值,使用前需提前安装。
npminstallkoakoa-bodyparser
koa-bodyparser具体用法如下:
koa-bodyparser的用法
constKoa=require("koa");
constbodyParser=require("koa-bodyparser");
constapp=newKoa();
//使用中间件
app.use(bodyParser());
app.use(async(ctx,next)=>{
if(ctx.path==="/"&&ctx.method==="POST"){
//使用中间件后ctx.request.body属性自动加上了post请求的数据
console.log(ctx.request.body);
}
});
app.listen(3000);
根据用法我们可以看出koa-bodyparser中间件引入的其实是一个函数,我们把它放在了use中执行,根据Koa的特点,我们推断出koa-bodyparser的函数执行后应该给我们返回了一个async函数,下面是我们模拟实现的代码。
文件:my-koa-bodyparser.js
constquerystring=require("querystring");
module.exports=functionbodyParser(){
returnasync(ctx,next)=>{
awaitnewPromise((resolve,reject)=>{
//存储数据的数组
letdataArr=[];
//接收数据
ctx.req.on("data",data=>dataArr.push(data));
//整合数据并使用Promise成功
ctx.req.on("end",()=>{
//获取请求数据的类型json或表单
letcontentType=ctx.get("Content-Type");
//获取数据Buffer格式
letdata=Buffer.concat(dataArr).toString();
if(contentType==="application/x-www-form-urlencoded"){
//如果是表单提交,则将查询字符串转换成对象赋值给ctx.request.body
ctx.request.body=querystring.parse(data);
}elseif(contentType==="applaction/json"){
//如果是json,则将字符串格式的对象转换成对象赋值给ctx.request.body
ctx.request.body=JSON.parse(data);
}
//执行成功的回调
resolve();
});
});
//继续向下执行
awaitnext();
};
};
在上面代码中由几点是需要我们注意的,即next的调用以及为什么通过流接收数据、处理数据和将数据挂在ctx.request.body要在Promise中进行。
首先是next的调用,我们知道Koa的next执行,其实就是在执行下一个中间件的函数,即下一个use中的async函数,为了保证后面的异步代码执行完毕后再继续执行当前的代码,所以我们需要使用await进行等待,其次就是数据从接收到挂在ctx.request.body都在Promise中执行,是因为在接收数据的操作是异步的,整个处理数据的过程需要等待异步完成后,再把数据挂在ctx.request.body上,可以保证我们在下一个use的async函数中可以在ctx.request.body上拿到数据,所以我们使用await等待一个Promise成功后再执行next。
koa-better-body中间件模拟
koa-bodyparser在处理表单提交时还是显得有一点弱,因为不支持文件上传,而koa-better-body则弥补了这个不足,但是koa-better-body为Koa1.x版本的中间件,Koa1.x的中间件都是使用Generator函数实现的,我们需要使用koa-convert将koa-better-body转化成Koa2.x的中间件。
npminstallkoakoa-better-bodykoa-convertpathuuid
koa-better-body具体用法如下:
koa-better-body的用法
constKoa=require("koa");
constbetterBody=require("koa-better-body");
constconvert=require("koa-convert");//将koa1.0中间转化成koa2.0中间件
constpath=require("path");
constfs=require("fs");
constuuid=require("uuid/v1");//生成随机串
constapp=newKoa();
//将koa-better-body中间件从koa1.0转化成koa2.0,并使用中间件
app.use(convert(betterBody({
uploadDir:path.resolve(__dirname,"upload")
})));
app.use(async(ctx,next)=>{
if(ctx.path==="/"&&ctx.method==="POST"){
//使用中间件后ctx.request.fields属性自动加上了post请求的文件数据
console.log(ctx.request.fields);
//将文件重命名
letimgPath=ctx.request.fields.avatar[0].path;
letnewPath=path.resolve(__dirname,uuid());
fs.rename(imgPath,newPath);
}
});
app.listen(3000);
上面代码中koa-better-body的主要功能就是将表单上传的文件存入本地指定的文件夹下,并将文件流对象挂在了ctx.request.fields属性上,我们接下来就模拟koa-better-body的功能实现一版基于Koa2.x处理文件上传的中间件。
文件:my-koa-better-body.js
constfs=require("fs");
constuuid=require("uuid/v1");
constpath=require("path");
//给Buffer扩展split方法预备后面使用
Buffer.prototype.split=function(sep){
letlen=Buffer.from(sep).length;//分隔符所占的字节数
letresult=[];//返回的数组
letstart=0;//查找Buffer的起始位置
letoffset=0;//偏移量
//循环查找分隔符
while((offset=this.indexOf(sep,start))!==-1){
//将分隔符之前的部分截取出来存入
result.push(this.slice(start,offset));
start=offset+len;
}
//处理剩下的部分
result.push(this.slice(start));
//返回结果
returnresult;
}
module.exports=function(options){
returnasync(ctx,next)=>{
awaitnewPromise((resolve,reject)=>{
letdataArr=[];//存储读取的数据
//读取数据
ctx.req.on("data",data=>dataArr.push(data));
ctx.req.on("end",()=>{
//取到请求体每段的分割线字符串
letbondery=`--${ctx.get("content-Type").split("=")[1]}`;
//获取不同系统的换行符
letlineBreak=process.platform==="win32"?"\r\n":"\n";
//非文件类型数据的最终返回结果
letfields={};
//分隔的buffer去掉没用的头和尾即开头的''和末尾的'--'
dataArr=dataArr.split(bondery).slice(1,-1);
//循环处理dataArr中每一段Buffer的内容
dataArr.forEach(lines=>{
//对于普通值,信息由包含键名的行+两个换行+数据值+换行组成
//对于文件,信息由包含filename的行+两个换行+文件内容+换行组成
let[head,tail]=lines.split(`${lineBreak}${lineBreak}`);
//判断是否是文件,如果是文件则创建文件并写入,如果是普通值则存入fields对象中
if(head.includes("filename")){
//防止文件内容含有换行而被分割,应重新截取内容并去掉最后的换行
lettail=lines.slice(head.length+2*lineBreak.length,-lineBreak.length);
//创建可写流并指定写入的路径:绝对路径+指定文件夹+随机文件名,最后写入文件
fs.createWriteStream(path.join(__dirname,options.uploadDir,uuid())).end(tail);
}else{
//是普通值取出键名
letkey=head.match(/name="(\w+)"/)[1];
//将key设置给fieldstail去掉末尾换行后的内容
fields[key]=tail.toString("utf8").slice(0,-lineBreak.length);
}
});
//将处理好的fields对象挂在ctx.request.fields上,并完成Promise
ctx.request.fields=fields;
resolve();
});
});
//向下执行
awaitnext();
}
}
上面的内容逻辑可以通过代码注释来理解,就是模拟koa-better-body的功能逻辑,我们主要的关心点在于中间件实现的方式,上面功能实现的异步操作依然是读取数据,为了等待数据处理结束仍然在Promise中执行,并使用await等待,Promise执行成功调用next。
koa-views中间件模拟
Node模板是我们经常使用的工具用来在服务端帮我们渲染页面,模板的种类繁多,因此出现了koa-view中间件,帮我们来兼容这些模板,先安装依赖的模块。
npminstallkoakoa-viewsejs
下面是一个ejs的模板文件:
文件:index.ejs
ejs <%=name%> <%=age%> <%if(name=="panda"){%> panda <%}else{%> shen <%}%> <%arr.forEach(item=>{%><%=item%> <%})%>