Koa2 中间件原理解析 —— 看了就会写


阅读原文


前言

Koa 2.x 版本是当下最流行的 NodeJS 框架,Koa 2.0 的源码特别精简,不像 Express 封装的功能那么多,因此大部分的功能都是由 Koa 开发团队(同 Express 是一家出品)和社区贡献者针对 Koa 对 NodeJS 的封装特性实现的中间件来提供的,用法很是简单,就是引入中间件,并调用 Koause 方法使用在对应的位置,这样就能够经过在内部操做 ctx 实现一些功能,咱们接下来就讨论经常使用中间件的实现原理以及咱们应该如何开发一个 Koa 中间件供本身和别人使用。html


Koa 的洋葱模型介绍

咱们本次不对洋葱模型的实现原理进行过多的刨析,主要根据 API 的使用方式及洋葱模型分析中间件是如何工做的。npm

// 洋葱模型特色
// 引入 Koa
const Koa = require("koa");

// 建立服务
const app = new Koa();

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);
});

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

// 监听服务
app.listen(3000);

// 1
// 3
// 5
// 6
// 4
// 2
复制代码

咱们知道 Koause 方法是支持异步的,因此为了保证正常的按照洋葱模型的执行顺序执行代码,须要在调用 next 的时候让代码等待,等待异步结束后再继续向下执行,因此咱们在 Koa 中都是建议使用 async/await 的,引入的中间件都是在 use 方法中调用,由此咱们能够分析出每个 Koa 的中间件都是返回一个 async 函数的。json


koa-bodyparser 中间件模拟

想要分析 koa-bodyparser 的原理首先须要知道用法和做用,koa-bodyparser 中间件是将咱们的 post 请求和表单提交的查询字符串转换成对象,并挂在 ctx.request.body 上,方便咱们在其余中间件或接口处取值,使用前需提早安装。redux

npm install koa koa-bodyparser数组

koa-bodyparser 具体用法以下:promise

// koa-bodyparser 的用法
const Koa = require("koa");
const bodyParser = require("koa-bodyparser");

const app = new Koa();

// 使用中间件
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
const querystring = require("querystring");

module.exports = function bodyParser() {
    return async (ctx, next) => {
        await new Promise((resolve, reject) => {
            // 存储数据的数组
            let dataArr = [];

            // 接收数据
            ctx.req.on("data", data => dataArr.push(data));

            // 整合数据并使用 Promise 成功
            ctx.req.on("end", () => {
                // 获取请求数据的类型 json 或表单
                let contentType = ctx.get("Content-Type");

                // 获取数据 Buffer 格式
                let data = Buffer.concat(dataArr).toString();

                if (contentType === "application/x-www-form-urlencoded") {
                    // 若是是表单提交,则将查询字符串转换成对象赋值给 ctx.request.body
                    ctx.request.body = querystring.parse(data);
                } else if (contentType === "applaction/json") {
                    // 若是是 json,则将字符串格式的对象转换成对象赋值给 ctx.request.body
                    ctx.request.body = JSON.parse(data);
                }

                // 执行成功的回调
                resolve();
            });
        });

        // 继续向下执行
        await next();
    };
};
复制代码

在上面代码中由几点是须要咱们注意的,即 next 的调用以及为何经过流接收数据、处理数据和将数据挂在 ctx.request.body 要在 Promise 中进行。app

首先是 next 的调用,咱们知道 Koanext 执行,其实就是在执行下一个中间件的函数,即下一个 use 中的 async 函数,为了保证后面的异步代码执行完毕后再继续执行当前的代码,因此咱们须要使用 await 进行等待,其次就是数据从接收到挂在 ctx.request.body 都在 Promise 中执行,是由于在接收数据的操做是异步的,整个处理数据的过程须要等待异步完成后,再把数据挂在 ctx.request.body 上,能够保证咱们在下一个 useasync 函数中能够在 ctx.request.body 上拿到数据,因此咱们使用 await 等待一个 Promise 成功后再执行 next框架


koa-better-body 中间件模拟

koa-bodyparser 在处理表单提交时仍是显得有一点弱,由于不支持文件上传,而 koa-better-body 则弥补了这个不足,可是 koa-better-bodyKoa 1.x 版本的中间件,Koa 1.x 的中间件都是使用 Generator 函数实现的,咱们须要使用 koa-convertkoa-better-body 转化成 Koa 2.x 的中间件。koa

