手寫Koa.js原始碼

蔣鵬飛發表於2020-11-09

Node.js寫一個web伺服器,我前面已經寫過兩篇文章了:

Express的原始碼還是比較複雜的,自帶了路由處理和靜態資源支援等等功能,功能比較全面。與之相比,本文要講的Koa就簡潔多了,Koa雖然是Express的原班人馬寫的,但是設計思路卻不一樣。Express更多是偏向All in one的思想,各種功能都整合在一起,而Koa本身的庫只有一箇中介軟體核心,其他像路由處理和靜態資源這些功能都沒有,全部需要引入第三方中介軟體庫才能實現。下面這張圖可以直觀的看到Expresskoa在功能上的區別,此圖來自於官方文件

image.png

基於Koa的這種架構,我計劃會分幾篇文章來寫,全部都是原始碼解析:

  • Koa的核心架構會寫一篇文章,也就是本文。
  • 對於一個web伺服器來說,路由是必不可少的,所以@koa/router會寫一篇文章。
  • 另外可能會寫一些常用中介軟體,靜態檔案支援或者bodyparser等等,具體還沒定,可能會有一篇或多篇文章。

本文可執行迷你版Koa程式碼已經上傳GitHub,拿下來,一邊玩程式碼一邊看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaCore

簡單示例

我寫原始碼解析,一般都遵循一個簡單的套路:先引入庫,寫一個簡單的例子,然後自己手寫原始碼來替代這個庫,並讓我們的例子順利執行。本文也是遵循這個套路,由於Koa的核心庫只有中介軟體,所以我們寫出的例子也比較簡單,也只有中介軟體。

Hello World

第一個例子是Hello World,隨便請求一個路徑都返回Hello World

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

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

const port = 3001;
app.listen(port, () => {
  console.log(`Server is running on http://127.0.0.1:${port}/`);
});

logger

然後再來一個logger吧,就是記錄下處理當前請求花了多長時間:

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

注意這個中介軟體應該放到Hello World的前面。

從上面兩個例子的程式碼來看,KoaExpress有幾個明顯的區別:

  • ctx替代了reqres
  • 可以使用JS的新API了,比如asyncawait

手寫原始碼

手寫原始碼前我們看看用到了哪些API,這些就是我們手寫的目標:

  • new Koa():首先肯定是Koa這個類了,因為他使用new進行例項化,所以我們認為他是一個類。
  • app.useappKoa的一個例項,app.use看起來是一個新增中介軟體的例項方法。
  • app.listen:啟動伺服器的例項方法
  • ctx:這個是Koa的上下文,看起來替代了以前的reqres
  • asyncawait:支援新的語法,而且能使用await next(),說明next()返回的很可能是一個promise

本文的手寫原始碼全部參照官方原始碼寫成,檔名和函式名儘量保持一致,寫到具體的方法時我也會貼上官方原始碼地址。Koa這個庫程式碼並不多,主要都在這個資料夾裡面:https://github.com/koajs/koa/tree/master/lib,下面我們開始吧。

Koa類

Koa專案的package.json裡面的main這行程式碼可以看出,整個應用的入口是lib/application.js這個檔案:

"main": "lib/application.js",

lib/application.js這個檔案就是我們經常用的Koa類,雖然我們經常叫他Koa類,但是在原始碼裡面這個類叫做Application。我們先來寫一下這個類的殼吧:

// application.js

const Emitter = require("events");

// module.exports 直接匯出Application類
module.exports = class Application extends Emitter {
  // 建構函式先執行下父類的建構函式
  // 再進行一些初始化工作
  constructor() {
    super();

    // middleware例項屬性初始化為一個空陣列,用來儲存後續可能的中介軟體
    this.middleware = [];
  }
};

這段程式碼我們可以看出,Koa直接使用class關鍵字來申明類了,看過我之前Express原始碼解析的朋友可能還有印象,Express原始碼裡面還是使用的老的prototype來實現物件導向的。所以Koa專案介紹裡面的Expressive middleware for node.js using ES2017 async functions並不是一句虛言,它不僅支援ES2017新的API,而且在自己的原始碼裡面裡面也是用的新API。我想這也是Koa要求執行環境必須是node v7.6.0 or higher的原因吧。所以到這裡我們其實已經可以看出KoaExpress的一個重大區別了,那就是:Express使用老的API,相容性更強,可以在老的Node.js版本上執行;Koa因為使用了新API,只能在v7.6.0或者更高版本上執行了。

這段程式碼還有個點需要注意,那就是Application繼承自Node.js原生的EventEmitter類,這個類其實就是一個釋出訂閱模式,可以訂閱和釋出訊息,我在另一篇文章裡面詳細講過他的原始碼。所以他有些方法如果在application.js裡面找不到,那可能就是繼承自EventEmitter,比如下圖這行程式碼:

image.png

這裡有this.on這個方法,看起來他應該是Application的一個例項方法,但是這個檔案裡面沒有,其實他就是繼承自EventEmitter,是用來給error這個事件新增回撥函式的。這行程式碼if裡面的this.listenerCount也是EventEmitter的一個例項方法。

Application類完全是JS物件導向的運用,如果你對JS物件導向還不是很熟悉,可以先看看這篇文章:https://segmentfault.com/a/1190000023201844

app.use

從我們前面的使用示例可以看出app.use的作用就是新增一箇中介軟體,我們在建構函式裡面也初始化了一個變數middleware,用來儲存中介軟體,所以app.use的程式碼就很簡單了,將接收到的中介軟體塞到這個陣列就行:

use(fn) {
  // 中介軟體必須是一個函式,不然就報錯
  if (typeof fn !== "function")
    throw new TypeError("middleware must be a function!");

  // 處理邏輯很簡單,將接收到的中介軟體塞入到middleware陣列就行
  this.middleware.push(fn);
  return this;
}

注意app.use方法最後返回了this,這個有點意思,為什麼要返回this呢?這個其實我之前在其他文章講過的:類的例項方法返回this可以實現鏈式呼叫。比如這裡的app.use就可以連續點點點了,像這樣:

app.use(middlewaer1).use(middlewaer2).use(middlewaer3)

為什麼會有這種效果呢?因為這裡的this其實就是當前例項,也就是app,所以app.use()的返回值就是appapp上有個例項方法use,所以可以繼續點app.use().use()

app.use的官方原始碼看這裡: https://github.com/koajs/koa/blob/master/lib/application.js#L122

app.listen

在前面的示例中,app.listen的作用是用來啟動伺服器,看過前面用原生API實現web伺服器的朋友都知道,要啟動伺服器需要呼叫原生的http.createServer,所以這個方法就是用來呼叫http.createServer的。

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

這個方法本身其實沒有太多可說的,只是呼叫http模組啟動服務而已,主要的邏輯都在this.callback()裡面了。

app.listen的官方原始碼看這裡:https://github.com/koajs/koa/blob/master/lib/application.js#L79

app.callback

this.callback()是傳給http.createServer的回撥函式,也是一個例項函式,這個函式必須符合http.createServer的引數形式,也就是

http.createServer(function(req, res){})

所以this.callback()的返回值必須是一個函式,而且是這種形式function(req, res){}

除了形式必須符合外,this.callback()具體要幹什麼呢?他是http模組的回撥函式,所以他必須處理所有的網路請求,所有處理邏輯都必須在這個方法裡面。但是Koa的處理邏輯是以中介軟體的形式存在的,對於一個請求來說,他必須一個一個的穿過所有的中介軟體,具體穿過的邏輯,你當然可以遍歷middleware這個陣列,將裡面的方法一個一個拿出來處理,當然也可以用業界更常用的方法:compose

compose一般來說就是將一系列方法合併成一個方法來方便呼叫,具體實現的形式並不是固定的,有面試中常見的用reduce實現的compose,也有像Koa這樣根據自己需求單獨實現的composeKoacompose也單獨封裝了一個庫koa-compose,這個庫原始碼也是我們必須要看的,我們一步一步來,先把this.callback寫出來吧。

callback() {
  // compose來自koa-compose庫,就是將中介軟體合併成一個函式
  // 我們需要自己實現
  const fn = compose(this.middleware);

  // callback返回值必須符合http.createServer引數形式
  // 即 (req, res) => {}
  const handleRequest = (req, res) => {
    const ctx = this.createContext(req, res);
    return this.handleRequest(ctx, fn);
  };

  return handleRequest;
}

這個方法先用koa-compose將中介軟體都合成了一個函式fn,然後在http.createServer的回撥裡面使用reqres建立了一個Koa常用的上下文ctx,然後再呼叫this.handleRequest來真正處理網路請求。注意這裡的this.handleRequest是個例項方法,和當前方法裡面的區域性變數handleRequest並不是一個東西。這幾個方法我們一個一個來看下。

this.callback對應的官方原始碼看這裡:https://github.com/koajs/koa/blob/master/lib/application.js#L143

koa-compose

koa-compose雖然被作為了一個單獨的庫,但是他的作用卻很關鍵,所以我們也來看看他的原始碼吧。koa-compose的作用是將一箇中介軟體組成的陣列合併成一個方法以便外部呼叫。我們先來回顧下一個Koa中介軟體的結構:

function middleware(ctx, next) {}

這個陣列就是有很多這樣的中介軟體:

[
  function middleware1(ctx, next) {},
  function middleware2(ctx, next) {}
]

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!");
  }

  // 返回一個方法,這個方法就是compose的結果
  // 外部可以通過呼叫這個方法來開起中介軟體陣列的遍歷
  // 引數形式和普通中介軟體一樣,都是context和next
  return function (context, next) {
    return dispatch(0); // 開始中介軟體執行,從陣列第一個開始

    // 執行中介軟體的方法
    function dispatch(i) {
      let fn = middleware[i]; // 取出需要執行的中介軟體

      // 如果i等於陣列長度,說明陣列已經執行完了
      if (i === middleware.length) {
        fn = next; // 這裡讓fn等於外部傳進來的next,其實是進行收尾工作,比如返回404
      }

      // 如果外部沒有傳收尾的next,直接就resolve
      if (!fn) {
        return Promise.resolve();
      }

      // 執行中介軟體,注意傳給中介軟體接收的引數應該是context和next
      // 傳給中介軟體的next是dispatch.bind(null, i + 1)
      // 所以中介軟體裡面呼叫next的時候其實呼叫的是dispatch(i + 1),也就是執行下一個中介軟體
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };
}

上面程式碼主要的邏輯就是這行:

return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

這裡的fn就是我們自己寫的中介軟體,比如文章開始那個logger,我們稍微改下看得更清楚:

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

app.use(logger);

那我們compose裡面執行的其實是:

logger(context, dispatch.bind(null, i + 1));

也就是說logger接收到的next其實是dispatch.bind(null, i + 1),你呼叫next()的時候,其實呼叫的是dispatch(i + 1),這樣就達到了執行陣列下一個中介軟體的效果。

另外由於中介軟體在返回前還包裹了一層Promise.resolve,所以我們所有自己寫的中介軟體,無論你是否用了Promisenext呼叫後返回的都是一個Promise,所以你可以使用await next()

koa-compose的原始碼看這裡:https://github.com/koajs/compose/blob/master/index.js

app.createContext

上面用到的this.createContext也是一個例項方法。這個方法根據http.createServer傳入的reqres來構建ctx這個上下文,官方原始碼長這樣:

image-20201029163710087

這段程式碼裡面contextctxresponseresrequestreqapp這幾個變數相互賦值,頭都看暈了。其實完全沒必要陷入這堆麵條裡面去,我們只需要將他的思路和骨架拎清楚就行,那怎麼來拎呢?

  1. 首先搞清楚他這麼賦值的目的,他的目的其實很簡單,就是為了使用方便。通過一個變數可以很方便的拿到其他變數,比如我現在只有request,但是我想要的是req,怎麼辦呢?通過這種賦值後,直接用request.req就行。其他的類似,這種麵條式的賦值我很難說好還是不好,但是使用時確實很方便,缺點就是看原始碼時容易陷進去。
  2. requestreq有啥區別?這兩個變數長得這麼像,到底是幹啥的?這就要說到Koa對於原生req的擴充套件,我們知道http.createServer的回撥裡面會傳入req作為請求物件的描述,裡面可以拿到請求的header啊,method啊這些變數。但是Koa覺得這個req提供的API不好用,所以他在這個基礎上擴充套件了一些API,其實就是一些語法糖,擴充套件後的req就變成了request。之所以擴充套件後還保留的原始的req,應該也是想為使用者提供更多選擇吧。所以這兩個變數的區別就是requestKoa包裝過的reqreq是原生的請求物件。responseres也是類似的。
  3. 既然requestresponse都只是包裝過的語法糖,那其實Koa沒有這兩個變數也能跑起來。所以我們拎骨架的時候完全可以將這兩個變數踢出去,這下骨架就清晰了。

那我們踢出responserequest後再來寫下createContext這個方法:

// 建立上下文ctx物件的函式
createContext(req, res) {
  const context = Object.create(this.context);
  context.app = this;
  context.req = req;
  context.res = res;

  return context;
}

這下整個世界感覺都清爽了,context上的東西也一目瞭然了。但是我們的context最初是來自this.context的,這個變數還必須看下。

app.createContext對應的官方原始碼看這裡:https://github.com/koajs/koa/blob/master/lib/application.js#L177

context.js

上面的this.context其實就是來自context.js,所以我們先在Application建構函式裡面新增這個變數:

// application.js

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

// 建構函式裡面
constructor() {
    // 省略其他程式碼
  this.context = context;
}

然後再來看看context.js裡面有啥,context.js的結構大概是這個樣子:

const delegate = require("delegates");

module.exports = {
  inspect() {},
  toJSON() {},
  throw() {},
  onerror() {},
};

const proto = module.exports;

delegate(proto, "response")
  .method("set")
  .method("append")
  .access("message")
  .access("body");

delegate(proto, "request")
  .method("acceptsLanguages")
  .method("accepts")
  .access("querystring")
  .access("socket");

