Zijue/blog

14.koa中间件功能扩展

Opened this issue · 0 comments

Zijue commented

如何实现一个请求参数解析中间件

Koa中有个包就是专门做这个的,叫koa-bodyparser。它可以将我们请求携带的参数进行解析,接下来我们自己实现其中一些功能。

const querystring = require('querystring');

function bodyParser() {
    return async (ctx, next) => {
        ctx.request.body = await new Promise((resolve, reject) => {
            // 接受请求的信息存入数组,待接受完毕之后运行处理逻辑
            let bufferArr = [];
            ctx.req.on('data', function (chunk) {
                bufferArr.push(chunk);
            });

            ctx.req.on('end', function () {
                let type = ctx.get('content-type');
                let body = Buffer.concat(bufferArr);
                if (type.startsWith('application/x-www-form-urlencoded')) { // 表单格式
                    resolve(querystring.parse(body.toString()));
                } else if (type.startsWith('application/json')) { // json格式
                    resolve(JSON.parse(body.toString()));
                } else if (type.startsWith('text/plain')) { // 纯文本格式,一般需要用户自行判断如何处理该数据
                    resolve(body.toString());
                } else if (type.startsWith('multipart/form-data')) {
                    // todo
                } else {
                    resolve({});
                }
            })
        });
        await next(); // 解析请求体之后,继续向下执行
    }
}
module.exports = bodyParser;

上述代码中,还没有处理form-data格式用于接受文件。我们先试试打印未处理请求的效果是怎么样的:

从图上可以看到请求内容被boundary分隔符分成了好几部分。我们要解析出对应的内容,就需要对接收的请求体二进制数据做切割。但是Buffer中没有split方法,我们可以使用8.Buffer 的应用中实现的split方法。最终完成如下:

const path = require('path');
const fs = require('fs').promises;
const querystring = require('querystring');
const uuid = require('uuid');

Buffer.prototype.split = function (sep) {
    let arr = [];
    let offset = 0; // 偏移位置
    let current = 0; // 当前找到的索引
    let len = Buffer.from(sep).length; // 分隔符真实的长度,单位字节
    while (-1 != (current = this.indexOf(sep, offset))) { // 查找到位置(字节)的索引,只要有继续
        arr.push(this.slice(offset, current));
        offset = current + len;
    }
    arr.push(this.slice(offset));
    return arr;
}

function bodyParser({ dir } = {}) {
    return async (ctx, next) => {
        ctx.request.body = await new Promise((resolve, reject) => {
            // 接受请求的信息存入数组,待接受完毕之后运行处理逻辑
            let bufferArr = [];
            ctx.req.on('data', function (chunk) {
                bufferArr.push(chunk);
            });

            ctx.req.on('end', function () {
                let type = ctx.get('content-type');
                let body = Buffer.concat(bufferArr);
                if (type.startsWith('application/x-www-form-urlencoded')) { // 表单格式
                    resolve(querystring.parse(body.toString()));
                } else if (type.startsWith('application/json')) { // json格式
                    resolve(JSON.parse(body.toString()));
                } else if (type.startsWith('text/plain')) { // 纯文本格式,一般需要用户自行判断如何处理该数据
                    resolve(body.toString());
                } else if (type.startsWith('multipart/form-data')) { // form-data
                    let boundary = '--' + type.split('=')[1]; // content-type中的分隔符比实际传递的少两个'-'
                    let lines = body.split(boundary).slice(1, -1); // 切割之后的数组需要去除头尾
                    let formData = {};
                    lines.forEach(async function (line) {
                        /**
                            Content-Disposition: form-data; name="name"\r\n
                            \r\n
                            zijue
                         */
                        let [head, body] = line.split('\r\n\r\n'); // 规范中定义的key、value之间的填充
                        head = head.toString();
                        let key = head.match(/name="(.+?)"/)[1];
                        if (head.includes('filename')) { // 传递的是文件,需要将文件存储到服务器上
                            /*
                                Content-Disposition: form-data; name="upload"; filename="test.txt"
                                Content-Type: text/plain // 此处结尾有不可见字符 \r\n,共计两个字节
                                 // 此处结尾有不可见字符 \r\n,共计两个字节
                                Zijue

                                520

                                Xiaodai
                                 // 此处结尾有不可见字符 \r\n,共计两个字节
                             */
                            // 所以文件内容的区间就为[head部分长度+4, 行总长-2]
                            let fileContent = line.slice(head.length + 4, -2);
                            dir = dir || path.join(__dirname, 'upload');
                            let filePath = uuid.v4(); // 生成唯一文件名
                            let uploadPath = path.join(dir, filePath);
                            formData[key] = {
                                filename: uploadPath,
                                size: fileContent.length
                            }
                            await fs.writeFile(uploadPath, fileContent);
                        } else {
                            let value = body.toString();
                            formData[key] = value.slice(0, -2); // 去除结尾处的 \r\n
                        }
                    });
                    resolve(formData);
                } else {
                    resolve({});
                }
            })
        });
        await next(); // 解析请求体之后,继续向下执行
    }
}
module.exports = bodyParser;

