從零實(shí)現(xiàn)TypeScript版Koa

這篇文章會(huì)講些什么?

  • 如何從零開始完成一個(gè)涵蓋Koa核心功能的Node.js類庫
  • 從代碼層面解釋Koa一些代碼寫法的原因:如中間件為什么必須調(diào)用next函數(shù)、ctx是怎么來的和一個(gè)請求是什么關(guān)系

我們知道Koa類庫主要有以下幾個(gè)重要特性:

  • 支持洋蔥圈模型的中間件機(jī)制
  • 封裝request、response提供context對象,方便http操作
  • 異步函數(shù)、中間件的錯(cuò)誤處理機(jī)制

第一步:基礎(chǔ)Server運(yùn)行

目標(biāo):完成基礎(chǔ)可行新的Koa Server

  • 支持app.listen監(jiān)聽端口啟動(dòng)Server
  • 支持app.use添加類middleware處理函數(shù)

核心代碼如下:

class Koa {
  private middleware: middlewareFn = () => {};
  constructor() {}
  listen(port: number, cb: noop) {
    const server = http.createServer((req, res) => {
      this.middleware(req, res);
    });
    return server.listen(port, cb);
  }
  use(middlewareFn: middlewareFn) {
    this.middleware = middlewareFn;
    return this;
  }
}

const app = new Koa();
app.use((req, res) => {
  res.writeHead(200);
  res.end("A request come in");
});
app.listen(3000, () => {
  console.log("Server listen on port 3000");
});

第二步:洋蔥圈中間件機(jī)制實(shí)現(xiàn)

目標(biāo):接下來我們要完善listen和use方法,實(shí)現(xiàn)洋蔥圈中間件模型

如下面代碼所示,在這一步中我們希望app.use能夠支持添加多個(gè)中間件,并且中間件是按照洋蔥圈(類似深度遞歸調(diào)用)的方式順序執(zhí)行

app.use(async (req, res, next) => {
  console.log("middleware 1 start");
  // 具體原因我們會(huì)在下面代碼實(shí)現(xiàn)詳細(xì)講解
  await next();
  console.log("middleware 1 end");
});
app.use(async (req, res, next) => {
  console.log("middleware 2 start");
  await next();
  console.log("middleware 2 end");
});
app.use(async (req, res, next) => {
  res.writeHead(200);
  res.end("An request come in");
  await next();
});
app.listen(3000, () => {
  console.log("Server listen on port 3000");
});

上述Demo有三個(gè)需要我們注意的點(diǎn):

  • 在中間件中next()函數(shù)必須且只能調(diào)用一次
  • 調(diào)用next函數(shù)時(shí)必須使用await

我們會(huì)在接下來的代碼中逐個(gè)分析這些使用方法的原因,下面我們來看一看具體怎么實(shí)現(xiàn)這種洋蔥圈機(jī)制:

class Koa {
  ...
  use(middlewareFn: middlewareFn) {
    // 1、調(diào)用use時(shí),使用數(shù)組存貯所有的middleware
    this.middlewares.push(middlewareFn);
    return this;
  }
  listen(port: number, cb: noop) {
    // 2、 通過composeMiddleware將中間件數(shù)組轉(zhuǎn)換為串行[洋蔥圈]調(diào)用的函數(shù),在createServer中回調(diào)函數(shù)中調(diào)用
    // 所以真正的重點(diǎn)就是 composeMiddleware,如果做到的,我們接下來看該函數(shù)的實(shí)現(xiàn)
    // BTW: 從這里可以看到 fn 是在listen函數(shù)被調(diào)用之后就生成了,這就意味著我們不能在運(yùn)行時(shí)動(dòng)態(tài)的添加middleware
    const fn = composeMiddleware(this.middlewares);
    const server = http.createServer(async (req, res) => {
      await fn(req, res);
    });
    return server.listen(port, cb);
  }
}

