KoaJs原理学习

983 阅读5分钟

~~文章首发于我的个人技术博客

上一篇文章 ExpressJs中间件原理学习 学习了Node.js Web 应用程序框架ExpressJs,这篇文章会学习Node.js 的另一个web框架- Koa。Koa是一个非常精简的node框架,由 Express 幕后的原班人马打造,旨在成为 Web 应用程序和 API开发领域的更小、更具表现力和更强大的基石。主要体现在:

  1. Koa 内部使用 ES6 编写,利用 async 函数,避免了回调函数的缺陷,也使得Koa 处理异常更加简单。
  2. 基于async/await(generator)的中间件洋葱模型机制,并且不提供其他中间件。
  3. 提供一个高内聚的ctx对象,该对象封装了node原生req和res对象。

Koa源码结构

  • lib
    • application.js ——> ctx.app
    • context.js ——> ctx
    • request.js. ——> ctx.request
    • response.js. ——> ctx.response

Koa源码结构极其的简单。application.js就是入口文件,其余三个文件可以看作就是对node的原生req和res对象的封装。下面我们先来看看这四个文件的代码实现。

application.js

核心源码解读,注意注释

// 模块依赖,列出了核心的几个模块

// 封装了node原生res对象
const response = require("./response");
// 用于异步中间件
const compose = require("koa-compose");
// ctx对象的基础
const context = require("./context");
// 封装了node原生req对象
const request = require("./request");

// Application继承了node的Emitter模块,可以处理异步事件。app.on("XXX")
module.exports = class Application extends Emitter {
  constructor(options) {
    super();
    options = options || {};
    this.proxy = options.proxy || false;
    this.subdomainOffset = options.subdomainOffset || 2;
    this.proxyIpHeader = options.proxyIpHeader || "X-Forwarded-For";
    this.maxIpsCount = options.maxIpsCount || 0;
    this.env = options.env || process.env.NODE_ENV || "development";
    if (options.keys) this.keys = options.keys;
    // 用于放置收集的中间件
    this.middleware = [];
    // Object.create,防止对象引用的污染,实现类似原型继承
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }

  // 创建server,封装了http.createServer
  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }

  // 使用中间件
  use(fn) {
    if (typeof fn !== "function")
      throw new TypeError("middleware must be a function!");

    // 收集中间件
    this.middleware.push(fn);
    return this;
  }

  /**
   * 返回(req, res) => void函数,作为参数传递给上面的listen函数中的http.createServer函数
   */
  callback() {
    // 组合中间件
    const fn = compose(this.middleware);

    const handleRequest = (req, res) => {
      // 重点:每一个请求都有唯一的ctx对象,这个对象就是封装了node的res和req对象
      const ctx = this.createContext(req, res);
      // 处理请求
      return this.handleRequest(ctx, fn);
    };
    return handleRequest;
  }

  // 处理请求
  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);
  }

  // 创建ctx对象
  createContext(req, res) {
    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;
  }

  // 错误处理
  onerror(err) {
    const isNativeError =
      Object.prototype.toString.call(err) === "[object Error]" ||
      err instanceof Error;
    if (!isNativeError)
      throw new TypeError(util.format("non-error thrown: %j", err));

    if (404 === err.status || err.expose) return;
    if (this.silent) return;

    const msg = err.stack || err.toString();
    console.error(`\n${msg.replace(/^/gm, "  ")}\n`);
  }
};

application.js 处理了如下的核心逻辑:

  1. 实现中间件的逻辑,包括中间件的错误处理。
  2. 为每个请求封装出一个十分强大的ctx对象。

context.js

context.js处理了两个核心逻辑:

  1. 创建ctx对象的原型(基础功能),包括代理了application.js 中的错误处理onerror
  2. 让ctx对象代理request和response的属性和方法
// 模块依赖
const util = require("util");
const createError = require("http-errors");
const httpAssert = require("http-assert");
const delegate = require("delegates");
const statuses = require("statuses");
const Cookies = require("cookies");

/**
 * ctx对象的原型(基础功能)
 */
const proto = (module.exports = {
  // ctx.throw
  throw(...args) {
    throw createError(...args);
  },

  // ctx.onerror。就是 this.app.emit("error")
  /**
   * this是什么?为啥能获取app?
   * 其实在application.js的createContext中可以看出:context.app = request.app = response.app = this;
   */
  onerror(err) {
    // ...
    // delegate
    this.app.emit("error", err, this);
    // ...
  },
});

// 让ctx对象代理request和response的属性和方法
delegate(proto, "response")
  .method("attachment")
  .method("redirect")
  .method("remove")
  // ...

