手寫@koa/router源碼

上一篇文章我們講了Koa的基本架構,可以看到Koa的基本架構只有中間件內核,並沒有其他功能,路由功能也沒有。要實現路由功能我們必須引入第三方中間件,本文要講的路由中間件是@koa/router,這個中間件是掛在Koa官方名下的,他跟另一個中間件koa-router名字很像。其實@koa/routerforkkoa-router,因為koa-router的作者很多年沒維護了,所以Koa官方將它fork到了自己名下進行維護。這篇文章我們還是老套路,先寫一個@koa/router的簡單例子,然後自己手寫@koa/router源碼來替換他。

本文可運行程式碼已經上傳GitHun,拿下來一邊玩程式碼,一邊看文章效果更佳://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaRouter

簡單例子

我們這裡的例子還是使用之前Express文章中的例子

  1. 訪問跟路由返回Hello World
  2. get /api/users返回一個用戶列表,數據是隨便造的
  3. post /api/users寫入一個用戶資訊,用一個文件來模擬資料庫

這個例子之前寫過幾次了,用@koa/router寫出來就是這個樣子:

const fs = require("fs");
const path = require("path");
const Koa = require("koa");
const Router = require("@koa/router");
const bodyParser = require("koa-bodyparser");

const app = new Koa();
const router = new Router();

app.use(bodyParser());

router.get("/", (ctx) => {
  ctx.body = "Hello World";
});

router.get("/api/users", (ctx) => {
  const resData = [
    {
      id: 1,
      name: "小明",
      age: 18,
    },
    {
      id: 2,
      name: "小紅",
      age: 19,
    },
  ];

  ctx.body = resData;
});

router.post("/api/users", async (ctx) => {
  // 使用了koa-bodyparser才能從ctx.request拿到body
  const postData = ctx.request.body;

  // 使用fs.promises模組下的方法,返回值是promises
  await fs.promises.appendFile(
    path.join(__dirname, "db.txt"),
    JSON.stringify(postData)
  );

  ctx.body = postData;
});

app.use(router.routes());

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

上述程式碼中需要注意,Koa主要提倡的是promise的用法,所以如果像之前那樣使用回調方法可能會導致返回Not Found。比如在post /api/users這個路由中,我們會去寫文件,如果我們還是像之前Express那樣使用回調函數:

fs.appendFile(path.join(__dirname, "db.txt"), postData, () => {
  ctx.body = postData;
});

這會導致這個路由的處理方法並不知道這裡需要執行回調,而是直接將外層函數執行完就結束了。而外層函數執行完並沒有設置ctx的返回值,所以Koa會默認返回一個Not Found。為了避免這種情況,我們需要讓外層函數等待這裡執行完,所以我們這裡使用fs.promises下面的方法,這下面的方法都會返回promise,我們就可以使用await來等待返回結果了。

手寫源碼

本文手寫源碼全部參照官方源碼寫成,方法名和變數名儘可能與官方程式碼保持一致,大家可以對照著看,寫到具體方法時我也會貼上官方源碼地址。手寫源碼前我們先來看看有哪些API是我們需要解決的:

  1. Router類:我們從@koa/router引入的就是這個類,通過new關鍵字生成一個實例router,後續使用的方法都掛載在這個實例下面。
  2. router.getrouter.postrouter的實例方法getpost是我們定義路由的方法。
  3. router.routes:這個實例方法的返回值是作為中間件傳給app.use的,所以這個方法很可能是生成具體的中間件給Koa調用。

@koa/router的這種使用方法跟我們之前看過的Express.js的路由模組有點像,如果之前看過Express.js源碼解析的,看本文應該會有種似曾相識的感覺。

先看看路由架構

Express.js源碼解析裡面我講過他的路由架構,本文講的@koa/router的架構跟他有很多相似之處,但是也有一些改進。在進一步深入@koa/router源碼前,我們先來回顧下Express.js的路由架構,這樣我們可以有一個整體的認識,可以更好的理解後面的源碼。對於我們上面這個例子來說,他有兩個API:

  1. get /api/users
  2. post /api/users

這兩個API的path是一樣的,都是/api/users,但是他們的method不一樣,一個是get,一個是postExpress裡面將path這一層提取出來單獨作為了一個類—-Layer。一個Layer對應一個path,但是同一個path可能對應多個method。所以Layer上還添加了一個屬性routeroute上也存了一個數組,數組的每個項存了對應的method和回調函數handle。所以整個結構就是這個樣子:

const router = {
  stack: [
    // 裡面很多layer
    {
      path: '/api/users'
      route: {
      	stack: [
          // 裡面存了多個method和回調函數
          {
            method: 'get',
            handle: function1
          },
          {
            method: 'post',
            handle: function2
          }
        ]
    	}
    }
  ]
}

