Koa实现原理分析

February 05, 2021 by Sylvenas

koa 是由 Express 原班人马打造的,致力于成为一个更小、更富有表现力、更健壮的 Web 框架。使用 koa 编写 web 应用,通过组合不同的 generator,可以免除重复繁琐的回调函数嵌套,并极大地提升错误处理的效率。koa 不在内核方法中绑定任何中间件,它仅仅提供了一个轻量优雅的函数库,使得编写 Web 应用变得得心应手。

特点

  • 轻量、无捆绑
  • 中间件架构
  • 通过不同的 generator 以及 await/async 替代了回调
  • 增强的错误处理
  • 简单易用的 api

简单使用

Koa 对 node 服务进行了封装,并提供了简单易用的 API。假如我们想在请求 3000 端口时返回 hello, node! 的数据,使用原生 node 实现代码如下:

const http = require("http");

const server = http.createServer((req, res) => {
  res.end("hello, node!");
});

server.listen(3000, () => {
  console.log("server is running on 3000...");
});

使用 Koa 实现如下:

const Koa = require("koa");
const app = new Koa();

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

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

通过对比可以发现,koa 实现方式通过 new Koa() 创建了一个 koa 实例,实例上有 use 方法,use 的回调函数中接收 ctxnext 两个参数。就这简单的几点,基本就组成了 koa 的全部内容。

中间件和洋葱圈模型

中间件是 Koa 的核心,koa 通过 use() 去调用一系列的中间件,并通过 next() 将上下文交给下一个中间件去进行处理。当没有下一个 next() 可执行之后,再倒序执行每个 use() 回调函数中 next 之后的逻辑。 这就是 koa 的洋葱圈模型:

洋葱圈模型

如下一段代码,在请求 localhost:3000 端口后 node 控制台打印顺序为: 1、3、5、6、4、2

next 之前(包含 next)为队列模式(先进先调用),next 之后为栈模式(先进后调用)

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);
  ctx.body = "hello, node!";
  console.log(6);
});

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

Koa 源码结构

Koa 的核心文件一共有四个:application.jscontext.jsrequest.jsresponse.js。所有的代码加起来不到 2000 行,十分的轻便,而且大量代码集中在 request.jsresponse.js 对于请求头和响应头的处理,核心代码只有几百行。

application

application.js 是 koa 的入口文件,里面导出了 koa 的构造函数,构造函数中包含了 koa 的主要功能实现。

listen

application 构造函数首先通过 Node.js 中 http 模块,实现了 listen 功能:

listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
}

use

use 方法将接收到的中间件函数,全部添加到了 this.middleware 中,以便后面按顺序调用各个中间件。同时为了兼容 koa1 中的 use 使用,对于 generator 类型的中间件函数,会通过 koa-convert 库将其进行转换,以兼容 koa2 中的 koa 的递归调用。

use(fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    if (isGeneratorFunction(fn)) { // 兼容 koa1 的 use 用法
      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');
      fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    this.middleware.push(fn);
    return this;
}

callback

上面 listen 函数在服务启动时,createServer 函数会返回 callback 函数的执行结果。 在服务启动时,callback 函数做了中间件的合并,监听框架层的错误请求等功能。 然后返回了 handleRequest 的方法,它接收 reqres 两个参数,每次服务端收到请求时,会根据 Node.js http 原生的 req 和 res,创建一个新的 koa 的上下文 ctx。

 callback() {
    const fn = compose(this.middleware); // 合并中间件

    if (!this.listenerCount('error')) this.on('error', this.onerror); // 捕获框架层的错误

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res); // 创建上下文
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

createContext

再来看 createContext 函数,一大串的赋值骚操作,我们细细解读一下:

  • 1.先通过 Object.create(),创建了新的从 context.jsrequest.jsresponse.js 引入的对象,防止引入的原始对象被污染。

  • 2.通过 context.request = Object.create(this.request)context.response = Object.create(this.response) 将 request 和 response 对象挂载到了 context 对象上。这部分对应了 context.js 中 delegate 的委托部分(有关 delegate 可以见后面 koa 核心库部分的解读),能让 ctx 直接通过 ctx.xxx 去访问到 ctx.request.xxxctx.response.xxx

  • 3.通过一系列的赋值操作,将原始的 http 请求的 res 和 req,以及 Koa 实例 app 等等分别挂载到了 context、request 和 response 对象中,以便于在 context.js、request.js 和 response.js 中针对原始的请求、相应参数等做一些系列的处理访问,便于用户使用。

createContext(req, res) {
    // Object.create()创建
    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

callback 中执行完 createContext 后,会将创建好的 ctx 以及合并中间件后生成的顺序执行函数传给 handleRequest 并执行该函数。 handleRequest 中会通过 onFinished 这个方法监听 res,当 res 完成、关闭或者出错时,便会执行 onerror 回调。 之后返回中间件执行的结果,当中间件全部执行完之后,执行 respond 进行数据返回操作。

handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

context

cookies

context.js 中通过 get 和 set 方法做了 cookie 的设置和读取操作。

delegate

context.js 中有大量的 delegate 操作,是通过 delegate,可以让 ctx 能够直接访问其上面 response 和 request 中的属性和方法,即可以通过 ctx.xxx 获取到 ctx.request.xxxctx.response.xxx

delegate 是通过 delegates 这个库实现的,通过 proto.defineGetterproto.defineSetter 去代理对象下面节点的属性和方法等。(proto.defineGetter 和 proto.defineSetter 现已被 mdn 废弃,改用 Object.defineProperty())

delegate(proto, "response")
  .method("attachment")
  .method("redirect")
  .access("lastModified")
  .access("etag")
  .getter("headerSent")
  .getter("writable");
// ...

delegate(proto, "request")
  .method("acceptsLanguages")
  .getter("ip");
// ...

context.js 中导出了一个 context 对象,主要用来在中间件以及其它各部件之间传递信息的,同时 context 对象上挂载了 request 和 response 两大对象。 另外其还做了 cookie 的处理以及使用 delegates 库对 request 和 response 对象上面的事件和方法进行了委托,便于用户使用。

request

request.js 导出了 request 对象,通过 get() 和 set() 方法对请求头的参数如 header、url、href、method、path、query……做了处理,挂载到了 request 对象上,方便用户获取和设置。

response

同 request.js ,通过 get() 和 set()对响应参数做了处理。

koa-compose

在 application.js 中,通过 compose 将中间件进行了合并,这也是 koa 的一个核心实现。

先来看 koa-compose 的源码,实现非常简单,只有几十行:

function compose(middleware) {
  // 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!");
  }
  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;
      if (!fn) return Promise.resolve();
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };
}

