NodeJS 进阶 —— Koa 源码分析

Koa2 洋葱模型 —— compose 串联中间件的四种实现 原文出自:https://www.pandashen.com 前言 Koa 是当下主流 NodeJS 框架,以轻量见长,而它中间件机制与相对传统的 Express 支持了异步,所以编码时经常使用 async/await,提高了可读性,使代码变得更优雅,上一篇文章 NodeJS 进阶 —— Koa 源码分析,也对 “洋葱模型” 和实现它的 compose 进行分析,由于个人觉得 compose... 阅读详情

在这里插入图片描述


阅读原文


前言

Koa 2.x 版本是当下最流行的 NodeJS 框架,同时社区涌现出一大批围绕 Koa 2.x 的中间件以及基于 Koa 2.x 封装的企业级框架,如 egg.js,然而 Koa 本身的代码却非常精简,精简到所有文件的代码去掉注释后还不足 2000 行,本篇就围绕着这 2000 行不到的代码抽出核心逻辑进行分析,并压缩成一版只有 200 行不到的简易版 Koa。


Koa 分析过程

在下面的内容中,我们将对 Koa 所使用的功能由简入深的分析,首先会给出使用案例,然后根据使用方式,分析实现原理,最后对分析的功能进行封装,封装过程会从零开始并一步一步完善,代码也是从少到多,会完整的看到一个简版 Koa 诞生的过程,再此之前我们打开 Koa 源码地址。

在这里插入图片描述

通过上面对 Koa 源码目录的截图,发现只有 4 个核心文件,为了方便理解,封装简版 Koa 的文件目录结构也将严格与源码同步。


搭建基本服务

在引入 Koa 时我们需要创建一个 Koa 的实例,而启动服务是通过 listen 监听一个端口号实现的,代码如下。

const Koa = require("koa");

const app = new Koa();

app.listen(3000, () => {
    console.log("server start 3000");
});

通过使用我们可以分析出 Koa 导出的应该是一个类,或者构造函数,鉴于 Koa 诞生的时间以及基于 node v7.6.0 以上版本的情况来分析,正是 ES6 开始 “横行霸道” 的时候,所以推测 Koa 导出的应该是一个类,打开源码一看,果然如此,所以我们也通过 class 的方式来实现。

而从启动服务的方式上看,app.listen 的调用方式与原生 http 模块提供的 server.listen 几乎相同,我们分析,listen 方法应该是对原生 http 模块的一个封装,启动服务的本质还是靠 http 模块来实现的。

// 文件路径:&#126koa/application.js
const http = require("http");

class Koa {
    handleRequest(req, res) {
        // 请求回调
    }
    listen(...args) {
        // 创建服务
        let server = http.createServer(this.handleRequest.bind(this));

        // 启动服务
        server.listen(...args);
    }
}

module.exports = Koa;

上面的代码初步实现了我们上面分析出的需求,为了防止代码冗余,我们将创建服务的回调抽取成一个 handleRequest 的实例方法,内部的逻辑在后面完善,现在可以创建这个 Koa 类的实例,通过调用实例的 listen 方法启动一个服务器。


上下文对象 ctx 的封装

1、基本使用

Koa 还有一个很重要的特性,就是它的 ctx 上下文对象,我们可以调用 ctx 的 request 和 response 属性获取原 req 和 res 的属性和方法,也在 ctx 上增加了一些原生没有的属性和方法,总之 ctx 给我们要操作的属性和方法提供了多种调用方式,使用案例如下。

const Koa = require("koa");

const app = new Koa();

app.use((ctx, next) => {
    // 原生的 req 对象的 url 属性
    console.log(ctx.req.url);
    console.log(ctx.request.req.url);
    console.log(ctx.response.req.url);

    // Koa 扩展的 url
    console.log(ctx.url);
    console.log(ctx.request.req.url);

    // 设置状态码和响应内容
    ctx.response.status = 200;
    ctx.body = "Hello World";
});

app.listen(3000, () => {
    console.log("server start 3000");
});

2、创建 ctx 的引用关系

从上面我们可以看出,ctx 为 use 方法的第一个参数,request 和 response 是 ctx 新增的,而通过这两个属性又都可以获取原生的 req 和 res 属性,ctx 本身也可以获取到原生的 req 和 res,我们可以分析出,ctx 是对这些属性做了一个集成,或者说特殊处理。

源码的文件目录中正好有与 request、response 名字相对应的文件,并且还有 context 名字的文件,我们其实可以分析出这三个文件就是用于封装 ctx 上下文对象使用的,而封装 ctx 中也会用到 req 和 res,所以核心逻辑应该在 handleRequest 中实现。

在使用案例中 ctx 是作为 use 方法中回调函数的参数,所以我们分析应该有一个数组统一管理调用 use 后传入的函数,Koa 应该有一个属性,值为数组,用来存储这些函数,下面是实现代码。

// 文件路径:&#126koa/application.js
const http = require("http");

// ***************************** 以下为新增代码 *****************************
const context = require("./context");
const request = require("./request");
const response = require("./response");
// ***************************** 以上为新增代码 *****************************

