Koa 源碼剖析 & 實現

前言

Koa 做爲搭建 NodeJS 服務時最經常使用的 web 框架,其源碼並不複雜,但卻實現了兩個核心要點:node

  • 中間件(Middleware)流程控制,又稱「洋蔥式模型」
  • 將 http.createServer 方法中的 request(IncomingMessage object)和 response(ServerResponse object)掛載到上下文(ctx)中

不管你在準備面試,或想提高編碼能力,那麼理解 Koa 源碼是一個不錯的選擇。因此,咱們的目標是:git

只關注核心功能點,最大程度地精簡代碼,親自實現一個 Koa。github

準備工做

首先,確保你的工做目錄以下:web

├── lib
│   ├── application.js
│   ├── context.js
│   ├── request.js
│   └── response.js
└── app.js
複製代碼

隨後,編寫一個入門級別的 Hello World 服務:面試

// app.js
const Koa = require("./lib/application.js");
const app = new Koa();

app.use(async ctx => {
  ctx.body = "Hello World";
});

app.listen(3000);
複製代碼

固然這個服務暫時沒法運行。json

Application

application.js 做爲入口文件,它導出了一個 Class(本質爲構造函數),用於建立 Koa 實例。數組

const http = require("http");

class Koa {
  constructor() {
    // 存放中間件函數
    this.middleware = [];
  }
  use(fn) {
    this.middleware.push(fn);
    // 鏈式調用
    return this;
  }
  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
  callback() {
    // 處理具體的請求和響應……
  }
}

module.exports = Koa;
複製代碼

因爲 NodeJS 原生提供的 request 對象 和 response 對象上能使用的方法較少,因而 Koa 分別在這兩個對象上做了相應的拓展。瀏覽器

而且爲了簡化 API,將這兩個對象封裝並掛載到 Koa 的會話上下文(Context)中。bash

Request

// request.js
module.exports = {
  get url() {
    return this.req.url;
  },
  get method() {
    return this.req.method;
  },
};
複製代碼

Response

// response.js
module.exports = {
  get body() {
    return this._body;
  },
  set body(val) {
    this._body = val;
  },
};
複製代碼

Context

context 對象爲被訪問的屬性設置 gettersetter,並委託給 request 對象和 response 對象執行。網絡

// context.js
module.exports = {
  get url() {
    return this.request.url;
  },
  get body() {
    return this.response.body;
  },
  set body(val) {
    this.response.body = val;
  },
  get method() {
    return this.request.method;
  },
};
複製代碼

當設置的訪問器過多,你能夠採用另外一種寫法:

// context.js
const reqGetters = ["url", "method"],
  resAccess = ["body"],
  proto = {};

for (let name of reqGetters) {
  proto.__defineGetter__(name, function() {
    return this["request"][name];
  });
}
for (let name of resAccess) {
  proto.__defineGetter__(name, function() {
    return this["response"][name];
  });
  proto.__defineSetter__(name, function(val) {
    return (this["response"][name] = val);
  });
}
module.exports = proto;
複製代碼

同時,更改 application.js

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

class Koa {
  constructor() {
    this.request = request;
    this.response = response;
    this.context = context;
    // 存放中間件函數
    this.middleware = [];
  }
  callback() {
    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      this.middleware[0](ctx);
      res.end(ctx.body);
    };
    return handleRequest;
  }
  createContext(req, res) {
    // 將拓展後的請求和響應掛載到 context 上
    const context = Object.create(this.context);
    const request = (context.request = Object.create(this.request));
    const response = (context.response = Object.create(this.response));
    // 掛載原生請求、響應
    context.req = request.req = req;
    context.res = response.res = res;

    return context;
  }
}

module.exports = Koa;
複製代碼

運行 app.js,瀏覽器訪問顯示 "Hello World"

中間件

Koa 中間件機制源於 compose 函數:它是一個高階函數,能將多個順序執行的函數組合成一個函數,內層函數的返回值做爲外層函數的參數。

舉個栗子 🌰:

function lower(str) {
  return str.toLowerCase();
}

function join(arr) {
  return arr.join(",");
}

function padStart(str) {
  return str.padStart(str.length + 6, "apple,");
}

// 我想順序調用 join() lower() padStart()

padStart(lower(join(["BANANA", "ORANGE"])));
// apple,banana,orange
複製代碼

固然,你須要一個 compose 函數來自動實現以上操做,而不是手動。

function compose(...funcs) {
  return args => funcs.reduceRight((composed, f) => f(composed), args);
}

const fn = compose(padStart, lower, join);
fn(["BANANA", "ORANGE"]);
// apple,banana,orange
複製代碼

