手寫Koa.js源碼

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

Express的源碼仍是比較複雜的,自帶了路由處理和靜態資源支持等等功能,功能比較全面。與之相比,本文要講的Koa就簡潔多了,Koa雖然是Express的原班人馬寫的,可是設計思路卻不同。Express更可能是偏向All in one的思想,各類功能都集成在一塊兒,而Koa自己的庫只有一箇中間件內核,其餘像路由處理和靜態資源這些功能都沒有,所有須要引入第三方中間件庫才能實現。下面這張圖能夠直觀的看到Expresskoa在功能上的區別,此圖來自於官方文檔前端

image.png

基於Koa的這種架構,我計劃會分幾篇文章來寫,所有都是源碼解析:java

  • Koa的核心架構會寫一篇文章,也就是本文。
  • 對於一個web服務器來講,路由是必不可少的,因此@koa/router會寫一篇文章。
  • 另外可能會寫一些經常使用中間件,靜態文件支持或者bodyparser等等,具體還沒定,可能會有一篇或多篇文章。

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

簡單示例

我寫源碼解析,通常都遵循一個簡單的套路:先引入庫,寫一個簡單的例子,而後本身手寫源碼來替代這個庫,並讓咱們的例子順利運行。本文也是遵循這個套路,因爲Koa的核心庫只有中間件,因此咱們寫出的例子也比較簡單,也只有中間件。git

Hello World

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

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吧,就是記錄下處理當前請求花了多長時間:web

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有幾個明顯的區別:express

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

手寫源碼

手寫源碼前咱們看看用到了哪些API,這些就是咱們手寫的目標:json

  • 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面向對象還不是很熟悉,能夠先看看這篇文章:http://www.javashuo.com/article/p-txfbwzdy-nm.html

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

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

相關文章
相關標籤/搜索