class Koa {
// ***************************** 以下为新增代码 *****************************
    contructor() {
        // 存储中间件
        this.middlewares = [];

        // 为了防止通过 this 修改属性而导致影响原引入文件的导出对象,做一个继承
        this.context = Object.create(context);
        this.request = Object.create(request);
        this.response = Object.create(response);
    }
    use(fn) {
        // 将传给 use 的函数存入数组中
        this.middlewares.push(fn);
    }
    createContext(req, res) {
        // 或取定义的上下文
        let ctx = this.context;

        // 增加 request 和 response
        ctx.request = this.request;
        ctx.response = this.response;

        // 让 ctx、request、response 都具有原生的 req 和 res
        ctx.req = ctx.request.req = ctx.response.req = req;
        ctx.res = ctx.response.res = ctx.request.res = res;

        // 返回上下文对象
        return ctx;
    }
// ***************************** 以上为新增代码 *****************************
    handleRequest(req, res) {
        // 创建 ctx 上下文对象
        let ctx = this.createContext(req, res);
    }
    listen(...args) {
        // 创建服务
        let server = http.createServer(this.handleRequest.bind(this));

        // 启动服务
        server.listen(...args);
    }
}

module.exports = Koa;

首先,给实例创建了三个属性 context、request 和 response 分别继承了 context.js、request.js 和 response.js 导出的对象,之所以这么做而不是直接赋值是防止操作实例属性时 “污染” 原对象,而获取原模块导出对象的属性可以通过原型链进行查找,并不影响取值。

其次,给实例挂载了 middlewares 属性,值为数组,为了存储 use 方法调用时传入的函数,在 handleRequest 把创建 ctx 属性及引用的过程单独抽取成了 createContext 方法,并在 handleRequest 中调用,返回值为创建好的 ctx 对象,而在 createContext 中我们根据案例中的规则构建了 ctx 的属性相关的各种引用关系。

3、实现 request 取值

上面构建的属性中,所有通过访问原生 req 或 res 的属性都能获取到,反之则是 undefined,这就需要我们去构建 request.js。

// 文件路径:&#126koa/request.js
const url = require("url");

// 给 url 和 path 添加 getter
const request = {
    get url() {
        return this.req.url;
    },
    get path() {
        return url.parse(this.req.url).pathname;
    }
};

module.exports = request;

上面我们只构造了两个属性 url 和 path,我们知道 url 是原生所自带的属性,我们在使用 ctx.request.url 获取是通过 request 对象设置的 getter,将 ctx.request.req.url 的值返回了。

path 是原生 req 所没有的属性,但却是通过原生 req 的 url 属性和 url 模块共同构建出来的,所以我们同样用了给 request 对象设置 getter 的方式获取 req 的 url 属性,并使用 url 模块将转换对象中的 pathname 返回,此时就可以通过 ctx.request.path 来获取访问路径,至于源码中我们没有处理的 req 属性都是通过这样的方式建立的引用关系。

4、实现 response 的取值和赋值

Koa 中 response 对象的真正作用是给客户端进行响应,使用时是通过访问属性获取,并通过重新赋值实现响应,但是现在 response 获取的属性都是 undefined,我们这里先不管响应给浏览器的问题,首先要让 response 下的某个属性有值才行,下面我们来实现 response.js。

// 文件路径:&#126koa/response.js
// 给 body 和 status 添加 getter 和 setter
const response = {
    get body() {
        return this._body;
    },
    set body(val) {
        // 只要给 body 赋值就代表响应成功
        this.status = 200;
        this._body = val;
    },
    get status() {
        return this.res.statusCode;
    },
    set status(val) {
        this.res.statusCode = val;
    }
};

module.exports = response;

这里选择了 Koa 在使用时,response 对象上比较重要的两个属性进行处理,因为这两个属性是服务器响应客户端所必须的,并模仿了 request.js 的方式给 body 和 status 设置了 getter,不同的是响应浏览器所做的其实是赋值操作,所以又给这两个属性添加了 setter,对于 status 来说,直接操作原生 res 对象的 statusCode 属性即可,因为同为赋值操作。

还有一点,响应是通过给 body 赋值实现,我们认为只要触发了 body 的 setter 就成功响应,所以在 body 的 getter 中将响应状态码设置为 200,至于 body 赋值是如何实现响应的,放在后面再说。

5、ctx 代理 request、response 的属性

上面实现了通过 request 和 response 对属性的操作,Koa 虽然给我们提供了多样的属性操作方式,但由于我们程序猿(媛)们都很 “懒”,几乎没有人会在开发的时候愿意多写代码,大部分情况都是通过 ctx 直接操作 request 和 response 上的属性,这就是我们现在的问题所在,这些属性通过 ctx 访问不到。

我们需要给 ctx 对象做一个代理,让 ctx 可以访问到 request 和 response 上的属性,这个场景何曾相识,不正是 Vue 创建实例时,将传入参数对象 options 的 data 属性代理给实例本身的场景吗,既然如此,我们也通过相似的方式实现,还记得上面引入的 context 模块作为实例的 context 属性所继承的对象,而剩下的最后一个核心文件 context.js 正是用来做这件事的,代码如下。

// 文件路径:&#126koa/context.js
const proto = {};

// 将传入对象属性代理给 ctx
function defineGetter(property, key) {
    proto.__defineGetter__(key, function () {
        return this[property][key];
    });
}

// 设置 ctx 值时直接操作传入对象的属性
function defineSetter(property, key) {
    proto.__defineSetter__(key, function (val) {
        this[property][key] = val;
    });
}

// 将 request 的 url 和 path 代理给 ctx
defineGetter("request", "url");
defineGetter("request", "path");

// 将 response 的 body 和 status 代理给 ctx
defineGetter("response", "body");
defineSetter("response", "body");
defineGetter("response", "status");
defineSetter("response", "status");

module.exports = proto;

在 Vue 中是使用 Object.defineProperty 来时实现的代理,而在 Koa 源码中借助了 delegate 第三方模块来实现的,并在添加代理时链式调用了 delegate 封装的方法,我们并没有直接使用 delegate 模块,而是将 delegate 内部的核心逻辑抽取出来在 context.js 中直接编写,这样方便大家理解原理,也可以清楚的知道是如何实现代理的。