npm install koa koa-better-body koa-convert path uuid

koa-better-body 具体用法以下:

// koa-better-body 的用法
const Koa = require("koa");
const betterBody = require("koa-better-body");
const convert = require("koa-convert"); // 将 koa 1.0 中间转化成 koa 2.0 中间件
const path = require("path");
const fs = require("fs");
const uuid = require("uuid/v1"); // 生成随机串

const app = new Koa();

// 将 koa-better-body 中间件从 koa 1.0 转化成 koa 2.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);

        // 将文件重命名
        let imgPath = ctx.request.fields.avatar[0].path;
        let newPath = path.resolve(__dirname, uuid());
        fs.rename(imgPath, newPath);
    }
});

app.listen(3000);
复制代码

上面代码中 koa-better-body 的主要功能就是将表单上传的文件存入本地指定的文件夹下,并将文件流对象挂在了 ctx.request.fields 属性上,咱们接下来就模拟 koa-better-body 的功能实现一版基于 Koa 2.x 处理文件上传的中间件。

// 文件:my-koa-better-body.js
const fs = require("fs");
const uuid = require("uuid/v1");
const path = require("path");

// 给 Buffer 扩展 split 方法预备后面使用
Buffer.prototype.split = function (sep) {
    let len = Buffer.from(sep).length; // 分隔符所占的字节数
    let result = []; // 返回的数组
    let start = 0; // 查找 Buffer 的起始位置
    let offset = 0; // 偏移量

    // 循环查找分隔符
    while ((offset = this.indexOf(sep, start)) !== -1) {
        // 将分隔符以前的部分截取出来存入
        result.push(this.slice(start, offset));
        start = offset + len;
    }

    // 处理剩下的部分
    result.push(this.slice(start));

    // 返回结果
    return result;
}

module.exports = function (options) {
    return async (ctx, next) => {
        await new Promise((resolve, reject) => {
            let dataArr = []; // 存储读取的数据

            // 读取数据
            ctx.req.on("data", data => dataArr.push(data));

            ctx.req.on("end", () => {
                // 取到请求体每段的分割线字符串
                let bondery = `--${ctx.get("content-Type").split("=")[1]}`;

                // 获取不一样系统的换行符
                let lineBreak = process.platform === "win32" ? "\r\n" : "\n";

                // 非文件类型数据的最终返回结果
                let fields = {};

                // 分隔的 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")) {
                        // 防止文件内容含有换行而被分割,应从新截取内容并去掉最后的换行
                        let tail = lines.slice(head.length + 2 * lineBreak.length, -lineBreak.length);

                        // 建立可写流并指定写入的路径:绝对路径 + 指定文件夹 + 随机文件名,最后写入文件
                        fs.createWriteStream(path.join(__dirname, options.uploadDir, uuid())).end(tail);
                    } else {
                        // 是普通值取出键名
                        let key = head.match(/name="(\w+)"/)[1];

                        // 将 key 设置给 fields tail 去掉末尾换行后的内容
                        fields[key] = tail.toString("utf8").slice(0, -lineBreak.length);
                    }
                });

                // 将处理好的 fields 对象挂在 ctx.request.fields 上,并完成 Promise
                ctx.request.fields = fields;
                resolve();
            });
        });

        // 向下执行
        await next();
    }
}
复制代码

上面的内容逻辑能够经过代码注释来理解,就是模拟 koa-better-body 的功能逻辑,咱们主要的关心点在于中间件实现的方式,上面功能实现的异步操做依然是读取数据,为了等待数据处理结束仍然在 Promise 中执行,并使用 await 等待,Promise 执行成功调用 next


koa-views 中间件模拟

Node 模板是咱们常用的工具用来在服务端帮咱们渲染页面,模板的种类繁多,所以出现了 koa-view 中间件,帮咱们来兼容这些模板,先安装依赖的模块。

npm install koa koa-views ejs

下面是一个 ejs 的模板文件:

<!-- 文件:index.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>ejs</title>
</head>
<body>
    <%=name%>
    <%=age%>

    <%if (name=="panda") {%>
        panda
    <%} else {%>
        shen
    <%}%>

    <%arr.forEach(item => {%>
        <li><%=item%></li>
    <%})%>
