koa 源碼分析

koa 源碼分析

原文鏈接

簡介

koa 是由 Express 原班人馬打造的,相比 Express 的大而全,koa 致力於成爲一個更小、更富有表現力、更健壯的 Web 框架,適合作爲 web 服務框架的基石。

koa1 通過組合不同的 generator,可以避免嵌套地獄,並極大地提升錯誤處理的效率。koa2 使用了最新的 async await generator 語法糖,使得開發更高效。

koa 不在內核方法中綁定任何中間件,但確很輕易集成中間件,只需要 use 方法傳入一箇中間件函數,就能方便獲取請求響應等上下文信息和下一個中間件,使得中間件的使用井然有序。

概覽

koa 源碼在 lib 文件下四個文件中,接下來一一介紹每個模塊文件的內容。

lib/
├── application.js
├── context.js
├── request.js
└── response.js
  • application.js 導出一個類函數,用來生成koa實例。該類派生 node events,方便錯誤處理。
  1. use() 添加訂閱中間件,內部使用一個數組維護中間件;
  2. listen() node http 起一個服務;
  3. callback() 返回一個 http 服務回調函數 cb。
    1. compose 處理中間件數組,返回一個函數 fnMiddleware。內部 promise 化中間件,遞歸調用使得中間件拿到上下文 ctx 和下一個中間件 next 並順序執行;
    2. createContext 在 cb 中接收 http 請求的回調參數 req、res,使得 application實例、context、request、response 能夠相互訪問 req、res,每次返回一個新的 context;
    3. handleRequest 最終執行 fnMiddleware,中間件無錯誤後調用私有函數 respond 返回響應。
  • context.js 導出一個對象,主要功能有:錯誤處理、cookie 處理、代理 request.js、response.js 上的屬性和方法(例如:訪問ctx.url,其實是訪問了 request.url,又其實訪問了node http req.url)。
  • request.js 導出一個對象,封裝處理了 node 原生 http 的請求 req ,方便獲取設置 req,避免直接與 req 打交道。
  • response.js 導出一個對象,封裝處理了 node 原生 http 的響應 res ,方便獲取設置 res,避免直接與 res 打交道。

使用例子

  • 起一個簡單的服務
const Koa = require('koa');
const app = new Koa();

app.listen(3000);

其實是以下的語法糖

const http = require('http');
const Koa = require('koa');
const app = new Koa();

http.createServer(app.callback()).listen(3000);
  • 使用中間件處理 node http 請求、響應
const Koa = require('koa');
const app = new Koa();