我们封装了两个方法 defineGetter 和 defineSetter 分别来实现取值和设置值时,将传入的属性(第二个参数)代理给传入的对象(第一个参数),函数内是通过 Object.prototype.__defineGetter__ 和 Object.prototype.__defineSetter__ 实现的,点击方法名可查看官方 API。


洋葱模型 —— 实现中间件的串行

现在已经实现了 ctx 上下文对象的创建,但是会发现我们封装 ctx 之前所写的案例 use 回调中的代码并不能执行,也不会报错,根本原因是 use 方法内传入的函数没有调用,在使用 Koa 的过程中会发现,我们往往使用多个 use,并且传入 use 的回调函数除了 ctx 还有第二个参数 next,而这个 next 也是一个函数,调用 next 则执行下一个 use 中的回调函数,否则就会 “卡住”,这种执行机制被取名为 “洋葱模型”,而这些被执行的函数被称为 “中间件”,下面我们就来分析这个 “洋葱模型” 并实现中间件的串行。

在这里插入图片描述

1、洋葱模型分析

下面来看看表述洋葱模型的一个经典案例,结果似乎让人匪夷所思,一时很难想到原因,不着急先看了再说。

const Koa = require("koa");

const app = new Koa();

app.use((ctx, next) => {
    console.log(1);
    next();
    console.log(2);
});

app.use((ctx, next) => {
    console.log(3);
    next();
    console.log(4);
});

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

app.listen(3000, () => {
    console.log("server start 3000");
});

// 1
// 3
// 5
// 6
// 4
// 2

根据上面的执行特性我们不妨来分析以下,我们知道 use 方法执行时其实是把传入的回调函数放入了实例的 middlewares 数组中,而执行结果打印了 1 说明第一个回调函数被执行了,接着又打印了 2 说明第二个回调函数被执行了,根据上面的代码我们可以大胆的猜想,第一个回调函数调用的 next 肯定是一个函数,可能就是下一个回调函数,或者是 next 函数中执行了下一个回调函数,这样根据函数调用栈先进后出的原则,会在 next 执行完毕,即出栈后,继续执行上一个回调函数的代码。

2、支持异步的中间件串行

在实现中间件串行之前需要补充一点,中间件函数内调用 next 时,前面的代码出现异步,则会继续向下执行,等到异步执行结束后要执行的代码插入到同步代码中,这会导致执行顺序错乱,所以在官方推荐中告诉我们任何遇到异步的操作前都需要使用 await 进行等待(包括 next,因为下一个中间件中可能包含异步操作),这也间接的说明了传入 use 的回调函数只要有异步代码需要 await,所以应该是 async 函数,而了解 ES7 特性 async/await 的我们来说,一定能分析出 next 返回的应该是一个 Promise 实例,下面是我们在之前 application.js 基础上的实现。

// 文件路径:&#126koa/application.js
const http = require("http");
const context = require("./context");
const request = require("./request");
const response = require("./response");

class Koa {
    contructor() {
        // 存储中间件
        this.middlewares = [];

        // 为了防止通过 this 修改属性而导致影响原引入文件的导出对象,做一个继承
        this.context = Object.create(context);
        this.request = Object.create(request);
        this.response = Object.create(response);
    }
    use(fn) {
        // 将传给 use 的函数存入数组中
        this.middlewares.push(fn);
    }
    createContext(req, res) {
        // 或取定义的上下文
        let ctx = this.context;

        // 增加 request 和 response
        ctx.request = this.request;
        ctx.response = this.response;

        // 让 ctx、request、response 都具有原生的 req 和 res
        ctx.req = ctx.request.req = ctx.response.req = req;
        ctx.res = ctx.response.res = ctx.request.res = res;

        // 返回上下文对象
        return ctx;
    }
// ***************************** 以下为新增代码 *****************************
    compose(ctx, middles) {
        // 创建一个递归函数,参数为存储中间件的索引,从 0 开始
        function dispatch(index) {
            // 在所有中间件执行之后给 compose 返回一个 Promise(兼容一个中间件都没写的情况)
            if (index === middles.length) return Promise.resolve();

            // 取出第 index 个中间件函数
            const route = middles[index];

            // 为了兼容中间件传入的函数不是 async,一定要包装成一个 Promise
            return Promise.resolve(route(ctx, () => dispatch(index + 1)));
        }
        return dispatch(0); // 默认执行一次
    }
// ***************************** 以上为新增代码 *****************************
    handleRequest(req, res) {
        // 创建 ctx 上下文对象
        let ctx = this.createContext(req, res);

// ***************************** 以下为新增代码 *****************************
        // 执行 compose 将中间件组合在一起
        this.compose(ctx, this.middlewares);
// ***************************** 以上为新增代码 *****************************
    }
    listen(...args) {
        // 创建服务
        let server = http.createServer(this.handleRequest.bind(this));

        // 启动服务
        server.listen(...args);
    }
}

module.exports = Koa;

仔细想想我们其实在利用循环执行每一个 middlewares 中的函数,而且需要把下一个中间件函数的执行作为函数体的代码包装一层成为新的函数,并作为参数 next 传入,那么在上一个中间件函数内部调用 next 就相当于先执行了下一个中间件函数,而下一个中间件函数内部调用 next,又先执行了下一个的下一个中间件函数,依次类推。

直到执行到最后一个中间件函数,调用了 next,但是 middlewares 中已经没有下一个中间件函数了,这也是为什么我们要给下一个中间件函数外包了一层函数而不是直接将中间件函数传入的原因之一(另一个原因是解决传参问题,因为在执行时还要传入下一个中间件函数),但是防止递归 “死循环”,要配合一个终止条件,即指向 middlewares 索引的变量等于了 middlewares 的长度,最后只是相当于执行了一个只有一条判断语句的函数就 return 的函数,而并没有报错。