// 3、洋蔥圈模型的核心:
// 入?yún)ⅲ核惺占闹虚g件
// 返回:串行調(diào)用中間件數(shù)組的函數(shù)
function composeMiddleware(middlewares: middlewareFn[]) {
  return (req: IncomingMessage, res: ServerResponse) => {
    let start = -1;
    // dispatch:觸發(fā)第i個(gè)中間件執(zhí)行
    function dispatch(i: number) {
      // 剛開始可能不理解這里為什么這么判斷,可以看完整個(gè)函數(shù)在來思考這個(gè)問題
      // 正常情況下每次調(diào)用前 start < i,調(diào)用完next() 應(yīng)該 start === i
      // 如果調(diào)用多次next(),第二次及以后調(diào)用因?yàn)橹耙淹瓿蓅tart === i賦值,所以會(huì)導(dǎo)致 start >= i
      if (i <= start) {
        return Promise.reject(new Error("next() call more than once!"));
      }
      if (i >= middlewares.length) {
        return Promise.resolve();
      }
      start = i;
      const middleware = middlewares[i];
      // 重點(diǎn)來了!!!
      // 取出第i個(gè)中間件執(zhí)行,并將dispatch(i+1)作為next函數(shù)傳給各下一個(gè)中間件
      return middleware(req, res, () => {
        return dispatch(i + 1);
      });
    }
    return dispatch(0);
  };
}

主要涉及到Promise幾個(gè)知識(shí)點(diǎn):

  • async 函數(shù)返回的是一個(gè)Promise對象【所以的中間件都會(huì)返回一個(gè)promise對象】
  • async 函數(shù)內(nèi)部遇到 await 調(diào)用時(shí)會(huì)暫停執(zhí)行await函數(shù),等待返回結(jié)果后繼續(xù)向下執(zhí)行
  • async 函數(shù)內(nèi)部發(fā)生錯(cuò)誤會(huì)導(dǎo)致返回的Promise變?yōu)閞eject狀態(tài)

現(xiàn)在我們在回顧之前提出的幾個(gè)問題:

  1. koa中間件中為什么必須且只能調(diào)用一次next函數(shù)

     可以看到如果不調(diào)用next,就不會(huì)觸發(fā)dispatch(i+1),下一個(gè)中間件就沒辦法觸發(fā),造成假死狀態(tài)最終請求超時(shí)
     
     調(diào)用多次next則會(huì)導(dǎo)致下一個(gè)中間件執(zhí)行多次
    
  2. next() 調(diào)用為什么需要加 await

     這也是洋蔥圈調(diào)用機(jī)制的核心,當(dāng)執(zhí)行到 await next(),會(huì)執(zhí)行next()【調(diào)用下一個(gè)中間件】等待返回結(jié)果,在接著向下執(zhí)行
    

第三步:Context提供

目標(biāo):封裝Context,提供request、response的便捷操作方式

// 1、 定義KoaRequest、KoaResponse、KoaContext
interface KoaContext {
  request?: KoaRequest;
  response?: KoaResponse;
  body: String | null;
}
const context: KoaContext = {
  get body() {
    return this.response!.body;
  },
  set body(body) {
    this.response!.body = body;
  }
};

function composeMiddleware(middlewares: middlewareFn[]) {
  return (context: KoaContext) => {
    let start = -1;
    function dispatch(i: number) {
      // ..省略其他代碼..
      // 2、所有的中間件接受context參數(shù)
      middleware(context, () => {
        return dispatch(i + 1);
      });
    }
    return dispatch(0);
  };
}

class Koa {
  private context: KoaContext = Object.create(context);
  listen(port: number, cb: noop) {
    const fn = composeMiddleware(this.middlewares);
    const server = http.createServer(async (req, res) => {
      // 3、利用req、res創(chuàng)建context對象
      // 這里需要注意:context是創(chuàng)建一個(gè)新的對象,而不是直接賦值給this.context
      // 因?yàn)閏ontext適合請求相關(guān)聯(lián)的,這里也保證了每一個(gè)請求都是一個(gè)新的context對象
      const context = this.createContext(req, res);
      await fn(context);
      if (context.response && context.response.res) {
        context.response.res.writeHead(200);
        context.response.res.end(context.body);
      }
    });
    return server.listen(port, cb);
  }
  // 4、創(chuàng)建context對象
  createContext(req: IncomingMessage, res: ServerResponse): KoaContext {
    // 為什么要使用Object.create而不是直接賦值?
    // 原因同上需要保證每一次請求request、response、context都是全新的
    const request = Object.create(this.request);
    const response = Object.create(this.response);
    const context = Object.create(this.context);
    request.req = req;
    response.res = res;
    context.request = request;
    context.response = response;
    return context;
  }
}