delegate(proto, "request")
  .method("acceptsLanguages")
  .method("acceptsEncodings")
  .method("acceptsCharsets")
  .method("accepts")
  .method("get")
  // ...

request.js和response.js

request.js和response.js两个文件很类似。就是分别封装了node原生的req对象作为request对象、res对象作为response对象,并提供了一些便捷的属性和方法。

以request.js为例:

//...模块依赖
// 提供了大量的Getter和Setter,大部分都是对this.req的代理
/**
 *  this.req究竟是什么?
 *  其实在application.js的createContext中可以看出:
 *  context.req = request.req = response.req = req(node原生的req对象);
 */
module.exports = {
  // ...
  get header() {
    return this.req.headers;
  },
  set header(val) {
    this.req.headers = val;
  },
  // ...
};

探究Koa中间件模型

在application.js中我们看到了Koa对于中间件的实现,主要用到了koa-compose这个来组合中间件,并且实现了洋葱圈模式的调用。

来一段Koa中间件的demo:

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

// first中间件
const first = async (ctx, next) => {
  console.log("first start");
  await next();
  console.log("first end");
}
// second中间件
const second = async (ctx, next) => {
  console.log("second start");
  await next();
  console.log("second end");
}

app.use(first);
app.use(second);
app.listen(3000);

// first中间件先执行,但是是最后执行完的。
// console.log("first start");
// console.log("second start");
// console.log("second end");
// console.log("first end");
  1. 如何理解Koa的洋葱圈模型

koa-middleware-stack.jpg

  • 多个中间件会形成一个栈结构(middle stack),以”先进后出”的顺序执行。
  • 在最外层的中间件首先执行。调用next函数,把执行权交给下一个中间件。以此递进,最外层的中间件收回执行权之后,执行next函数后面的代码。
  1. koa-compose

koa-compose是实现Koa的洋葱圈模型的关键。

module.exports = compose;

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!");
  }
  // 组合好后的中间件们返回一个接受context对象的函数,对于Koa可以忽略next参数
  return function (context, next) {
    // 每一个中间件会对应一个index,闭包变量
    // 用于防止在一个中间件中重复调用 next() 函数
    let index = -1;
    // dispatch(0) 启动中间件的链式调用 -> 中间件接受的next参数就是dispatch,并且还绑定了对应的index
    return dispatch(0);
    function dispatch(i) {
      // 有index对应不上
      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 {
        // dispatch.bind(null, i + 1) -> next -> 下一个中间件的执行权
        // Promise.resolve强行Promise -> await next() / next().then()
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        // 处理中间件链执行的错误
        return Promise.reject(err);
      }
    }
  };
}

探究ctx.body

Koa设置响应可以用ctx.body,并且它可以响应多种类型的数据。

在response.js中实现了ctx.body的读取和设置:

const response = {
  _body: "",
  // 获取ctx.body
  get body() {
    return this._body;
  },
  // 设置ctx.body
  set body(val) {
    const original = this._body;
    this._body = val;
    // 处理数据类型,设置this.type 也就是Content-Type
    // no content
    if (null == val) {
      if (!statuses.empty[this.status]) this.status = 204;
      if (val === null) this._explicitNullBody = true;
      this.remove("Content-Type");
      this.remove("Content-Length");
      this.remove("Transfer-Encoding");
      return;
    }

    // 设置响应码
    if (!this._explicitStatus) this.status = 200;

    // string
    if ("string" === typeof val) {
      if (setType) this.type = /^\s*</.test(val) ? "html" : "text";
      this.length = Buffer.byteLength(val);
      return;
    }

    // buffer
    if (Buffer.isBuffer(val)) {
      if (setType) this.type = "bin";
      this.length = val.length;
      return;
    }

    // stream,处理流式数据
    if (val instanceof Stream) {
      onFinish(this.res, destroy.bind(null, val));
      if (original != val) {
        val.once("error", (err) => this.ctx.onerror(err));
        // overwriting
        if (null != original) this.remove("Content-Length");
      }

      if (setType) this.type = "bin";
      return;
    }

    // json
    this.remove("Content-Length");
    this.type = "json";
  },
};

最终在application.js 的respond方法中完成响应

/**
 * Response helper.
 */

function respond(ctx) {
  // ...
  // responses
  if (Buffer.isBuffer(body)) return res.end(body);
  if ('string' === typeof body) return res.end(body);
  if (body instanceof Stream) return body.pipe(res);
  // ...
}

参考:

koa-compose 解读笔记

十分钟带你看完 KOA 源码 - 知乎