</body>
</html>
复制代码

koa-views 具体用法以下:

// koa-views 的用法
const Koa = require("koa");
const views = require("koa-views");
const path = require("path");

const app = new Koa();

// 使用中间件
app.use(views(path.resolve(__dirname, "views"), {
    extension: "ejs"
}));

app.use(async (ctx, next) => {
    await ctx.render("index", { name: "panda", age: 20, arr: [1, 2, 3] });
});

app.listen(3000);
复制代码

能够看出咱们使用了 koa-views 中间件后,让 ctx 上多了 render 方法帮助咱们实现对模板的渲染和响应页面,就和直接使用 ejs 自带的 render 方法同样,而且从用法能够看出 render 方法是异步执行的,因此须要使用 await 进行等待,接下来咱们就来模拟实现一版简单的 koa-views 中间件。

// 文件:my-koa-views.js
const fs = require("fs");
const path = require("path");
const { promisify } = require("util");

// 将读取文件方法转换成 Promise
const readFile = promisify(fs.radFile);

// 处处中间件
module.exports = function (dir, options) {
    return async (ctx, next) => {
        // 动态引入模板依赖模块
        const view = require(options.extension);

        ctx.render = async (filename, data) => {
            // 异步读取文件内容
            let tmpl = await readFile(path.join(dir, `${filename}.${options.extension}`), "utf8");

            // 将模板渲染并返回页面字符串
            let pageStr = view.render(tmpl, data);

            // 设置响应类型并响应页面
            ctx.set("Content-Type", "text/html;charset=utf8");
            ctx.body = pageStr;
        }

        // 继续向下执行
        await next();
    }
}
复制代码

挂在 ctx 上的 render 方法之因此是异步执行的是由于内部读取模板文件是异步执行的,须要等待,因此 render 方法为 async 函数,在中间件内部动态引入了咱们使的用模板,如 ejs,并在 ctx.render 内部使用对应的 render 方法获取替换数据后的页面字符串,并以 html 的类型响应。


koa-static 中间件模拟

下面是 koa-static 中间件的用法,代码使用的依赖以下,使用前需安装。

npm install koa koa-static mime

koa-static 具体用法以下:

// koa-static 的用法
const Koa = require("koa");
const static = require("koa-static");
const path = require("path");

const app = new Koa();

app.use(static(path.resolve(__dirname, "public")));

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

app.listen(3000);
复制代码

经过使用和分析,咱们知道了 koa-static 中间件的做用是在服务器接到请求时,帮咱们处理静态文件,若是咱们直接访问文件名的时候,会查找这个文件并直接响应,若是没有这个文件路径会看成文件夹,并查找文件夹下的 index.html,若是存在则直接响应,若是不存在则交给其余中间件处理。

// 文件:my-koa-static.js
const fs = require("fs");
const path = require("path");
const mime = require("mime");
const { promisify } = require("util");

// 将 stat 和 access 转换成 Promise
const stat = promisify(fs.stat);
const access = promisify(fs.access)

module.exports = function (dir) {
    return async (ctx, next) => {
        // 将访问的路由处理成绝对路径,这里要使用 join 由于有多是 /
        let realPath = path.join(dir, ctx.path);

        try {
            // 获取 stat 对象
            let statObj = await stat(realPath);

            // 若是是文件,则设置文件类型并直接响应内容,不然看成文件夹寻找 index.html
            if (statObj.isFile()) {
                ctx.set("Content-Type", `${mime.getType()};charset=utf8`);
                ctx.body = fs.createReadStream(realPath);
            } else {
                let filename = path.join(realPath, "index.html");

                // 若是不存在该文件则执行 catch 中的 next 交给其余中间件处理
                await access(filename);

                // 存在设置文件类型并响应内容
                ctx.set("Content-Type", "text/html;charset=utf8");
                ctx.body = fs.createReadStream(filename);
            }
        } catch (e) {
            await next();
        }
    }
}
复制代码

上面的逻辑中须要检测路径是否存在,因为咱们导出的函数都是 async 函数,因此咱们将 stataccess 转化成了 Promise,并用 try...catch 进行捕获,在路径不合法时调用 next 交给其余中间件处理。


koa-router 中间件模拟

Express 框架中,路由是被内置在了框架内部,而 Koa 中没有内置,是使用 koa-router 中间件来实现的,使用前须要安装。