compose 接收一个中间件函数的数组,返回了一个闭包函数,闭包中维护了一个 index 去记录当前调用的中间件。

里面创建了一个 dispatch 函数,dispatch(i) 会通过 Promise.resolve() 返回 middleware 中的第 i 项函数执行结果,即第 i + 1app.use() 传入的函数。 app.use() 回调的第二个参数是 next,所以当 app.use() 中的代码执行到 next() 时,便会执行 dispatch.bind(null, i + 1)),即执行下一个 app.use() 的回调。

依次类推,便将一个个 app.use() 的回调给串联了起来,直至没有下一个 next,边会按顺序返回执行每个 app.use()next() 后面的逻辑。最终通过 Promise.resolve() 返回第一个 app.use() 的执行结果。

实现一个简单的 Koa

封装 node 的 http 模块

按照本文开篇的最简单示例去实现,新建 application.js,内部创建一个 MyKoa 类,基于 node 的 http 模块,实现 listen 函数:

// application.js
const http = require("http");

class MyKoa {
  listen(...args) {
    const server = http.createServer((req, res) => {
      res.end("mykoa");
    });
    server.listen(...args);
  }
}

module.exports = MyKoa;

实现 use 方法和简易 createContext

然后要实现 app.use() 方法,我们看到 app.use() 中内部有 ctx.body,所以我们还需要实现一个简单的 ctx 对象。

1.创建一个 context.js,内部导出 ctx 对象,分别通过 get 和 set,实现可以获取和设置 ctx.body 的值:

// context.js
module.exports = {
  get body() {
    return this._body;
  },

  set body(value) {
    this._body = value;
  }
};

2.在 application.js 的 MyKoa 类中添加 use 和 createContext 方法,同时 res.end 返回 ctx.body:

const http = require("http");
const _context = require("./context");

class MyKoa {
  listen(...args) {
    const server = http.createServer((req, res) => {
      const ctx = this.createContext(req, res);
      this.callback();
      res.end(ctx.body);
    });
    server.listen(...args);
  }

  use(callback) {
    this.callback = callback;
  }

  createContext(req, res) {
    const ctx = Object.assign(_context);
    return ctx;
  }
}

module.exports = MyKoa;

完善 createContext

我们要通过 ctx 去访问请求头以及设置响应头等相关信息,例如 ctx.query,ctx.message 等等,就要创建 response.js 和 request.js 对请求头和响应头做处理,将 request 和 response 对象挂载到 ctx 对象上,同时实现一个 delegate 函数让 ctx 能够访问 request 和 response 上面的属性和方法。

1.实现简单的 request 和 response,request 中通过 get 方法,能够解析 req.url 中的参数,将其转换为一个对象返回。response 中,通过 get 和 set message,能够获取和设置 res.statusMessage 的值:

// request.js
module.exports = {
  get query() {
    const arr = this.req.url.split("?");
    if (arr[1]) {
      const obj = {};
      arr[1].split("&").forEach(str => {
        const param = str.split("=");
        obj[param[0]] = param[1];
      });
      return obj;
    }
    return {};
  }
};
// response.js
module.exports = {
  get message() {
    return this.res.statusMessage || "";
  },

  set message(msg) {
    this.res.statusMessage = msg;
  }
};

2.新建一个 utils.js,导出 delegate 方法,delegate 内部通过 Object.defineProperty ,让传入的对象 obj 能够在属性 property 改变时实时监听,例如 delegate(ctx, 'request') 当 request 对象值改变时,ctx 对 request 代理也能获取最新的值。 然后实现简单的 getter 和 setter,通过一个 listen 函数,当使用 getter 或者 setter 时,将对应的键添加到 setters 和 getters 中,让 obj 访问对应键时代理到 proterty 对应的键值:

// utils.js
module.exports.delegate = function Delegate(obj, property) {
  let setters = [];
  let getters = [];
  let listens = [];

  function listen(key) {
    Object.defineProperty(obj, key, {
      get() {
        return getters.includes(key) ? obj[property][key] : obj[key]; // 如果通过 getter 代理了,则返回对应 obj[property][key] 的值,否则返回 obj[key] 的值
      },
      set(val) {
        if (setters.includes(key)) {
          obj[property][key] = val; 如果通过 setter 代理了,则设置对应 obj[property][key] 的值,否则设置 obj[key] 的值
        } else {
          obj[key] = val;
        }
      },
    });
  }

  this.getter = function (key) {
    getters.push(key);
    if (!listens.includes(key)) { // 防止重复调用listen
      listen(key);
      listens.push(key);
    }
    return this;
  };

  this.setter = function (key) {
    setters.push(key);
    if (!listens.includes(key)) { // 防止重复调用listenf
      listen(key);
      listens.push(key);
    }
    return this;
  };
  return this;
};

3.在 context 使用 delegate 对 request 和 response 进行代理:

// context.js
const { delegate } = require("./utils");
const context = (module.exports = {
  get body() {
    return this._body;
  },

  set body(value) {
    this._body = value;
  }
});
delegate(context, "request").getter("query");
delegate(context, "response")
  .getter("message")
  .setter("message");

4.完善 createContext 函数:

// application.js
const http = require("http");
const _context = require("./context");
const _request = require("./request");
const _response = require("./response");

class MyKoa {
  // ...
  createContext(req, res) {
    const ctx = Object.assign(_context);
    const request = Object.assign(_request);
    const response = Object.assign(_response);
    ctx.request = request;
    ctx.response = response;
    ctx.req = request.req = req;
    ctx.res = response.res = res;
    return ctx;
  }
}

module.exports = MyKoa;

实现中间件和洋葱模型

到现在为止,只剩下实现 app.use() 中间件的功能了。

1.按照前面 koa-compose 分析的思路,在 utils.js 中,实现 compose:

// utils.js
module.exports.compose = middleware => {
  return (ctx, next) => {
    let index = -1;
    return dispatch(0);
    function dispatch(i) {
      if (i <= index) return Promise.reject(new Error("error"));
      index = i;
      const cb = middleware[i] || next;
      if (!cb) return Promise.resolve();
      try {
        return Promise.resolve(
          cb(ctx, function next() {
            return dispatch(i + 1);
          })
        );
      } catch (error) {
        return Promise.reject(error);
      }
    }
  };
};

2.在 app.js 中,初始化 this.middleware 的数组,use() 函数中将 callback 添加进数组

// ...
class MyKoa {
  constructor() {
    this.middleware = [];
  }
  // ...

  use(callback) {
    this.middleware.push(callback);
  }
  // ...
}

module.exports = MyKoa;

3.listen 方法 createServer 中,遇到请求时将中间件合并,中间件执行完毕后返回 res 结果:

// ...
const { compose } = require("./utils");

class MyKoa {
  // ...
  listen(...args) {
    const server = http.createServer((req, res) => {
      const ctx = this.createContext(req, res);
      //
      const fn = compose(this.middleware);
      fn(ctx)
        .then(() => {
          // 全部中间件执行完毕后,返回相应信息
          res.end(ctx.body);
        })
        .catch(err => {
          throw err;
        });
    });
    server.listen(...args);
  }
  // ...
}
module.exports = MyKoa;

测试

到这里就大功告成了,引入我们的 Mykoa 在如下服务中测试一下:

const Koa = require("../my-koa/application");
const app = new Koa();

app.use((ctx, next) => {
  ctx.message = "ok";
  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.use((ctx, next) => {
  console.log(ctx.message);
  console.log(ctx.query);
  ctx.body = "hello, myKoa";
});

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

访问 http://localhost:3000/api?name=zlx 接口,返回数据为 hello, myKoa 。 node 服务器控制台打印内容如下:

1;
3;
5;
ok;
{name: "zlx";}
6;
4;
2;

说明我们实现的没有任何问题!