在这整个过程中如果有任意一个 next 没有被调用,就不会向下执行其他的中间件函数,这样就 “卡住了”,完全符合 Koa 中间件的执行规则,而 await 过后也就是下一个中间件优先执行完成,则会继续执行当前中间件 next 调用下面的代码,这也就是 1、3、5、6、4、2 的由来。

为了实现所描述的执行过程,将所有中间件串行的逻辑抽出了一个 compose 方法,但是我们没有使用普通的循环,而是使用递归实现的,首先在 compose 创建 dispatch 递归函数,参数为当前数组函数的索引,初始值为 0,函数逻辑是先取出第一个函数执行,并传入一个回调函数参数,回调函数参数中递归 dispatch,参数 +1,这样就会将整个中间件串行起来了。

但是上面的串行也只是同步串行,如果某个中间件内部需要等待异步,则调用得 next 函数必须返回一个 Promise,有些中间件没有执行异步,则不需要 async 函数,也不会返回 Promise,而 Koa 规定只要遇到 next 就需要等待,则将取出每一个中间件函数执行后的结果使用 Promise.resolve 强行包装成一个成功态的 Promise,就对异步进行了兼容。

我们最后也希望 compose 返回一个 Promise 方便执行一些只有在中间件都执行后才会执行的逻辑,每次串行最后执行的都是一个只有一条判断逻辑就 return 了的函数(包含一个中间件也没有的情况),此时 compose 返回了 undefined,无法调用 then 方法,为了兼容这种情况也强行的使用相同的 “招数”,在判断条件的 return 关键字后面加上了 Promise.resolve(),直接返回了一个成功态的 Promise。

注意:官方只是推荐我们在调用 next 的时候使用 await 等待,即使执行的 next 真的存在异步,也不是非 await 不可,我们完全可以使用 return 来代替 await,唯一的区别就是 next 调用后,下面的代码不会再执行了,类比 “洋葱模型”,形象地说就是 “下去了就上不来了”,这个完全可以根据我们的使用需要而定,如果 next 后面不再有任何逻辑,完全可以使用 return 替代。


实现真正的响应

在对 ctx 实现属性代理后,我们通过 ctx.body 重新赋值其实只是改变了 response.js 导出对象的 _body 属性,而并没有实现真正的响应,看下面这个 Koa 的例子。

const Koa = require("koa");
const fs = require("fs");

const app = new Koa();

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

app.use(async (ctx, next) => {
    ctx.body = fs.createReadStream("1.txt");

    ctx.body = await new Promise((resolve, reject) => {
        setTimeout(() => resolve("panda"), 3000);
    });
});

app.listen(3000, () => {
    console.log("server start 3000");
});

其实最后响应给客户端的值是 panda,正常在最后一个中间件执行后,由于异步定时器的代码没有执行完,ctx.body 最后的值应该是 1.txt 的可读流,这与客户端接收到的值相违背,通过这个猜想上的差异我们应该知道,compose 在串行执行中间件后为什么要返回一个 Promise 了,因为最后执行的只有判断语句的函数会等待我们例子中最后一个 use 传入的中间件函数执行完毕调用,也就是说在执行 compose 返回值的 then 时,ctx.body 的值已经是 panda 了。

// 文件路径:&#126koa/application.js
const http = require("http");

// ***************************** 以下为新增代码 *****************************
const Stream = require("stream");
// ***************************** 以上为新增代码 *****************************

const context = require("./context");
const request = require("./request");
const response = require("./response");

class Koa {
    contructor() {
        // 存储中间件
        this.middlewares = [];

        // 为了防止通过 this 修改属性而导致影响原引入文件的导出对象,做一个继承
        this.context = Object.create(context);
        this.request = Object.create(request);
        this.response = Object.create(response);
    }
    use(fn) {
        // 将传给 use 的函数存入数组中
        this.middlewares.push(fn);
    }
    createContext(req, res) {
        // 或取定义的上下文
        let ctx = this.context;

        // 增加 request 和 response
        ctx.request = this.request;
        ctx.response = this.response;

        // 让 ctx、request、response 都具有原生的 req 和 res
        ctx.req = ctx.request.req = ctx.response.req = req;
        ctx.res = ctx.response.res = ctx.request.res = res;

        // 返回上下文对象
        return ctx;
    }
    compose(ctx, middles) {
        // 创建一个递归函数,参数为存储中间件的索引,从 0 开始
        function dispatch(index) {
            // 在所有中间件执行之后给 compose 返回一个 Promise(兼容一个中间件都没写的情况)
            if (index === middles.length) return Promise.resolve();

            // 取出第 index 个中间件函数
            const route = middles[index];

            // 为了兼容中间件传入的函数不是 async,一定要包装成一个 Promise
            return Promise.resolve(route(ctx, () => dispatch(index + 1)));
        }
        return dispatch(0); // 默认执行一次
    }
    handleRequest(req, res) {
        // 创建 ctx 上下文对象
        let ctx = this.createContext(req, res);

// ***************************** 以下为修改代码 *****************************
        // 设置默认状态码(Koa 规定),必须在调用中间件之前
        ctx.status = 404;

        // 执行 compose 将中间件组合在一起
        this.compose(ctx, this.middlewares).then(() => {
            // 获取最后 body 的值
            let body = ctx.body;

            // 检测 ctx.body 的类型,并使用对应的方式将值响应给浏览器
            if (Buffer.isBuffer(body) || typeof body === "string") {
                // 处理 Buffer 类型的数据
                res.setHeader("Content-Type", "text/plain;charset=utf8");
                res.end(body);
            } else if (typeof body === "object") {
                // 处理对象类型
                res.setHeader("Content-Type", "application/json;charset=utf8");
                res.end(JSON.stringify(body));
            } else if (body instanceof Stream) {
                // 处理流类型的数据
                body.pipe(res);
            } else {
                res.end("Not Found");
            }
        });
// ***************************** 以上为修改代码 *****************************
    }
    listen(...args) {
        // 创建服务
        let server = http.createServer(this.handleRequest.bind(this));

        // 启动服务
        server.listen(...args);
    }
}