第四步:異步函數(shù)錯(cuò)誤處理機(jī)制

目標(biāo):支持通過 app.on("error"),監(jiān)聽錯(cuò)誤事件處理異常

我們回憶下在Koa中如何處理異常,代碼可能類似如下:

app.use(async (context, next) => {
  console.log("middleware 2 start");
  // throw new Error("出錯(cuò)了");
  await next();
  console.log("middleware 2 end");
});

// koa統(tǒng)一錯(cuò)誤處理:監(jiān)聽error事件
app.on("error", (error, context) => {
  console.error(`請求${context.url}發(fā)生了錯(cuò)誤`);
});

從上面的代碼可以看到核心在于:

  • Koa實(shí)例app需要支持事件觸發(fā)、事件監(jiān)聽能力
  • 需要我們捕獲異步函數(shù)異常,并觸發(fā)error事件

下面我們看具體代碼如何實(shí)現(xiàn):

// 1、繼承EventEmitter,增加事件觸發(fā)、監(jiān)聽能力
class Koa extends EventEmitter {
  listen(port: number, cb: noop) {
    const fn = composeMiddleware(this.middlewares);
    const server = http.createServer(async (req, res) => {
      const context = this.createContext(req, res);
      // 2、await調(diào)用fn,可以使用try catch捕獲異常,觸發(fā)異常事件
      try {
        await fn(context);
        if (context.response && context.response.res) {
          context.response.res.writeHead(200);
          context.response.res.end(context.body);
        }
      } catch (error) {
        console.error("Server Error");
        // 3、觸發(fā)error時(shí)提供context更多信息,方面日志記錄,定位問題
        this.emit("error", error, context);
      }
    });
    return server.listen(port, cb);
  }
}

總結(jié)

至此我們已經(jīng)使用TypeScript完成簡版Koa類庫,支持了

  • 洋蔥圈中間件機(jī)制
  • Context封裝request、response
  • 異步異常錯(cuò)誤處理機(jī)制

完整Demo代碼可以參考koa2-reference

更多精彩文章,歡迎大家Star我們的倉庫,我們每周都會(huì)推出幾篇高質(zhì)量的大前端領(lǐng)域相關(guān)文章。

參考資料

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,546評論 6 533
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,570評論 3 418
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,505評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,017評論 1 313
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,786評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,219評論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,287評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,438評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,971評論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,796評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,995評論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,540評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,230評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,918評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,697評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內(nèi)容

  • 本節(jié)將結(jié)合例子和源碼對koa2的中間件機(jī)制做一介紹。 什么是中間件? 中間件的本質(zhì)就是一種在特定場景下使用的函數(shù),...
    空無一碼閱讀 1,456評論 0 2
  • 參考資料 https://chenshenhai.github.io/koa2-note/note/static/...
    JunChow520閱讀 10,505評論 1 8
  • 弄懂js異步 講異步之前,我們必須掌握一個(gè)基礎(chǔ)知識(shí)-event-loop。 我們知道JavaScript的一大特點(diǎn)...
    DCbryant閱讀 2,744評論 0 5
  • 看到標(biāo)題,也許您會(huì)覺得奇怪,redux跟Koa以及Express并不是同一類別的框架,干嘛要拿來做類比。盡管,例如...
    Perkin_閱讀 1,740評論 0 4
  • 陸陸續(xù)續(xù)用了koa和co也算差不多用了大半年了,大部分的場景都是在服務(wù)端使用koa來作為restful服務(wù)器用,使...
    Sunil閱讀 1,559評論 0 3