如何实现koa的静态文件服务器功能

同样的Koa中也有一个专门处理该问题的包,叫koa-static。当用户访问指定静态文件目录时,返回该文件的内容。实现起来很简单,代码如下:

const path = require('path')
const fs = require('fs').promises

async function static(staticPath) {
    return async (ctx, next) => {
        try {
            let filePath = path.join(staticPath, ctx.path);
            let statObj = await fs.stat(filePath);
            if (statObj.isDirectory()) { // 如果是文件夹 会查找index.html
                filePath = path.join(filePath, 'index.html')
            }
            ctx.body = await fs.readFile(filePath, 'utf-8');
        } catch (e) { // 报错说明处理不了 没有这个文件
            await next(); // 继续向下执行
        }
    }
}
module.exports = static;

简单实现Koa路由router的功能

Koa中有一个包@koa/router就是专门处理路由映射问题的。实现原理如下:

class Layer {
    constructor(path, method, callback) {
        this.path = path;
        this.method = method;
        this.callback = callback;
    }
    match(path, method) {
        return this.path == path && this.method == method.toLowerCase()
    }
}

class Router {
    constructor() {
        this.stack = [];
    }
    compose(layers, ctx, next) {
        let dispatch = (i) => {
            if (i == layers.length) return next();
            let callback = layers[i].callback;
            return Promise.resolve(callback(ctx, () => dispatch(i + 1)))
        }
        return dispatch(0);
    }
    routes() {
        return async (ctx, next) => { // app.routes()的返回结果,标准的中间件函数
            let path = ctx.path;
            let method = ctx.method;
            // 当请求来临时,从暂存的栈中过滤出与之相匹配的路径,可能有多个
            let layers = this.stack.filter(layer => layer.match(path, method));
            this.compose(layers, ctx, next);
        }
    }
};

['get', 'post'].forEach(method => [
    Router.prototype[method] = function (path, callback) {
        let layer = new Layer(path, method, callback);
        this.stack.push(layer);
    }
])

module.exports = Router

路由的使用方法:

router = new Router();
router.get('/login', async (ctx, next) => {
    console.log('login-1');
    await next();
})
router.get('/login', async (ctx, next) => {
    console.log('login-2')
    await next()
})
router.post('/login', async (ctx, next) => {
    console.log('login-post')
    await next();
})
app.use(router.routes());

当使用不同请求方式时,会走到不同的处理中间件中。

如何优雅地在koa上扩展业务功能代码

module.exports = function (app) {
    app.use(async (ctx, next) => {
        if (ctx.path === '/login' && ctx.method == 'POST') {
            // 验证用户密码,生成cookie之类的
            console.log('todo');
        } else {
            await next();
        }
    });
}

// 然后在主逻辑代码中引入使用
const login = require('./login');

login(app); // 类似于装饰器的方式

上述代码具体地址koa中间件的使用