逐行分析Koa源码

Koa 简介

官网介绍:Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。

其中有几个关键字:更小、更富有表现力、更健壮、错误处理、中间件、快速。

用过 Express,再来用 Koa,肯定会有以上几点感受。

优秀的作品,总是忍不住想通过源码看看它是怎么实现的。

从 require('koa') 开始看 Koa 源码

const Koa = require('koa');

在 Node.js 里导入是通过 require 函数调用进行的。 Node.js 会根据 require 的是相对路径还是非相对路径做出不同的行为。

require('koa') 是非相对路径,Node.js 会在一个特殊的文件夹 node_modules 里查找你的模块。node_modules 可能与当前文件在同一级目录下,或者在上层目录里。 Node.js 会向上级目录遍历,查找每个 node_modules 直到它找到要加载的模块。写 require 的时候,vscode 会有提示。

在项目的 node_modules 目录下找到 koa 目录,首先看 package.json,找到 main 字段:

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

main 字段对应的是 Koa 的入口文件。lib 目录下只有4个文件,这也是 Koa 的所有源码。相比 Express 『更小』。

找到入口文件就好办了。

new Koa() 发生了什么

实例化一个 Koa 对象来使用 Koa 对外提供的各种 API。

const app = new Koa();

在 application.js 中找到 Application 类,看其 constructor 构造函数,了解其初始化过程。

// ... 省略一些 require
const Emitter = require('events');

// Application 继承了 events,也就有了事件发布订阅的功能
// Koa 错误处理功能就是以此为基础
module.exports = class Application extends Emitter {
  constructor(options) {
    super();
    options = options || {};
    this.proxy = options.proxy || false;
    this.subdomainOffset = options.subdomainOffset || 2;
    this.env = options.env || process.env.NODE_ENV || 'development';
    if (options.keys) this.keys = options.keys;

    // 很重要:初始化 middleware 中间件数组
    this.middleware = [];

    // 很重要:初始化 context、request、response 三个属性,它们与 middleware 共同组成了 Koa 最核心的部分
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);

    if (util.inspect.custom) {
      this[util.inspect.custom] = this.inspect;
    }
  }
}

Koa 实例化完成之后,我们会使用以下方法正式创建一个应用,让它具有处理请求和响应的能力。

app.use(async function (ctx, next) {
  // ...
  await next();
  // ...
});

// ... 或许还需要使用很多的 use 方法

app.listen(3000, '127.0.0.1', error => {
  console.log('app started at port 3000...');
});

use 方法:

module.exports = class Application extends Emitter {
  use(fn) {
    // 规定 use 方法的参数必须是一个 function
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');

    // Koa1.x 是用 generator 函数来操作异步流程的,Koa2在这里做了兼容,并将在 V3 版本中彻底弃用。
    if (isGeneratorFunction(fn)) {
      deprecate('Support for generators will be removed in v3. ' +
                'See the documentation for examples of how to convert old middleware ' +
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      // 将 generator 形式的函数转换成 async 形式。
      // https://www.npmjs.com/package/koa-convert
      fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    // 将回调函数添加到中间件队列中
    this.middleware.push(fn);
    return this;
  }
}

listen 方法:

module.exports = class Application extends Emitter {
  listen(...args) {
    debug('listen');
    // 下面两段代码很熟悉了,使用 Node.js 创建一个 HTTP 服务
    // this.callback 方法就是每次接收到 HTTP 请求之后的具体操作
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
}

callback 方法:

module.exports = class Application extends Emitter {
  callback() {
    // 使用 compose 方法预处理 middleware:https://www.npmjs.com/package/koa-compose
    // 首先判断 compose 方法的入参是不是数组,如果不是,则 throw new TypeError('Middleware stack must be an array!')
    // 接着使用 for of 循环,判断数组的每一项是不是 function,如果不是,则 throw new TypeError('Middleware must be composed of functions!')
    // 最后返回一个 function,这个 function 是 Koa 中间件执行流程的核心,后面会记录。
    const fn = compose(this.middleware);

    // 因为 Application 类继承了 events,所以也有 listenerCount 方法
    // 用来判断开发者是否有监听 error,如果没有的话,Koa 会内置一个,并执行自定义的 onerror 方法
    // 当错误发生时,console.log 一些信息
    if (!this.listenerCount('error')) this.on('error', this.onerror);

    // http.createServer 的入参是一个方法,有两个入参,即 req、res
    const handleRequest = (req, res) => {
      // 使用 createContext 将 req 和 res 包装成一个 ctx 上下文对象
      const ctx = this.createContext(req, res);
      // 正式处理接收到的 HTTP 请求
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }
}

先来看下 createContext 做了哪些事情?

module.exports = class Application extends Emitter {
  createContext(req, res) {
    // 代码很清晰,就是在 context 对象上挂载了一些属性,然后返回
    // context 的初始内容,可参考 lib/context.js 文件
    // 做一个 demo,收到请求后,把 context 打印出来,结合代码看,就都清楚了
    const context = Object.create(this.context);
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
    context.app = request.app = response.app = this;
    context.req = request.req = response.req = req;
    context.res = request.res = response.res = res;
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    context.originalUrl = request.originalUrl = req.url;
    context.state = {};
    return context;
  }
}

再来看 handleRequest 方法:

module.exports = class Application extends Emitter {
  handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    // onFinish 方法是通过引入 on-finished 包得到的
    // 主要作用是:当 HTTP 请求 closes、finishes 或 errors 时执行回调
    // https://www.npmjs.com/package/on-finished
    // 执行的 onerror 方法,可参考 context.js 文件中的 onerror 方法,主要通过 emit 触发 error 事件,最后 res.end(msg) 返回错误信息
    onFinished(res, onerror);
    // fnMiddleware 就是上面通过 compose 组合之后的一系列 middleware,下面重点叙述
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }
}

compose 方法:

// https://github.com/koajs/compose/blob/master/index.js
function compose (middleware) {
  // 上面笔记记录过的判断一个中间件是否符合规范的逻辑
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  // compose 最后返回的方法,即 handleRequest 中执行的 fnMiddleware 方法。
  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      // 每个中间件是一个 async 函数,被上一个中间件的 next 方法调用(首个除外)
      if (!fn) return Promise.resolve()
      try {
        // 执行 next 方法,起始就是再次执行 dispatch,只是传入的 index + 1,表示执行下一个中间件
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

Koa 使用 compose 组合之后的一系列中间件来处理 HTTP 的 request 和 response,而 compose 的实现原理很像是一个『洋葱模型』:


koa.png

具体过程是:

  • 一个请求到一旦到后端,就开始接触洋葱的最外层。

  • 遇到一个 next(),就进入下一层。不过值得提醒的是,异步函数的 next() 与同步函数的 next(),不是在同一个空间的,我们可以假想一个“异步空间栈”,后入先出。

  • 什么时候到洋葱中心?就是遇到的第一个没有next的中间件,或者遇到一个中间件报错,就会把这个中间件当成中心,因为遇到错误了,不会再继续往里面走。这个时候,就开始向洋葱的外层开始走了。如果第一个中间件就没有 next,直接返回的。那么就不存在洋葱模型。

  • 一层一层外面走的时候,就先走位所有的同步中间件,再依次走“异步空间栈”的中间件。

有没有一种『递归』的感觉。

之前模拟实现了上述 compose 方法,可参考:https://github.com/zymfe/test-code/blob/master/test93.js

整体流程就是这样,Koa 的核心就是提供了一套简单的中间件(一些自定义方法)使用方法,可以拦截、处理 HTTP 请求和响应,方便我们处理业务。

request.js 和 response.js 没有重点说,它们是在 createContext 方法被添加了一些新的属性,原有属性和方法可以参考对应文件中的代码,就是一些设置、读取请求头、请求体的方法和属性等。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容

  • 一、基本用法 1.1 架设 HTTP 服务 // demos/01.jsconst Koa = require('...
    majun00阅读 1,345评论 0 5
  • 原文链接:http://www.jianshu.com/p/6b816c609669 前传 出于兴趣最近开始研究k...
    悬笔e绝阅读 7,197评论 1 11
  • koa 的源码文件很少,lib 文件夹下只有4个文件 application、context、request、re...
    梦想成真213阅读 282评论 0 0
  • 一、背景 Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 ...
    bayi_lzp阅读 10,471评论 6 26
  • 早晨7点半,被楼下除草剂的声音吵醒。简单而健康的早餐之后,开启了今天的美乐家之旅。现在是下午5点半,除了午饭时间,...
    稀稀公主阅读 250评论 0 0