module.exports = Koa;

处理 response 时,在 body 的 setter 中将状态码设置为了 200,就是说需要设置 ctx.body 去触发 setter 让响应成功,如果没有给 ctx.body 设置任何值,默认应该是无响应的,在官方文档也有默认状态码为 404 的明确说明,所以在 handleRequest 把状态码设置为了 404,但必须在 compose 执行之前才叫默认状态码,因为中间件中可能会操作 ctx.body,重新设置状态码。

在 comose 的 then 中,也就是在所有中间件执行后,我们取出 ctx.body 的值,即为最后生效的响应值,对该值进行了数据类型验证,如 Buffer、字符串、对象和流,并分别用不同的方式处理了响应,但本质都是调用的原生 res 对象的 end 方法。


中间件错误处理

在上面的逻辑当中我们实现了很多 Koa 的核心逻辑,但是只考虑了顺利执行的情况,并没有考虑如果中间件中代码执行出现错误的问题,如下面案例。

const Koa = require("koa");

const app = new Koa();

app.use((ctx, next) => {
    // 抛出异常
    throw new Error("Error");
});

// 添加 error 监听
app.on("error", err => {
    console.log(err);
});

app.listen(3000, () => {
    console.log("server start 3000");
});

我们之所以让 compose 方法在执行所有中间件后返回一个 Promise 还有一个更重要的意义,因为在 Promise 链式调用中,只要其中任何一个环节出现代码执行错误或抛出异常,都会直接执行出现错误的 then 方法中错误的回调或者最后的 catch 方法,对于 Koa 中间件的串行而言,最后一个 then 调用 catch 方法就是 compose 的返回值调用 then 后继续调用的 catch,catch 内可以捕获到任意一个中间件执行时出现的错误。

// 文件路径:&#126koa/application.js
const http = require("http");
const Stream = require("stream");

// ***************************** 以下为新增代码 *****************************
const EventEmitter = require("events");
const httpServer = require("_http_server");
// ***************************** 以上为新增代码 *****************************

const context = require("./context");
const request = require("./request");
const response = require("./response");

// ***************************** 以下为修改代码 *****************************
// 继承 EventEmitter 后可以用创建的实例 app 添加 error 监听,可以通过 emit 触发监听
class Koa extends EventEmitter {
    contructor() {
        supper();
// ***************************** 以上为修改代码 *****************************

        // 存储中间件
        this.middlewares = [];

        // 为了防止通过 this 修改属性而导致影响原引入文件的导出对象,做一个继承
        this.context = Object.create(context);
        this.request = Object.create(request);
        this.response = Object.create(response);
    }
    use(fn) {
        // 将传给 use 的函数存入数组中
        this.middlewares.push(fn);
    }
    createContext(req, res) {
        // 或取定义的上下文
        let ctx = this.context;

        // 增加 request 和 response
        ctx.request = this.request;
        ctx.response = this.response;

        // 让 ctx、request、response 都具有原生的 req 和 res
        ctx.req = ctx.request.req = ctx.response.req = req;
        ctx.res = ctx.response.res = ctx.request.res = res;

        // 返回上下文对象
        return ctx;
    }
    compose(ctx, middles) {
        // 创建一个递归函数,参数为存储中间件的索引,从 0 开始
        function dispatch(index) {
            // 在所有中间件执行之后给 compose 返回一个 Promise(兼容一个中间件都没写的情况)
            if (index === middles.length) return Promise.resolve();

            // 取出第 index 个中间件函数
            const route = middles[index];

            // 为了兼容中间件传入的函数不是 async,一定要包装成一个 Promise
            return Promise.resolve(route(ctx, () => dispatch(index + 1)));
        }
        return dispatch(0); // 默认执行一次
    }
    handleRequest(req, res) {
        // 创建 ctx 上下文对象
        let ctx = this.createContext(req, res);

        // 设置默认状态码(Koa 规定),必须在调用中间件之前
        ctx.status = 404;

        // 执行 compose 将中间件组合在一起
        this.compose(ctx, this.middlewares).then(() => {
            // 获取最后 body 的值
            let body = ctx.body;

            // 检测 ctx.body 的类型,并使用对应的方式将值响应给浏览器
            if (Buffer.isBuffer(body) || typeof body === "string") {
                // 处理 Buffer 类型的数据
                res.setHeader("Content-Type", "text/plain;charset=utf8");
                res.end(body);
            } else if (typeof body === "object") {
                // 处理对象类型
                res.setHeader("Content-Type", "application/json;charset=utf8");
                res.end(JSON.stringify(body));
            } else if (body instanceof Stream) {
                // 处理流类型的数据
                body.pipe(res);
            } else {
                res.end("Not Found");
            }
// ***************************** 以下为修改代码 *****************************
        }).catch(err => {
            // 执行 error 事件
            this.emit("error", err);

            // 设置 500 状态码
            ctx.status = 500;

            // 返回状态码对应的信息响应浏览器
            res.end(httpServer.STATUS_CODES[ctx.status]);
        });
// ***************************** 以上为修改代码 *****************************
    }
    listen(...args) {
        // 创建服务
        let server = http.createServer(this.handleRequest.bind(this));

        // 启动服务
        server.listen(...args);
    }
}