這段程式碼裡面context匯出的是一個物件proto,這個物件本身有一些方法,inspecttoJSON之類的。然後還有一堆delegate().method()delegate().access()之類的。嗯,這個是幹啥的呢?要知道這個的作用,我們需要去看delegates這個庫:https://github.com/tj/node-delegates,這個庫也是tj大神寫的。一般使用是這樣的:

delegate(proto, target).method("set");

這行程式碼的作用是,當你呼叫proto.set()方法時,其實是轉發給了proto[target],實際呼叫的是proto[target].set()。所以就是proto代理了對target的訪問。

那用在我們context.js裡面是啥意思呢?比如這行程式碼:

delegate(proto, "response")
  .method("set");

這行程式碼的作用是,當你呼叫proto.set()時,實際去呼叫proto.response.set(),將proto換成ctx就是:當你呼叫ctx.set()時,實際呼叫的是ctx.response.set()。這麼做的目的其實也是為了使用方便,可以少寫一個response。而且ctx不僅僅代理response,還代理了request,所以你還可以通過ctx.accepts()這樣來呼叫到ctx.request.accepts()。一個ctx就囊括了responserequest,所以這裡的context也是一個語法糖。因為我們前面已經踢了responserequest這兩個語法糖,context作為包裝了這兩個語法糖的語法糖,我們也一起踢掉吧。在Application的建構函式裡面直接將this.context賦值為空物件:

// application.js
constructor() {
    // 省略其他程式碼
  this.context = {};
}

現在語法糖都踢掉了,整個Koa的結構就更清晰了,ctx上面也只有幾個必須的變數:

ctx = {
  app,
  req,
  res
}

context.js對應的原始碼看這裡:https://github.com/koajs/koa/blob/master/lib/context.js

app.handleRequest

現在我們ctxfn都構造好了,那我們處理請求其實就是呼叫fnctx是作為引數傳給他的,所以app.handleRequest程式碼就可以寫出來了:

// 處理具體請求
handleRequest(ctx, fnMiddleware) {
  const handleResponse = () => respond(ctx);

  // 呼叫中介軟體處理
  // 所有處理完後就呼叫handleResponse返回請求
  return fnMiddleware(ctx)
    .then(handleResponse)
    .catch((err) => {
    console.log("Somethis is wrong: ", err);
  });
}

我們看到compose庫返回的fn雖然支援第二個引數用來收尾,但是Koa並沒有用他,如果不傳的話,所有中介軟體執行完返回的就是一個空的promise,所以可以用then接著他後面處理。後面要進行的處理就只有一個了,就是將處理結果返回給請求者的,這也就是respond需要做的。

app.handleRequest對應的原始碼看這裡:https://github.com/koajs/koa/blob/master/lib/application.js#L162

respond

respond是一個輔助方法,並不在Application類裡面,他要做的就是將網路請求返回:

function respond(ctx) {
  const res = ctx.res; // 取出res物件
  const body = ctx.body; // 取出body

  return res.end(body); // 用res返回body
}

大功告成

現在我們可以用自己寫的Koa替換官方的Koa來執行我們開頭的例子了,不過logger這個中介軟體執行的時候會有點問題,因為他下面這行程式碼用到了語法糖:

console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);

這裡的ctx.methodctx.url在我們構建的ctx上並不存在,不過沒關係,他不就是個req的語法糖嘛,我們從ctx.req上拿就行,所以上面這行程式碼改為:

console.log(`${ctx.req.method} ${ctx.req.url} - ${ms}ms`);

總結

通過一層一層的抽絲剝繭,我們成功拎出了Koa的程式碼骨架,自己寫了一個迷你版的Koa

這個迷你版程式碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaCore

最後我們再來總結下本文的要點吧:

  1. KoaExpress原班人馬寫的一個新框架。
  2. Koa使用了JS的新API,比如asyncawait
  3. Koa的架構和Express有很大區別。
  4. Express的思路是大而全,內建了很多功能,比如路由,靜態資源等,而且Express的中介軟體也是使用路由同樣的機制實現的,整個程式碼更復雜。Express原始碼可以看我之前這篇文章:手寫Express.js原始碼
  5. Koa的思路看起來更清晰,Koa本身的庫只是一個核心,只有中介軟體功能,來的請求會依次經過每一箇中介軟體,然後再出來返回給請求者,這就是大家經常聽說的“洋蔥模型”。
  6. 想要Koa支援其他功能,必須手動新增中介軟體。作為一個web伺服器,路由可以算是基本功能了,所以下一遍文章我們會來看看Koa官方的路由庫@koa/router,敬請關注。

參考資料

Koa官方文件:https://github.com/koajs/koa

Koa原始碼地址:https://github.com/koajs/koa/tree/master/lib

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges

我也搞了個公眾號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~

相關文章