整個路由的執行分為了兩部分:註冊路由匹配路由

註冊路由就是構造上面這樣一個結構,主要是通過請求動詞對應的方法來實現,比如運行router.get('/api/users', function1)其實就會往router上添加一個layer,這個layerpath/api/users,同時還會在layer.route的數組上添加一個項:

{
  method: 'get',
  handle: function1
}

匹配路由就是當一個請求來了我們就去遍歷router上的所有layer,找出path匹配的layer,再找出layermethod匹配的route,然後將對應的回調函數handle拿出來執行。

@koa/router有著類似的架構,他的程式碼就是在實現這種架構,先帶著這種架構思維,我們可以很容易讀懂他的程式碼。

Router類

首先肯定是Router類,他的構造函數也比較簡單,只需要初始化幾個屬性就行。由於@koa/router模組大量使用了面向對象的思想,如果你對JS的面向對象還不熟悉,可以先看看這篇文章。

module.exports = Router;

function Router() {
  // 支援無new直接調用
  if (!(this instanceof Router)) return new Router();

  this.stack = []; // 變數名字都跟Express.js的路由模組一樣
}

上面程式碼有一行比較有意思

if (!(this instanceof Router)) return new Router();

這種使用方法我在其他文章也提到過:支援無new調用。我們知道要實例化一個類,一般要使用new關鍵字,比如new Router()。但是如果Router構造函數加了這行程式碼,就可以支援無new調用了,直接Router()可以達到同樣的效果。這是因為如果你直接Router()調用,this instanceof Router返回為false,會走到這個if裡面去,構造函數會幫你調用一下new Router()

所以這個構造函數的主要作用就是初始化了一個屬性stack,嗯,這個屬性名字都跟Express.js路由模組一樣。前面的架構已經說了,這個屬性就是用來存放layer的。

Router構造函數官方源碼://github.com/koajs/router/blob/master/lib/router.js#L50

請求動詞函數

前面架構講了,作為一個路由模組,我們主要解決兩個問題:註冊路由匹配路由

先來看看註冊路由,註冊路由主要是在請求動詞函數裡面進行的,比如router.getrouter.post這種函數。HTTP動詞有很多,有一個庫專門維護了這些動詞:methods@koa/router也是用的這個庫,我們這裡就簡化下,直接一個將getpost放到一個數組裡面吧。

// HTTP動詞函數
const methods = ["get", "post"];
for (let i = 0; i < methods.length; i++) {
  const method = methods[i];

  Router.prototype[method] = function (path, middleware) {
    // 將middleware轉化為一個數組,支援傳入多個回調函數
    middleware = Array.prototype.slice.call(arguments, 1);

    this.register(path, [method], middleware);

    return this;
  };
}

上面程式碼直接循環methods數組,將裡面的每個值都添加到Router.prototype上成為一個實例方法。這個方法接收pathmiddleware兩個參數,這裡的middleware其實就是我們路由的回調函數,因為程式碼是取的arguments第二個開始到最後所有的參數,所以其實他是支援同時傳多個回調函數的。另外官方源碼其實是三個參數,還有可選參數name,因為是可選的,跟核心邏輯無關,我這裡直接去掉了。

還需要注意這個實例方法最後返回了this,這種操作我們在Koa源碼裡面也見過,目的是讓用戶可以連續點點點,比如這樣:

router.get().post();

這些實例方法最後其實都是調this.register()去註冊路由的,下面我們看看他是怎麼寫的。

請求動詞函數官方源碼://github.com/koajs/router/blob/master/lib/router.js#L189

router.register()

router.register()實例方法是真正註冊路由的方法,結合前面架構講的,註冊路由就是構建layer的數據結構可知,router.register()的主要作用就是構建這個數據結構:

Router.prototype.register = function (path, methods, middleware) {
  const stack = this.stack;

  const route = new Layer(path, methods, middleware);

  stack.push(route);

  return route;
};

程式碼跟預期的一樣,就是用pathmethodmiddleware來創建一個layer實例,然後把它塞到stack數組裡面去。

router.register官方源碼://github.com/koajs/router/blob/master/lib/router.js#L553

Layer類

上面程式碼出現了Layer這個類,我們來看看他的構造函數吧:

const { pathToRegexp } = require("path-to-regexp");

module.exports = Layer;

function Layer(path, methods, middleware) {
  // 初始化methods和stack屬性
  this.methods = [];
  // 注意這裡的stack存放的是我們傳入的回調函數
  this.stack = Array.isArray(middleware) ? middleware : [middleware];

  // 將參數methods一個一個塞進this.methods裡面去
  for (let i = 0; i < methods.length; i++) {
    this.methods.push(methods[i].toUpperCase());    // ctx.method是大寫,注意這裡轉換為大寫
  }

  // 保存path屬性
  this.path = path;
  // 使用path-to-regexp庫將path轉化為正則
  this.regexp = pathToRegexp(path);
}