module.exports = Koa;

在使用的案例当中,使用 app(即 Koa 创建的实例)监听了一个 error 事件,当中间件执行错误时会触发该监听的回调,这让我们想起了 NodeJS 中一个重要的核心模块 events,这个模块帮我们提供了一个事件机制,通过 on 方法添加监听,通过 emit 触发监听,所以我们引入了 events,并让 Koa 类继承了 events 导入的 EventEmitter 类,此时 Koa 的实例就可以使用 EventEmitter 原型对象上的 on 和 emit 方法。

在 compose 执行后调用的 catch 中,通过实例调用了 emit,并传入了事件类型 error 和错误对象,这样就是实现了中间件的错误监听,只要中间件执行出错,就会执行案例中错误监听的回调。


让引入的 Koa 直接指向 application.js

在上面我们实现了 Koa 大部分常用功能的核心逻辑,但还有一点美中不足,就是我们引入自己的简易版 Koa 时,默认会查找 koa 路径下的 index.js,想要执行我们的 Koa 必须要使用路径找到 application.js,代码如下。

// 现在的引入方式
const Koa = require("./koa/application");
// 希望的引入方式
const Koa = require("./koa");

我们更希望像直接引入指定 koa 文件夹,就可以找到 application.js 文件并执行,这就需要我们在 koa 文件夹创建 package.json 文件,并在动一点小小的 “手脚” 如下。

文件路径:&#126koa/package.js

{
    .
    .
    .
    "main": "./application.js",
    .
    .
    .
}

Koa 原理图

在文章最后一节送给大家一张 Koa 执行的原理图,这张图片是准备写这篇文章时在 Google 上发现的,觉得把 Koa 的整个流程表达的非常清楚,所以这里拿来帮助大家理解 Koa 框架的原理和执行过程。

在这里插入图片描述

之所以没有在文章开篇放上这张图是因为觉得在完全没有了解过 Koa 的原理之前,可能有一部分小伙伴看这张图会懵,会打消学习的积极性,因为本篇的目的就是带着大家从零到有的,一步一步实现简易版 Koa,梳理 Koa 的核心逻辑,如果你已经看到了这里,是不是觉得这张图出现的不早不晚,刚刚好。


总结

最后还是在这里做一个总结,在 Koa 中主要的部分有 listen 创建服务器、封装上下文对象 ctx 并代理属性、use 方法添加中间件、compose 串行执行中间、让 Koa 继承 EventEmitter 实现错误监听,而我个人觉得最重要的就是 compose,它是一个事件串行机制,也是实现 “洋葱模型” 的核心,如今 compose 已经不再只是一个方法名,而是一种编程思想,用于将多个程序串行在一起,或同步,或异步,在 Koa 中自不必多说,因为大家已经见识过了,compose 在 React 中也起着串联中间件的作用,如串联 promise、redux-thunk、logger 等,在 Webpack 源码依赖的核心模块 tapable 中也有所应用,在我们的学习过程中,这样优秀的编程思想是应该重点吸收的。


koa-nodejs:koa与nodejs开发实战源代码需要的可以下载手打不易如果能帮到你我希望你也能帮我求支持求打赏 Koa与Node.js开发实战 基于Node.js平台的新的一代Web开发框架Koa2,构建真实线上实战项目(iKcamp /着) “ ” 立即下载

相关推荐

nodejs-koa-blog:基于Node.js Koa2实战开发的一套完整的博客项目网站

一,这是个什么的项目? 基于Node.js Koa2实战开发的一套完整的博客项目网站,使用Koa2二次开发套件适合多端的RESTful API,同时配套完整的后台管理系统,并前端展示既有基于ejs服务端渲染,也有基于Vue.js前瞻分离的2套前端网站。 博客线上地址是: : 二,项目包含什么功能? 2.1.Node.js Koa2服务端RESTful API 管理员与权限控制接口 文章管理接口 评论/回复功能接口 分类接口 广告接口 2.2。博客前端展示网站 ejs服务端渲染 Vue.js前瞻分离 2.2。后台管理系统 使用Vue.js iviewui设计构建的后台管理系统 2.3。优势 使用精小而强大的Node.js Koa2框架做服务端API接口。 前端既有服务端渲染,也有前细分分离,并且做彻底的优化工作,前端展示网站打开快。 性能优化方面的工作: 服务端使用了缓存机制,减少服务器的请求压力,如Redis缓存,HTTP缓存 使用了CND加速,静态文件存储在七牛服务器上 ejs服务端渲染尽量减少文件的约会,减少对DOM的操作,并封装使用了图片懒加载,事件防抖和节流控制浏览器滚动监

KOA2 内部原理及模拟源码

前言Koa 2.x 版本是当下最流行的 NodeJS 框架,同时社区涌现出一大批围绕 Koa 2.x 的中间件以及基于 Koa 2.x 封装的企业级框架,如 egg.js,然而 Koa 本身的代码却非常精简,精简到所有文件的代码去掉注释后还不足 2000 行,本篇就围绕着这 2000 行不到的代码抽出核心逻辑进行分析,并压缩成一版只有 200 行不到的简易版 Koa。Koa 分析过程在下面的内容中...

weixin_34143774的博客 296

【数据结构】平行四边形数量

平行四边形个数,哈希表,散列

cyy010617的博客 4009

学习 koa 源码的整体架构,浅析koa洋葱模型原理和co原理

