7. koa

112 阅读7分钟

koa

  • npm i koa
  • 底层基于promise,只是对http模块的一个封装,不会对性能产生影响
  • koa的三个核心点
    • 增强req/res,为了不破环原有的,自己生成了两个新的对象:request/response
      • 把自己产生的两个新对象和原有的req/res统称为ctx
    • 中间件机制
    • 错误处理
const Koa = require("koa");

// 通过new的方式创建一个应用
const app = new Koa();

// 可以通过app.use来注册回调
// 为什么会出现koa, 原生http模块的req/res功能比较弱
// 如要通过url模块来解析req.url,res.end只能返回buffer或者string
// koa来增强req/res 有自己的中间件机制 错误处理
app.use((ctx) => {
    // throw new Error("error")
    
    console.log(ctx.req.url); // 原生的
    console.log(ctx.request.url); // koa封装的
    console.log(ctx.request.req.url); // koa封装的request上也有req属性对象
    console.log(ctx.url); // 一般用这个
    
    console.log(ctx.request.query);
    console.log(ctx.query);
    
    
    ctx.body = 'hello zhuzhu'; // 响应结果
    ctx.response.body = 'zhuzhu';
});

app.listen(3000, function() {
    console.log('server start on 3000')
});

// 可以采用事件监听的方式捕获错误
app.on('error', function(err)  {
    console.log(err);
})

koa上下文的实现原理

const EventEmitter = require("events");
const http = require("http");
class Koa extends EventEmitter{
    constructor() {
        super();
        // 保证应用之间互不干扰 否则多个应用共享一个上下文 可能会混乱
        this.context = Object.create(context); // this.context.__proto__ = context
        this.request = Object.create(request);
        this.response = Object.create(response);
        this.middlewares = [];
    }

    use(middleware) {
        this.middlewares.push(middleware);
    }

    _createContext(req, res) { // 创建上下文
        // 这样虽然应用之间的上下文独立了,多个请求共享了同一个上下文
        // 实际上也应该是独立的
        // let ctx = this.context;
        let ctx = Object.create(this.context); // ctx.__proto__.__proto__ = context
        let request = Object.create(this.request);
        let response = Object.create(this.response);

        ctx.request = request;
        ctx.req = ctx.request.req = req;

        ctx.response = response;
        ctx.res = ctx.response.res = res;


        return ctx;
    }
    
    _compose(ctx){
        // 需要将中间件的所有的方法拿出来,先调用第一个,第一个调用完毕后,会调用next,再去执行第二个
        let index = -1;
        const dispatch = (i) => {
            if(i <= index){
                return Promise.reject('next() called multiple times')
            }
            index = i;
            if(this.middlewares.length === i){
                return Promise.resolve();
            }
            return Promise.resolve(this.middleware[i](ctx,()=> dispatch(i+1)))
        }
        return dispatch(0)
    }
    
    _handleRequest = (req, res)=> {
        const ctx = this._createContext(req, res);
        // 默认的状态码是404
        res.statusCode = 404;
        //this.fn(ctx);
        this._compose(ctx).then(() => {
            // ctx.body还支持流的写法 ctx.body = fs.createReadStream(require('path').resolve(__dirname, 'package.json'))
            if(ctx.body instanceof Stream){
                ctx.body.pipe(res);
            }
            else if(typeof ctx.body === 'object') {
                res.setHeader("content-type", "application/json;chartset=utf-8")
                res.end(JSON.stringify(ctx.body));
            }else if(ctx.body) {
                res.end(ctx.body);
            }else{
                res.end("not found");
            }
        }).catch(err=> { 
            this.emit('error', err); // 错误处理 
        })
    }
    listen(...args) {
        const server = http.createServer(this._handleRequest);
        server.listen(...args);
    }
}

module.exports  = Koa;


// context
const context = {
    get query() {
        return this.request.query;
    },
    get path() {
        return this.request.path;
    },
    get body() {
        return this.response.body;
    },
    set body(value) {
        // 当用户调用ctx.body的时候会更改状态码
        // 当往body上赋值时,修改状态码
        this.res.statusCode = 200;
        this.response.body = value;
    }
};
module.exports = context;





// request
const url = require("url");
const request = {
    get url() { // Object.defineProperty 属性访问器
        // 取值的时候是 ctx.request.url 所以this是ctx.request,这上面有req
        // 所以在request上增加req属性的目的是在request对象上通过this获取到req
        return this.req.url
    },
    get path() {
        return url.parse(this.url).pathname;
    },
    get query() {
        return url.parse(this.url, true).query;
    }
};

module.exports = request;







// response
const response = {
    _body: undefined,
    get body() {
        return this._body
    },
    set body(value) {
        this._body = value;
    }
};
module.exports = response;