// logger 中間件
app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}`);
});

// response
app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

logger 中間件 await next() 時會暫停下面代碼的執行,直到 response 中間件執行完畢。

注意到 response 沒有執行 next,此時已沒有下一個中間件,但即使執行也不會報錯,因爲內部處理爲一個 Promise.resolve 的 promise。

注意在一箇中間件中多次(2次及以上)執行 next() 會報錯。

如果 logger 中間件不執行 next,那麼 response 中間件不會被執行。也即 ctx.body 不會執行,application 中的 handleRequest 默認設置node http res.statusCode = 404,npm statuses 中維護了常用的 code 碼文本提示音,例如 404: Not Found

ctx.body 其實是調用了 koa response 對象的 body set 方法,賦值給 _body 屬性並且根據值設置 http 狀態碼。最後是在中間件 resolve 後調用 application 中的私有 respond 函數,執行了 node http res.end()。

動手實現一個精簡的 koa

骨架

  • application.js 需要起服務,所以需要引入node http模塊;需要發佈訂閱一些消息,所以需要繼承node events模塊。剩餘引入其它三個文件的模塊。
const http = require('http');
const Emitter = require('events');
const context = require('./context');
const request = require('./request');
const response = require('./response');

class Koa extends Emitter {
  constructor() {
    super();
  }
  
  listen() {}
  
  use() {}
  
  callback() {}
  
  handleRequest() {}
  
  createContext() {}
}

module.exports = Koa;
  • context.js
let proto = {};

module.exports = proto;
  • request.js
const request = {};

module.exports = request;
  • response.js
const response = {};

module.exports = response;

第一步,接收一箇中間功能

  • 構造函數,其它三個對象都能被 app 實例訪問。
constructor() {
  super();
  this.context = Object.create(context);
  this.request = Object.create(request);
  this.response = Object.create(response);
  this.fn = null;
}

簡單提一下爲什麼要使用 Object.create,例如避免改動 this.context.x 而影響 context.x
(除非你 this.context.__proto__.x,顯然沒人會刻意這麼去做)。

if(!Object.create) {
  Object.create = function(proto) {
    function F(){}
    F.prototype = proto;
    return new F;
  }
}
  • listen,語法糖方便起 http 服務。
listen(...args) {
  const server = http.createServer(this.callback());
  return server.listen(...args);
}
  • use,訂閱中間件,暫時只能訂閱一個。
use(fn) {
  this.fn = fn;
  return this;
}
  • callback,處理中間件,並且返回一個接收 node http req,res 的回調函數。
    每次接收一個 http 請求時,都會使用 koa createContext 根據當前請求環境新建上下文。
callback() {
  return (req, res) => {
    const ctx = this.createContext(req, res);
    return this.handleRequest(ctx);
  };
}
  • handleRequest,執行中間件,和響應 http 請求。
handleRequest(ctx) {
  fn(ctx);
  ctx.res.end(ctx.body);
}
  • createContext,每次處理一個 http 請求都會根據當前請求的 req、res 來更新相關內容。
    一系列賦值操作,主要爲了新生成得 context、request、response 可以相互訪問,且能訪問 koa app 實例和 http req、res。
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;
  return context;
}
  • request.js,簡單給 koa request 對象添加幾個處理 url 的方法
const parse = require('parseurl');

const request = {
  get url() {
    return this.req.url;
  },
  get path() {
    return parse(this.req).pathname;
  },
  get query() {
    return parse(this.req).query;
  }
};
  • response.js,這裏只添加一個設置響應 body 的方法
const response = {
  get body() {
    return this._body;
  },
  set body(val) {
    this.res.statusCode = 200;
    this._body = val;
  }
};
  • 主文件 index.js
const Koa = require('./application');
const app = new Koa();

app.use(ctx => {
  console.log(ctx.req.url);
  console.log(ctx.request.req.url);
  console.log(ctx.response.req.url);
  console.log(ctx.request.url);
  console.log(ctx.request.path);
  console.log(ctx.request.query);
  console.log(ctx.url);
  console.log(ctx.path);
  console.log(ctx.query);
  ctx.body = 'hello world';
});

app.listen(3000);
  • node index.js,瀏覽器輸入 localhost:3000/path?x=1&y=2,console 輸出
/path?x=1&y=2
/path?x=1&y=2
/path?x=1&y=2
/path?x=1&y=2
/path
x=1&y=2
undefined
undefined
undefined

可以看出,可以使用 koa context、request、response 來訪問 node req 的屬性,也可以直接訪問 request 對象上定義的方法。

建議是避免操作 node http 的 req 或 res。

衆所周知,koa 是支持 context 實例代理訪問 koa request、response 上的方法的。

第二步,實現 context 代理

  • context.js,代理訪問 koa request、response 上的方法

koa 使用了 __defineSetter____defineGetter__ 來實現,提示這兩個方法已被標準廢棄,這裏使用 Object.defineProperty 來實現。

注意 Object.defineProperty 只設置 get 方法 enumerableconfigurable 默認都是 false

function defineGetter(prop, name) {
  Object.defineProperty(proto, name, {
    get() {
      return this[prop][name];
    },
    enumerable: true,
    configurable: true,
  });
}

function defineSetter(prop, name) {
  Object.defineProperty(proto, name, {
    set(val) {
      this[prop][name] = val;
    },
    enumerable: true,
    configurable: true,
  });
}

defineGetter('request', 'url');
defineGetter('request', 'path');
defineGetter('request', 'query');

defineGetter('response', 'body');
defineSetter('response', 'body');
  • 重啓服務,console.log 輸出
/path?x=1&y=2
/path?x=1&y=2
/path?x=1&y=2
/path?x=1&y=2
/path
x=1&y=2
/path?x=1&y=2
/path
x=1&y=2

ctx.body = 'hello world' 也不是新添加屬性,而是訪問 response 上的 body set 方法。

第三部,接收多個同步中間件

constructor() {
-  this.fn = null;
+  this.middleware = [];
}

use(fn) {
-  this.fn = fn;
+  this.middleware.push(fn);
}
  • 新增 compose,實現洋蔥圈模型
function compose(middleware) {
  return function (context, next) {
    let index = -1;
    return dispatch(0);
    function dispatch(i) {
      if(i <= index) throw new Error('next() 在中間件中被調用2次以上');
      index = i;
      let fn = middleware[i];
      if(i === middleware.length) fn = next;
      if(!fn) return;
      return fn(context, dispatch.bind(null, i + 1));
    }
  }
}
callback() {
+  const fn = compose(this.middleware); 
  return (req, res) => {
    const ctx = this.createContext(req, res);
-    return this.handleRequest(ctx);
+    return this.handleRequest(ctx, fn);
  };
}

- handleRequest(ctx) {
+ handleRequest(ctx, fnMiddleware) {
- fn(ctx);
+ fnMiddleware(ctx);
  ctx.res.statusCode = 200;
  ctx.res.end(ctx.body);
}
  • index.js,就能使用多箇中間件和 next 了
app.use((ctx, next) => {
  console.log(ctx.url);
  next();
});

app.use((ctx, next) => {
  ctx.body = 'hello world';
  next();
});

第三部,異步洋蔥圈模型

  • 改造 compose,支持異步
function compose(middleware) {
  return function (context, next) {
    let index = -1;
    return dispatch(0);
    function dispatch(i) {
-      if(i <= index) throw new Error('next() 在中間件中被調用2次以上');
+      if(i <= index) return Promise.reject(new Error('next() 在中間件中被調用2次以上'));
      index = i;
      let fn = middleware[i];
      if(i === middleware.length) fn = next;
-      if(!fn) return;
+      if(!fn) return Promise.resolve();
-      return fn(context, dispatch.bind(null, i + 1));
+      try {
+        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
+      } catch (err) {
+        return Promise.reject(err);
+      }
    }
  }
}
handleRequest(ctx, fnMiddleware) {
-  fnMiddleware(ctx);
-  ctx.res.statusCode = 200;
-  ctx.res.end(ctx.body);
+  fnMiddleware(ctx).then(() => {
+    ctx.res.statusCode = 200;
+    ctx.res.end(ctx.body);
+  });
}
  • index.js 異步洋蔥圈
app.use(async (ctx, next) => {
  await new Promise(resolve => {
    setTimeout(() => {
      console.log(ctx.url);
      resolve();
    }, 500);
  });
  next();
});

app.use((ctx, next) => {
  ctx.body = 'hello world';
  next();
});

這樣一個簡單的 koa 的主要功能就實現了,行文爲了簡單,很多錯誤處理等細節都忽略了,這在正式的產品中是大忌,希望小心謹慎。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章