Layer的構造函數可以看出,他的架構跟Express.js路由模組已經有點區別了。Express.jsLayer上還有Route這個概念。而@koa/routerstack上存的直接是回調函數了,已經沒有route這一層了。我個人覺得這種層級結構是比Express的要清晰的,因為Expressroute.stack裡面存的又是layer,這種相互引用是有點繞的,這點我在Express源碼解析中也提出過

另外我們看到他也用到了path-to-regexp這個庫,這個庫我在很多處理路由的庫裡面都見到過,比如React-RouterExpress,真想去看看他的源碼,加到我的待寫文章列表裡面去,空了去看看~

Layer構造函數官方源碼://github.com/koajs/router/blob/master/lib/layer.js#L20

router.routes()

前面架構提到的還有件事情需要做,那就是路由匹配

對於Koa來說,一個請求來了會依次經過每個中間件,所以我們的路由匹配其實也是在中間件裡面做的。而@koa/router的中間件是通過router.routes()返回的。所以router.routes()主要做兩件事:

  1. 他應該返回一個Koa中間件,以便Koa調用
  2. 這個中間件的主要工作是遍歷router上的layer,找到匹配的路由,並拿出來執行。
Router.prototype.routes = function () {
  const router = this;

  // 這個dispatch就是我們要返回給Koa調用的中間件
  let dispatch = function dispatch(ctx, next) {
    const path = ctx.path;
    const matched = router.match(path, ctx.method); // 獲取所有匹配的layer

    let layerChain; // 定義一個變數來串聯所有匹配的layer

    ctx.router = router; // 順手把router掛到ctx上,給其他Koa中間件使用

    if (!matched.route) return next(); // 如果一個layer都沒匹配上,直接返回,並執行下一個Koa中間件

    const matchedLayers = matched.pathAndMethod; // 獲取所有path和method都匹配的layer
    // 下面這段程式碼的作用是將所有layer上的stack,也就是layer的回調函數都合併到一個數組layerChain裡面去
    layerChain = matchedLayers.reduce(function (memo, layer) {
      return memo.concat(layer.stack);
    }, []);

    // 這裡的compose也是koa-compose這個庫,源碼在講Koa源碼的時候講過
    // 使用compose將layerChain數組合併成一個可執行的方法,並拿來執行,傳入參數是Koa中間件參數ctx, next
    return compose(layerChain)(ctx, next);
  };

  // 將中間件返回
  return dispatch;
};

上述程式碼中主體返回的是一個Koa中間件,這個中間件裡面先是通過router.match方法將所有匹配的layer拿出來,然後將這些layer對應的回調函數通過reduce放到一個數組裡面,也就是layerChain。然後用koa-compose將這個數組合併成一個可執行方法,這裡就有問題了。之前在Koa源碼解析我講過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);
      }
    }
  };
}

這段程式碼裡面fn是我們傳入的中間件,在@koa/router這裡對應的其實是layerChain裡面的一項,執行fn的時候是這樣的:

fn(context, dispatch.bind(null, i + 1))

這裡傳的參數符合我們使用@koa/router的習慣,我們使用@koa/router一般是這樣的:

router.get("/", (ctx, next) => {
  ctx.body = "Hello World";
});

上面的fn就是我們傳的回調函數,注意我們執行fn時傳入的第二個參數dispatch.bind(null, i + 1),也就是router.get這裡的next。所以我們上面回調函數裡面再執行下next

router.get("/", (ctx, next) => {
  ctx.body = "Hello World";
  next();    // 注意這裡
});

這個回調裡面執行next()其實就是把koa-compose裡面的dispatch.bind(null, i + 1)拿出來執行,也就是dispatch(i + 1),對應的就是執行layerChain裡面的下一個函數。在這個例子裡面並沒有什麼用,因為匹配的回調函數只有一個。但是如果/這個路徑匹配了多個回調函數,比如這樣:

router.get("/", (ctx, next) => {
  console.log("123");
});

router.get("/", (ctx, next) => {
  ctx.body = "Hello World";
});

這裡/就匹配了兩個回調函數,但是你如果這麼寫,你會得到一個Not Found。為什麼呢?因為你第一個回調裡面沒有調用next()!前面說了,這裡的next()dispatch(i + 1),會去調用layerChain裡面的下一個回調函數,換一句話說,你這裡不調next()就不會運行下一個回調函數了!要想讓/返回Hello World,我們需要在第一個回調函數裡面調用next,像這樣:

router.get("/", (ctx, next) => {
  console.log("123");
  next();     // 記得調用next
});

router.get("/", (ctx, next) => {
  ctx.body = "Hello World";
});

所以有朋友覺得@koa/router回調函數裡面的next沒什麼用,如果你一個路由只有一個匹配的回調函數,那確實沒什麼用,但是如果你一個路徑可能匹配多個回調函數,記得調用next

router.routes官方源碼://github.com/koajs/router/blob/master/lib/router.js#L335

router.match()

上面router.routes的源碼裡面我們用到了router.match這個實例方法來查找所有匹配的layer,上面是這麼用的:

const matched = router.match(path, ctx.method);

所以我們也需要寫一下這個函數,這個函數不複雜,通過傳入的pathmethodrouter.stack上找到所有匹配的layer就行:

Router.prototype.match = function (path, method) {
  const layers = this.stack; // 取出所有layer

  let layer;
  // 構建一個結構來保存匹配結果,最後返回的也是這個matched
  const matched = {
    path: [], // path保存僅僅path匹配的layer
    pathAndMethod: [], // pathAndMethod保存path和method都匹配的layer
    route: false, // 只要有一個path和method都匹配的layer,就說明這個路由是匹配上的,這個變數置為true
  };

  // 循環layers來進行匹配
  for (let i = 0; i < layers.length; i++) {
    layer = layers[i];
    // 匹配的時候調用的是layer的實例方法match
    if (layer.match(path)) {
      matched.path.push(layer); // 只要path匹配就先放到matched.path上去

      // 如果method也有匹配的,將layer放到pathAndMethod裡面去
      if (~layer.methods.indexOf(method)) {
        matched.pathAndMethod.push(layer);
        if (layer.methods.length) matched.route = true;
      }
    }
  }

  return matched;
};

上面程式碼只是循環了所有的layer,然後將匹配的layer放到一個對象matched裡面並返回給外面調用,match.path保存了所有path匹配,但是method並不一定匹配的layer,本文並沒有用到這個變數。具體匹配path其實還是調用的layer的實例方法layer.match,我們後面會來看看。

這段程式碼還有個有意思的點是檢測layer.methods裡面是否包含method的時候,源碼是這樣寫的:

~layer.methods.indexOf(method)

而一般我們可能是這樣寫:

layer.methods.indexOf(method) > -1

這個源碼裡面的~是按位取反的意思,達到的效果與我們後面這種寫法其實是一樣的,因為:

~ -1;      // 返回0,也就是false
~ 0;       // 返回-1, 注意-1轉換為bool是true
~ 1;       // 返回-2,轉換為bool也是true

這種用法可以少寫幾個字母,又學會一招,大家具體使用的還是根據自己的情況來吧,選取喜歡的方式。

router.match官方源碼://github.com/koajs/router/blob/master/lib/router.js#L669

layer.match()

上面用到了layer.match這個方法,我們也來寫一下吧。因為我們在創建layer實例的時候,其實已經將path轉換為了一個正則,我們直接拿來用就行:

Layer.prototype.match = function (path) {
  return this.regexp.test(path);
};

layer.match官方源碼://github.com/koajs/router/blob/master/lib/layer.js#L54

總結

到這裡,我們自己的@koa/router就寫完了,使用他替換官方的源碼也能正常工作啦~

本文可運行程式碼已經上傳到GitHub,大家可以拿下來玩玩://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaRouter

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

  1. @koa/router整體是作為一個Koa中間件存在的。
  2. @koa/routerforkkoa-router繼續進行維護。
  3. @koa/router的整體思路跟Express.js路由模組很像。
  4. @koa/router也可以分為註冊路由匹配路由兩部分。
  5. 註冊路由主要是構建路由的數據結構,具體來說就是創建很多layer,每個layer上保存具體的pathmethods,和回調函數。
  6. @koa/router創建的數據結構跟Express.js路由模組有區別,少了route這個層級,但是個人覺得@koa/router的這種結構反而更清晰。Express.jslayerroute的相互引用反而更讓人疑惑。
  7. 匹配路由就是去遍歷所有的layer,找出匹配的layer,將回調方法拿來執行。
  8. 一個路由可能匹配多個layer和回調函數,執行時使用koa-compose將這些匹配的回調函數串起來,一個一個執行。
  9. 需要注意的是,如果一個路由匹配了多個回調函數,前面的回調函數必須調用next()才能繼續走到下一個回調函數。

參考資料

@koa/router官方文檔://github.com/koajs/router

@koa/router源碼地址://github.com/koajs/router/tree/master/lib

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

作者博文GitHub項目地址: //github.com/dennis-jiang/Front-End-Knowledges

作者掘金文章匯總://juejin.im/post/5e3ffc85518825494e2772fd

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