更改你的 app.js 代碼:

app
  .use((ctx, next) => {
    console.log(1);
    next();
    console.log(5);
  })
  .use((ctx, next) => {
    console.log(2);
    next();
    console.log(4);
  })
  .use((ctx, next) => {
    console.log(3);
    next();
    console.log(4);
  });
複製代碼

Koa 指望終端中打印:1 2 3 4 5 6,因此你須要實現 Koa 的 compose 函數,以便多箇中間件順序調用,並接受 next() 調用下一個中間件函數的操做,這樣一結合就造成了「洋蔥式模型」:Request 因爲 next() 的存在,它會遞歸進入下一個中間件函數被處理,直至不存在下一個中間件函數,遞歸結束,執行棧依次返回到上一個中間件函數繼續處理,直至執行棧爲空,返回 Response

繼續查看源碼,你會發現 Koa 使用了 koa-compose 庫,其代碼也很精簡。

在你的 Application.js 中添加如下內容:

class Boa {
  callback() {
    const fn = this.compose(this.middleware);
    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };
    return handleRequest;
  }
  handleRequest(ctx, fnMiddleware) {
    const handleResponse = () => this.respond(ctx);
    return fnMiddleware(ctx).then(handleResponse);
  }
  respond(ctx) {
    const { res, body } = ctx;
    res.end(body === undefined ? "Not Found" : body);
  }
  compose(middleware) {
    return function(context, next) {
      return dispatch(0);
      function dispatch(i) {
        let fn = middleware[i];
        // 全部中間件函數執行完畢,fn = undefined,結束遞歸
        if (i === middleware.length) fn = next;
        if (!fn) return Promise.resolve();
        // 遞歸調用下一個中間件函數
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      }
    };
  }
}
複製代碼

爲了更好的理解「洋蔥式模型」,你可使用 VSCode 的 Run 面板進行調試。

return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); // 在此處設置斷點
複製代碼

並在根目錄下配置 .vscode/launch.json

{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Launch Program",
      "skipFiles": ["<node_internals>/**"],
      "program": "${workspaceFolder}\\app.js"
    }
  ]
}
複製代碼

運行 F5,訪問 http://localhost:3000 命中斷點,使用 Step Over 和 Step Into 來將程序運行至第三個 next() 內部,你將會看到以下圖所示的調用棧:

雖然 koa-compose 的實現方式與 compose 有所不一樣,但核心思想是一致的:一旦調用了 dispatch(0),中間件函數就會自動遞歸執行,依次調用 dispatch(1) dispatch(2) dispatch(3),而在 compose() 中:

const fn = compose(padStart, lower, join);
fn(["BANANA", "ORANGE"]);
// 一旦調用 fn(),便會遞歸執行,依次調用 join(), lower(), padStart()
複製代碼

compose 中的數據流是單向的,而 Koa-compose 中 next() 的引入使得 Koa 的數據流是雙向的(數據從外到內,再從內到外),就比如你用一根針 💉 貫穿一顆洋蔥 🧅,先通過表皮深刻核心,再由核心離開表皮,這就是 Koa 中間件的獨特之處(「洋蔥式模型」):源於 compose,優於 compose.

處理異步

在 NodeJS 中,充斥着大量的 I/O 以及網絡請求,它們都屬於 異步請求

Koa 則使用了 async 函數和 await 操做符,丟棄了回調函數,以更優雅的方式去處理異步操做。

更改你的 app.js 代碼:

const fs = require("fs");
const promisify = require("util").promisify;

// 異步讀取根目錄下 demo.txt 的內容
const readTxt = async () => {
  const promisifyReadFile = promisify(fs.readFile);
  const data = await promisifyReadFile("./demo.txt", { encoding: "utf8" });
  return data ? data : "no content";
};

app
  .use((ctx, next) => {
    console.log("start");
    next();
    console.log("end");
  })
  .use(async (ctx, next) => {
    const data = await next();
    ctx.body = data;
  })
  .use(readTxt);
複製代碼

在根目錄下建立 demo.txt

you are the best.
複製代碼

如今,你的應用中包含了一個新的中間件函數 readTxt(),其內部的 fs.readFile() 屬於異步 I/O 的範疇。

根據 Koa 的指導思想:利用 asyncawait 語法糖,用同步代碼的書寫方式來解決異步操做。

運行 app.js,終端打印 start end,瀏覽器訪問顯示 you are the best.

至此,你已經實現了 Koa 的所有核心功能 🎉

源碼地址

相關文章
相關標籤/搜索