技术是没有终点的,也是学不完的,最重要的是活着、不秃。零基础入门的时候看书还是看视频,我觉得成年人,何必做选择题呢,两个都要。喜欢看书就看书,喜欢看视频就看视频。最重要的是在自学的过程中,一定不要眼高手低,要实战,把学到的技术投入到项目当中,解决问题,之后进一步锤炼自己的技术。技术学到手后,就要开始准备面试了,找工作的时候一定要好好准备简历,毕竟简历是找工作的敲门砖,还有就是要多做面试题,复习巩固。有需要面试题资料的朋友点击这里可以领取。f gen.next!/***/

2401_84091468的博客 780

问题:dpkg-deb: error: paste subprocess was killed by signal (Broken pipe)

 1. 问题:Ubuntu安装软件的过程中出现依赖项的问题 “E: Unmet dependencies. Try 'apt-get -f install' with no packages (or specify a solution).” 按照指示执行如下两条命令都没有解决问题。 sudo apt-get -f install sudo apt-get --fix-broken ...

gloria_littlechi的博客 2万+

手把手教你用Wireshark抓包分析Profinet非周期通信(附实战案例)

本文详细解析了PROFINET非周期通信(RTA)的技术原理与实战应用,通过Wireshark抓包分析,揭示从PLC请求到驱动器响应的完整对话过程。涵盖实验环境搭建、关键配置步骤、典型参数访问流程及故障诊断案例,帮助工程师深入理解并优化工业网络通信性能。

Black的专栏 1050

vue实现直播弹幕功能

前端校招精编面试解析大全点击这里即可获取完整版pdf查看实战项目、讲解视频,并且会持续更新!**如果你觉得这些内容对你有帮助,可以扫码获取!!(资料价值较高,非无偿)前端校招精编面试解析大全点击这里即可获取完整版pdf查看。

2301_82242670的博客 945

ADAS工作原理解析:如何通过智能技术提升驾驶安全

ADAS通过集成传感器、摄像头、雷达、SoC计算单元以及高效的数据融合技术,提供了一系列主动和被动安全功能,如自动刹车、车道保持、自适应巡航控制等。这些功能共同作用,不仅减少了交通事故的发生,还大幅提升了驾驶员的驾驶体验和安全性。随着技术的进步,ADAS的功能将越来越智能,最终迈向全自动驾驶的未来🚗💨。

weixin_43199439的博客 1149

从零学习Node.js框架Koa 【二】Koa 核心机制解析:中间件与 Context 的深度理解

本文是Koa框架系列文章的第二篇,主要解析Koa的核心机制——中间件与Context对象。文章首先通过日志中间件示例介绍了中间件的基本概念和使用方式,重点讲解了Koa独特的"洋葱模型"执行流程,即请求从外到内再由内到外的处理过程。接着详细介绍了Context对象的作用和常用API,包括ctx.state数据传递、ctx.app全局事件发布订阅以及ctx.throw错误处理方法。文章通过丰富代码示例帮助读者理解Koa的核心机制,为后续路由、数据库连接等高级主题打下基础。

前端(全栈)、AI Agent开发技术分享 2449

【Dify(v1.x) 核心源码深入解析】File 模块

文件模型(File Model)定义了文件的核心属性,如文件类型、传输方法、相关标识符等,是整个文件处理流程的数据基础。文件解析器(UploadFileParser)负责解析上传的文件,根据配置决定以何种方式(URL 或 Base64)提供图像数据。工具文件解析器(ToolFileParser)专注于工具文件的管理,提供工具文件管理器的获取接口。签名验证与生成(helpers.py)包含文件 URL 签名的生成与验证逻辑,确保文件访问的安全性。文件管理器(file_manager.py)

668

微信小程序 - 最新实现人脸识别认证功能,使用官方微信小程序人脸识别身份验证接口及常见的人脸核验、用户实名认证、身份证件与人脸是否匹配、是否活体、领取业务人脸认证等各种示例(验证活体人脸是否匹配源码)

微信小程序中实现人脸识别认证,微信小程序人脸识别认证-微信开放接口,如何为微信小程序添加人脸识别和身份验证功能,如何为微信小程序集成人脸识别与身份验证,微信人脸识别实名认证集成指南,识别失败,人脸识别接口怎么调用,前后端完整示例代码demo,微信小程序基于小程序实现人脸识别对比,人脸识别报错,微信人脸核身接口能力,微信小程序实名认证 人脸核验 人脸识别,微信小程序实名认证与人脸核身接口全解析,微信小程序-人脸识别+输出人脸匹配信息,微信小程序实名认证人脸核验人脸识别,微信小程序实名认证:wx.startFa

🏆 前端领域优质创作者 3275

nodejs之web服务器框架koa

Koa使用(Koa的安装,Koa的应用程序,Koa的属性,Koa错误处理,Koa的上下文Context) koa-router路由(koa-router的使用,koa-router的参数解析,koa上传文件,嵌套路由)

chengcheng9876的博客 1707

MIPI CPHY学习笔记01:TX物理层一致性测试项解析(LP信令模式)

在手机、平板、智能手表等移动设备中,MIPI C-PHY是负责图像、数据传输的核心接口,它就像设备内部的“高速数据线”,而低功耗(LP)信令模式,是这条“数据线”的“省电模式”,专门用于设备待机、低负载等场景,既要保证信号稳定,又要最大限度降低功耗。物理层一致性测试,就是给这个“省电模式”做的“全面体检”,确保不同厂商的设备能兼容互通。今天,我们就聚焦MIPI C-PHY TX(发射端)的Group 1 LP信令测试,用通俗的语言拆解8个核心测试项,看懂“省电信号”的合格标准。