koa中间件的实现原理

  • koa会将多个中间件进行组合处理,内部会将app.use里面传递进来的函数全部包装成promise,并且将这三个promise串联起来,内部会使用promise链链接起来。(第一个等待第二个执行完,第二个等待第三个执行完...)当第一个中间件函数执行完,就整个执行完成
  • koa使用时:promise或者next前面必须加上await或者return,这样才有等待的效果
  • 必须保证下面的promise完成,所以必须增加await,否则下面没执行完成,就直接继续后面的逻辑了
  • 总之 记住next前一定加上await
  • 上面的compose方法

koa处理请求

app.use(async (ctx, next) => {
    if(ctx.path === '/login' && ctx.method === 'POST'){
        let arr = [];
        ctx.req.on("data", function(chunk) {
        arr.push(chunk);
        });
        ctx.req.on('end', function() {
            // 表单格式默认就是key=value&key=value
            console.log(Buffer.concat(arr).toString());
            ctx.body = Buffer.concat(arr);
        })
    }else{
        await next();
    }
})
/*
注意 这样写 页面不会显示设置的arr buffer 而是显示not found
因为中间件会组合成一个大的promise,第一个中间件完成了就完成了
因为ctx.path === '/login' && ctx.method === 'POST'里面的代码现在的写法并没有等待的效果,是异步执行的
使用koa所有的异步方法都要包装成promise
*/
  • 中间件
// 中间件的作用 可以给koa中的属性扩展功能和方法 可以做一些鉴权相关的
/*
bodyParser这个功能(接受post传递过来的请求数据)很多地方可能都会用到
为了避免再用刀的地方就得手动引入
可以把解析的结果挂在ctx上
在前面定义的中间件肯定会优先执行
*/
// 1. 为了复用 将功能代码提取出来
function bodyParser(ctx) {
    return new Promise((resolve, reject) => {
        let arr = [];
        ctx.req.on("data", function(chunk) {
            arr.push(chunk);
        });
        ctx.req.on('end', function() {
            // 表单格式默认就是key=value&key=value
            console.log(Buffer.concat(arr).toString());
            resolve(Buffer.concat(arr).toString());
        })
    })
}

// 2. 为了不每次手动引入函数 将解析结果挂到ctx上
app.use(async (ctx, next) => {
    ctx.request.body = await new Promise((resolve, reject) => {
        let arr = [];
        ctx.req.on("data", function(chunk) {
            arr.push(chunk);
        });
        ctx.req.on('end', function() {
            // 表单格式默认就是key=value&key=value
            console.log(Buffer.concat(arr).toString());
            resolve(Buffer.concat(arr).toString());
        })
    });
    await next(); // 这个中间件 就是做一件事情 解析请求体 之后继续向下执行
});

// 3. 编写功能时 一般会把功能封装成函数(高阶函数) 作为插件来使用
function bodyParser() {
    return async (ctx, next) => {
        ctx.request.body = await new Promise((resolve, reject) => {
            let arr = [];
            ctx.req.on("data", function(chunk) {
                arr.push(chunk);
            });
            ctx.req.on('end', function() {
                // 表单格式默认就是key=value&key=value
                console.log(Buffer.concat(arr).toString());
                resolve(Buffer.concat(arr).toString());
            })
        });
        await next(); // 这个中间件 就是做一件事情 解析请求体 之后继续向下执行
    }
}

app.use(bodyParser());
  • 功能划分之后 代码就清晰了
const Koa = require("koa");
const app = new Koa();
const path = require("path");
const fs = require("fs");
const bodyParser = require("./middlewares/bodyParser.js");
const static = require('koa-static');
const Router = require('koa-router');
const login = require("./login");

// 路由
const router = new Router();


app.use(bodyParser());
app.use(static(__dirname));
app.use(static(path.resolve(__dirname, 'middleware'))); // 可以使用多次

login(app);

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

app.listen(3000, function(){
    console.log('server start 3000')
});




// 登录相关 login.js
// 业务逻辑可以封装成一个函数,把app传入,就可以去扩展功能
const fs = require("fs");
const path = require("path");
module.exports  = function(app) {
    // 默认访问/from 就显示一个表单 用户可以填写后提交 解析用户参数
    // 两个功能:1.返回表单 2.解析用户参数 用两个app.use来实现
    app.use(async (ctx, next) => {
        if(ctx.path === '/from' && ctx.method === 'GET'){
            // 返回文件
            // koa中默认返回一个流 会认为是要下载这个文件
            // 需要设置响应头
            ctx.set("Content-Type", "text/html;charset=utf-8")
            ctx.body = fs.createReadStream(path.resolve(__dirname, 'form.html'));
        }else{
            await next();
        }
    })

    app.use(async (ctx, next) => {
        if(ctx.path === '/login' && ctx.method === 'POST'){
            ctx.body = await bodyParser(ctx)
        }else{
            await next();
        }
    })
}