npm install koa koa-router

koa-router 功能很是强大,下面咱们只是简单的使用,而且根据使用的功能进行模拟。

// koa-router 的简单用法
const Koa = require("Koa");
const Router = require("koa-router");

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

router.get("/panda", (ctx, next) => {
    ctx.body = "panda";
});

router.get("/panda", (ctx, next) => {
    ctx.body = "pandashen";
});

router.get("/shen", (ctx, next) => {
    ctx.body = "shen";
})

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

app.listen(3000);
复制代码

从上面看出 koa-router 导出的是一个类,使用时须要建立一个实例,而且调用实例的 routes 方法将该方法返回的 async 函数进行链接,可是在匹配路由的时候,会根据路由 get 方法中的路径进行匹配,并串行执行内部的回调函数,当全部回调函数执行完毕以后会执行整个 Koa 串行的 next,原理同其余中间件,我下面来针对上面使用的功能简易实现。

// 文件:my-koa-router.js
// 控制每个路由层的类
class Layer {
    constructor(path, cb) {
        this.path = path;
        this.cb = cb;
    }
    match(path) {
        // 地址的路由和当前配置路由相等返回 true,不然返回 false
        return path === this.path;
    }
}

// 路由的类
class Router {
    constructor() {
        // 存放每一个路由对象的数组,{ path: /xxx, fn: cb }
        this.layers = [];
    }
    get(path, cb) {
        // 将路由对象存入数组中
        this.layers.push(new Layer(path, cb));
    }
    compose(ctx, next, handlers) {
        // 将匹配的路由函数串联执行
        function dispatch(index) {
            // 若是当前 index 个数大于了存储路由对象的长度,则执行 Koa 的 next 方法
            if(index >= handlers.length) return next();

            // 不然调用取出的路由对象的回调执行,并传入一个函数,在传入的函数中递归 dispatch(index + 1)
            // 目的是为了执行下一个路由对象上的回调函数
            handlers[index].cb(ctx, () => dispatch(index + 1));
        }

        // 第一次执行路由对象的回调函数
        dispatch(0);
    }
    routes() {
        return async (ctx, next) { // 当前 next 是 Koa 本身的 next,即 Koa 其余的中间件
            // 筛选出路径相同的路由
            let handlers = this.layers.filter(layer => layer.match(ctx.path));
            this.compose(ctx, next, handlers);
        }
    }
}
复制代码

在上面咱们建立了一个 Router 类,定义了 get 方法,固然还有 post 等,咱们只实现 get 意思一下,get 内为逻辑为将调用 get 方法的参数函数和路由字符串共同构建成对象存入了数组 layers,因此咱们建立了专门构造路由对象的类 Layer,方便扩展,在路由匹配时咱们能够根据 ctx.path 拿到路由字符串,并经过该路由过滤调数组中与路由不匹配的路由对象,调用 compose 方法将过滤后的数组做为参数 handlers 传入,串行执行路由对象上的回调函数。

compose 这个方法的实现思想很是的重要,在 Koa 源码中用于串联中间件,在 React 源码中用于串联 reduxpromisethunklogger 等模块,咱们的实现是一个简版,并无兼容异步,主要思想是递归 dispatch 函数,每次取出数组中下一个路由对象的回调函数执行,直到全部匹配的路由的回调函数都执行完,执行 Koa 的下一个中间件 next,注意此处的 next 不一样于数组中回调函数的参数 next,数组中路由对象回调函数的 next 表明下一个匹配路由的回调。


总结

上面咱们分析和模拟了一些中间件,其实咱们会理解 KoaExpress 相比较的优点是没有那么繁重,开发使用方便,须要的功能均可以用对应的中间件来实现,使用中间件能够给咱们带来一些好处,好比能将咱们处理好的数据和新方法挂载在 ctx 上,方便后面 use 传入的回调函数中使用,也能够帮咱们处理一些公共逻辑,不至于在每个 use 的回调中都去处理,大大减小了冗余代码,由此看来其实给 Koa 使用中间件的过程就是一个典型的 “装饰器” 模式,在经过上面的分析以后相信你们也了解了 Koa 的 “洋葱模型” 和异步特色,知道该如何开发本身的中间件了。

相关文章
相关标签/搜索