一只豌豆象的博客 301

Nodejs Stream pipe 的使用与实现原理分析

今日文章由 “Node.js技术栈@五月君” 授权分享,正文从下面开始~通过流我们可以将一大块数据拆分为一小部分一点一点的流动起来,而无需一次性全部读入,在 Linux 下我们可以通过 ...

程序员成长指北 2223

蓝牙模块HM-10手册

蓝牙模块HM-10手册,该手册对蓝牙模块HM-10进行了详细的介绍和使用方法以及一些AT指令。

SAP B1中文培訓教材_SDK

公司内部培训资料,对开发工具 ,DIAPI, UIAPI,Add-ON有详细解说

KOA2 compose 串联中间件实现(洋葱模型)

Koa 是当下主流 NodeJS 框架,以轻量见长,而它中间件机制与相对传统的 Express 支持了异步,所以编码时经常使用 async/await ,提高了可读性,使代码变得更优雅,上一篇文章 NodeJS 进阶 —— Koa 源码分析 ,也对 “洋葱模型” 和实现它的 compose 进行分析,由于个人觉得 compose 的编程思想比较重要,应用广泛,所以本篇借着 “洋葱模型” 的话题,打...

li420520的博客 1194

pscad IEEE标准系统搭建说明

pscad IEEE标准系统搭建说明,包含典型的IEEE 9节点、14节点、30节点、39节点和118节点系统。内含标准测试系统的原始数据和潮流结果。

深入解析Koa之核心原理

这篇文章主要介绍了玩转Koa之核心原理分析,本文从封装创建应用程序函数、扩展res和req、中间件实现原理、异常处理的等这几个方面来介绍,写的十分的全面细致,具有一定的参考价值,对此有需要的朋友可以参考学习下。如有不足之处,欢迎批评指正。 Koa作为下一代Web开发框架,不仅让我们体验到了async/await语法带来同步方式书写异步代码的酸爽,而且本身简洁的特点,更加利于开发者结合业务本身...

weixin_30832983的博客 83

大中型超市收银系统(可直接运行)

光步前台收银系统系统功能:商品销售、商品退货、挂单、收银员权限、会员销售、单品取消、整单取消、商品查询、脱机销售、脱机数据上传,基本资料下载,开钱箱、小票打印、收款结算,支持多币种付款方式(现金、储值卡、礼券、微信等),支持多种促销。运行环境:.net 4.0登陆信息用户名:1001 密码:123456内置商品信息,商品编码可输入00001000 - 00010000,测试效果相关功能键F5 结算 F9挂单 F11商品查询 F8退货 Insert单品取消 Del整单取消 F12开钱箱F2 会员 Ctrl+Z退出系统 Home 锁屏 相关源码待公开

KOA2 compose 串联中间件实现(洋葱模型)

前言Koa 是当下主流 NodeJS 框架,以轻量见长,而它中间件机制与相对传统的 Express 支持了异步,所以编码时经常使用 async/await,提高了可读性,使代码变得更优雅,上一篇文章 NodeJS 进阶 —— Koa 源码分析,也对 “洋葱模型” 和实现它的 compose 进行分析,由于个人觉得 compose 的编程思想比较重要,应用广泛,所以本篇借着 “洋葱模型” 的话题,打...

weixin_34018202的博客 426

我们来一步一步分析koa2的源码

koa2 源码分析 最近想做一个关于NodeJS 服务端相关的总结,思前想后觉得可以从源码分析作为切入点。于是首先便选择了koa2. 注:由于书写习惯原因 ,下文中所有出现koa的字眼 皆指的是koa2.x版本。如果是1.x版本则用koa1.x标明。 本文章写在我的github仓库如果喜欢 欢迎关注 一起学习

weixin_34294649的博客 204

Koa2 学习笔记(第一天)

前一段开始接触了Koa2,写这个笔记是希望能够把知识点记录下来,也能够记录自己的学习历程。Koa2是什么?Koa是下一代的Nodo.js web框架。 Koa2是由Express原班人马打造的轻量级框架。它去除了Express里面的很多中间件,另外把原来的callback回调函数的写法换成了async await的写法。如何安装Koa2?因为Node7.6才开始支持async await,所以使用

假面猫的博客 3567

深入 Nodejs 源码探究 CPU 信息的获取与利用率计算

在 Linux 下我们通过 top 或者 htop 命令可以看到当前的 CPU 资源利用率,另外在一些监控工具中你可能也遇见过,那么它是如何计算的呢?在 Nodejs 中我们该如何实现...

全栈者 的博客 1967

Node.js Stream 模块 pipe 方法使用与实现原理分析

通过流我们可以将一大块数据拆分为一小部分一点一点的流动起来,而无需一次性全部读入,在 Linux 下我们可以通过 | 符号实现,类似的在 Nodejs 的 Stream 模块中同样也为我们...

weixin_44829437的博客 1859

js实现线路流动_Nodejs Stream pipe 的使用与实现原理分析

通过流我们可以将一大块数据拆分为一小部分一点一点的流动起来,而无需一次性全部读入,在 Linux 下我们可以通过 | 符号实现,类似的在 Nodejs 的 Stream 模块中同样也为我们提供了 pipe() 方法来实现。1. Nodejs Stream pipe 基本示例选择 Koa 来实现这个简单的 Demo,因为之前有人在 “Nodejs技术栈” 交流群问过一个问题,怎么在 Koa...

weixin_28902131的博客 327
上一篇: Express 中间件 body-parser 原理分析
下一篇: Koa2 洋葱模型 —— compose 串联中间件的四种实现
Panda Shen
博客等级 码龄10年 18粉丝 50原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值