// middlewares/bodyParser.js

// buffer没有split分割方法 需要自己扩展
Buffer.prototype.split = function(sep){
    let arr = [];
    let len = Buffer.from(sep).length; // 分隔符可能是中文 保证取到的分隔符的长度是字节
    let offset = 0;
    let index = this.indexOf(sep); // 在二进制中查找sep的位置
    
    while(-1 !== (index = this.indexOf(sep, offset))){
        arr.push(this.slice(offset, index));
        offset = index + len;
    }
    // 最后找不到分隔符石还需要将最后一段内容放入数组
    arr.push(this.slice(offset));
    console.log(arr)
    return arr;
}
// Buffer.from("aaaa|bbbb|cccc").split("|");

// 这里还可以去增加一些全局的功能, 用app.use直接使用这个插件功能
const querystring = require('querystring'); // node自带
conts uuid = reuqire('uuid'); // 用于产生唯一的文件名
function bodyParser() {
    return async (ctx, next) => {
        ctx.request.body = await new Promise((resolve, reject) => {
            let arr = [];
            ctx.req.on("data", function(chunk) {
                arr.push(chunk);
            });
            ctx.req.on('end', function() {
                // 表单格式默认就是key=value&key=value
                console.log(Buffer.concat(arr).toString());
                // resolve(Buffer.concat(arr).toString());
                /*
                将数据解析成对象格式
                用户提交的数据格式有很多种类型
                前端最常见的是3种:1.json 2.表单格式 (a)普通字符串 (b)文件格式
                */
                let type = ctx.get("Content-Type");
                let str = Buffer.concat(arr);
                if(type === 'application/x-www-form-urlencoded'){ // 表单格式
                    resolve(querystring.parse(str.toString()));
                }else if(type.startsWith('text/plain')){ // 纯文本
                    resolve(str.toString());
                }else if(type.startsWith('application/json')){ // 对象
                    resolve(JSON.parse(str.toString()))
                }else if(type.startsWith('multipart/form-data')){ // 图片格式
                    let boundary = '--' + type.split("=")[1];
                    let lines = str.split(boundary).slice(1,-1); // 前后两段空白不要
                    let formData = {};
                    lines.forEach(line => {
                        let [head, body] = line.split("\r\n\r\n"); // 规范中定义的key和value之间就是用"\r\n\r\n"来分隔
                        console.log(head.toString());
                        if(head.includes('filename')){
                            // 文件需要放到服务器上
                            
                            // 文件内容
                            let content = line.slice(head.length + 4, -2);
                            let dir = path.join(__dirname, 'public');
                            let filePath = uuid.v4();
                            let uploadPath = path.join(dir, filePath);
                            formData[key] = {
                                filename: uploadPath,
                                size: content.length
                            }
                            fs.writeFileSync(uploadPath, content);
                        }else{
                            let key = head.toString().match(/name="(.+?)"/)[1];
                            let value = body.toString().slice(0, -2); // 去掉后面的换行和回车
                            formData[key] = value;
                            
                        }
                    })
                    resolve(formData);
                }
                else{
                    resolve({});
                }
            })
        });
        await next(); // 这个中间件 就是做一件事情 解析请求体 之后继续向下执行
    }
}
module.exports = bodyParser;
为了上传图片 要加上 enctype="multipart/form-data"
<form action="/login" method="POST" enctype="multipart/form-data">
    <input type="text" name="username"/>
    <input type="text" name="password"/>
    <input type="file" name="avatar"/>
    <button type="submit">提交</button>
</form>
发送请求的时候 content-type中会自行增加一个分隔符,如:
Content-Type: multipart/form-data;boundary=----WebKitFormBoundaryl2HCVTs0JzYvAFm4
  • koa官方提供了一些现成的第三方模块
    • koa-bodyparser
    • koa-static
    • koa-router (或者叫做 @koa/router) 是一个包

自实现static中间件

const fs = require('fs').promises;
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, 'utf8');
        }catch(e){ // 报错说明处理不了 没有这个文件
            return next(); // 交给下面的中间件来处理
        }
    }
}

自实现路由中间件

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 = [];
    }
    routes(){ // 返回一个中间件
        return async(ctx, next){
            let path = ctx.path;
            let method = ctx.method;
            let layers = this.stack.filter(layer => layer.math(path, method))
            this.compose(layers,ctx, next);
        }
    }
    compose(layers, ctx, next){
        const dispatch = (i) => {
            if(i == layers.length) return next();
            let callback = layers[i].callback;
            return Promise.resolve(callback(ctx, () => dispatch(i+1)))
        }
        return dispatch(0);
    }
}
['get', 'post'].forEach(method => {
    Router.prototype[method] = function(path, callback) {
        let layer = new Layer(path, method, callback);
        this.stack.push(layer);
    }
})