手寫Express.js原始碼

_蔣鵬飛發表於2020-11-02

上一篇文章我們講了怎麼用Node.js原生API來寫一個web伺服器,雖然程式碼比較醜,但是基本功能還是有的。但是一般我們不會直接用原生API來寫,而是藉助框架來做,比如本文要講的Express。通過上一篇文章的鋪墊,我們可以猜測,Express其實也沒有什麼黑魔法,也僅僅是原生API的封裝,主要是用來提供更好的擴充套件性,使用起來更方便,程式碼更優雅。本文照例會從Express的基本使用入手,然後自己手寫一個Express來替代他,也就是原始碼解析。

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

簡單示例

使用Express搭建一個最簡單的Hello World也是幾行程式碼就可以搞定,下面這個例子來源官方文件:

const express = require('express');
const app = express();
const port = 3000;

app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
});

可以看到Express的路由可以直接用app.get這種方法來處理,比我們之前在http.createServer裡面寫一堆if優雅多了。我們用這種方式來改寫下上一篇文章的程式碼:

const path = require("path");
const express = require("express");
const fs = require("fs");
const url = require("url");

const app = express();
const port = 3000;

app.get("/", (req, res) => {
  res.end("Hello World");
});

app.get("/api/users", (req, res) => {
  const resData = [
    {
      id: 1,
      name: "小明",
      age: 18,
    },
    {
      id: 2,
      name: "小紅",
      age: 19,
    },
  ];
  res.setHeader("Content-Type", "application/json");
  res.end(JSON.stringify(resData));
});

app.post("/api/users", (req, res) => {
  let postData = "";
  req.on("data", (chunk) => {
    postData = postData + chunk;
  });

  req.on("end", () => {
    // 資料傳完後往db.txt插入內容
    fs.appendFile(path.join(__dirname, "db.txt"), postData, () => {
      res.end(postData); // 資料寫完後將資料再次返回
    });
  });
});

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

Express還支援中介軟體,我們寫個中介軟體來列印出每次請求的路徑:

app.use((req, res, next) => {
  const urlObject = url.parse(req.url);
  const { pathname } = urlObject;

  console.log(`request path: ${pathname}`);

  next();
});

Express也支援靜態資源託管,不過他的API是需要指定一個資料夾來單獨存放靜態資源的,比如我們新建一個public資料夾來存放靜態資源,使用express.static中介軟體配置一下就行:

app.use(express.static(path.join(__dirname, 'public')));

然後就可以拿到靜態資源了:

image-20201007171251421

手寫原始碼

手寫原始碼才是本文的重點,前面的不過是鋪墊,本文手寫的目標就是自己寫一個express來替換前面用到的express api,其實就是原始碼解析。在開始之前,我們先來看看用到了哪些API

  1. express(),第一個肯定是express函式,這個執行後會返回一個app的例項,後面用的很多方法都是這個app上的。
  2. app.listen,這個方法類似於原生的server.listen,用來啟動伺服器。
  3. app.get,這是處理路由的API,類似的還有app.post等。
  4. app.use,這是中介軟體的呼叫入口,所有中介軟體都要通過這個方法來呼叫。
  5. express.static,這個中介軟體幫助我們做靜態資源託管,其實是另外一個庫了,叫serve-static,因為跟Express架構關係不大,本文就先不講他的原始碼了。

本文所有手寫程式碼全部參照官方原始碼寫成,方法名和變數名儘量與官方保持一致,大家可以對照著看,寫到具體的方法時我也會貼出官方原始碼的地址。

express()

首先需要寫的肯定是express(),這個方法是一切的開始,他會建立並返回一個app,這個app就是我們的web伺服器

// express.js
var mixin = require('merge-descriptors');
var proto = require('./application');

// 建立web伺服器的方法
function createApplication() {
  // 這個app方法其實就是傳給http.createServer的回撥函式
  var app = function (req, res) {

  };

  mixin(app, proto, false);

  return app;
}

exports = module.exports = createApplication;

上述程式碼就是我們在執行express()的時候執行的程式碼,其實就是個空殼,返回的app暫時是個空函式,真正的app並沒在這裡,而是在proto上,從上述程式碼可以看出proto其實就是application.js,然後通過下面這行程式碼將proto上的東西都賦值給了app

mixin(app, proto, false);

這行程式碼用到了一個第三方庫merge-descriptors,這個庫總共沒有幾行程式碼,做的事情也很簡單,就是將proto上面的屬性挨個賦值給app,對merge-descriptors原始碼感興趣的可以看這裡:https://github.com/component/merge-descriptors/blob/master/index.js

Express這裡之所以使用mixin,而不是普通的物件導向來繼承,是因為它除了要mixin proto外,還需要mixin其他庫,也就是需要多繼承,我這裡省略了,但是官方原始碼是有的。

express.js對應的原始碼看這裡:https://github.com/expressjs/express/blob/master/lib/express.js

app.listen

上面說了,express.js只是一個空殼,真正的appapplication.js裡面,所以app.listen也是在這裡。

// application.js

var app = exports = module.exports = {};

app.listen = function listen() {
  var server = http.createServer(this);
  return server.listen.apply(server, arguments);
};

上面程式碼就是呼叫原生http模組建立了一個伺服器,但是傳的引數是this,這裡的this是什麼呢?回想一下我們使用express的時候是這樣用的:

const app = express();

app.listen(3000);

所以listen方法的實際呼叫者是express()的返回值,也就是上面express.js裡面createApplication的返回值,也就是這個函式:

var app = function (req, res) {
};

所以這裡的this也是這個函式,所以我在express.js裡面就加了註釋,這個函式是http.createServer的回撥函式。現在這個函式是空的,實際上他應該是整個web伺服器的處理入口,所以我們給他加上處理的邏輯,在裡面再加一行程式碼:

var app = function(req, res) {
  app.handle(req, res);    // 這是真正的伺服器處理入口
};

app.handle

app.handle也是掛載在app下面的,所以他實際也在application.js這個檔案裡面,下面我們來看看他幹了什麼:

app.handle = function handle(req, res) {
  var router = this._router;

  // 最終的處理方法
  var done = finalhandler(req, res);

  // 如果沒有定義router
  // 直接結束返回
  if (!router) {
    done();
    return;
  }

  // 有router,就用router來處理
  router.handle(req, res, done);
}

上面程式碼可以看出,實際處理路由的是router,這是Router的一個例項,並且掛載在this上的,我們這裡還沒有給他賦值,如果沒有賦值的話,會直接執行finalhandler並且結束處理。finalhandler也是一個第三方庫,GitHub連結在這裡:https://github.com/pillarjs/finalhandler。這個庫的功能也不復雜,就是幫你處理一些收尾的工作,比如所有路由都沒匹配上,你可能需要返回404並記錄下error log,這個庫就可以幫你做。

app.get

上面說了,在具體處理網路請求時,實際上是用app._router來處理的,那麼app._router是在哪裡賦值的呢?事實上app._router的賦值有多個地方,一個地方就是HTTP動詞處理方法上,比如我們用到的app.get或者app.post。無論是app.get還是app.post都是呼叫的router方法來處理,所以可以統一用一個迴圈來寫這一類的方法。

// HTTP動詞的方法
var methods = ['get', 'post'];
methods.forEach(function (method) {
  app[method] = function (path) {
    this.lazyrouter();

    var route = this._router.route(path);
    route[method].apply(route, Array.prototype.slice.call(arguments, 1));
    return this;
  }
});

上面程式碼HTTP動詞都放到了一個陣列裡面,官方原始碼中這個陣列也是一個第三方庫維護的,名字就叫methods,GitHub地址在這裡:https://github.com/jshttp/methods。我這個例子因為只需要兩個動詞,就簡化了,直接用陣列了。這段程式碼其實給app建立了跟每個動詞同名的函式,所有動詞的處理函式都是一樣的,都是去調router裡面的對應方法來處理。這種將不同部分抽取出來,從而複用共同部分的程式碼,有點像我之前另一篇文章寫過的設計模式----享元模式

我們注意到上面程式碼除了呼叫router來處理路由外,還有一行程式碼:

this.lazyrouter();

lazyrouter方法其實就是我們給this._router賦值的地方,程式碼也比較簡單,就是檢測下有沒有_router,如果沒有就給他賦個值,賦的值就是Router的一個例項:

app.lazyrouter = function lazyrouter() {
  if (!this._router) {
    this._router = new Router();
  }
}

app.listenapp.handlemethods處理方法都在application.js裡面,application.js原始碼在這裡:https://github.com/expressjs/express/blob/master/lib/application.js

Router

寫到這裡我們發現我們已經使用了Router的多個API,比如:

  1. router.handle
  2. router.route
  3. route[method]

所以我們來看下Router這個類,下面的程式碼是從原始碼中簡化出來的:

// router/index.js
var setPrototypeOf = require('setprototypeof');

var proto = module.exports = function () {
  function router(req, res, next) {
    router.handle(req, res, next);
  }

  setPrototypeOf(router, proto);

  return router;
}

這段程式碼對我來說是比較奇怪的,我們在執行new Router()的時候其實執行的是new proto()new proto()並不是我奇怪的地方,奇怪的是他設定原型的方式。我之前在講JS的物件導向的文章提到過如果你要給一個類加上類方法可以這樣寫:

function Class() {}

Class.prototype.method1 = function() {}

var instance = new Class();

這樣instance.__proto__就會指向Class.prototype,你就可使用instance.method1了。

Express.js的上述程式碼其實也是實現了類似的效果,setprototypeof又是一個第三方庫,作用類似Object.setPrototypeOf(obj, prototype),就是給一個物件設定原型,setprototypeof存在的意義就是相容老標準的JS,也就是加了一些polyfill他的程式碼在這裡。所以:

setPrototypeOf(router, proto);

這行程式碼的意思就是讓router.__proto__指向protorouter是你在new proto()時的返回物件,執行了上面這行程式碼,這個router就可以拿到proto上的全部方法了。像router.handle這種方法就可以掛載到proto上了,成為proto.handle

繞了一大圈,其實就是JS物件導向的使用,給router新增類方法,但是為什麼使用這麼繞的方式,而不是像我上面那個Class那樣用呢?這我就不是很清楚了,可能有什麼歷史原因吧。

路由架構

Router的基本結構知道了,要理解Router的具體程式碼,我們還需要對Express的路由架構有一個整體的認識。就以我們這兩個示例API來說:

get /api/users

post /api/users

我們發現他們的path是一樣的,都是/api/users,但是他們的請求方法,也就是method不一樣。Express裡面將path這一層提取出來作為了一個類,叫做Layer。但是對於一個Layer,我們只知道他的path,不知道method的話,是不能確定一個路由的,所以Layer上還新增了一個屬性route,這個route上也存了一個陣列,陣列的每個項存了對應的method和回撥函式handle。整個結構你可以理解成這個樣子:

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

知道了這個結構我們可以猜到,整個流程可以分成兩部分:註冊路由匹配路由。當我們寫app.getapp.post這些方法時,其實就是在router上新增layerroute。當一個網路請求過來時,其實就是遍歷layerroute,找到對應的handle拿出來執行。

注意route陣列裡面的結構,每個項按理來說應該使用一種新的資料結構來儲存,比如routeItem之類的。但是Express並沒有這樣做,而是將它和layer合在一起了,給layer新增了methodhandle屬性。這在初次看原始碼的時候可能造成困惑,因為layer同時存在於routerstack上和routestack上,肩負了兩種職責。

router.route

這個方法是我們前面註冊路由的時候呼叫的一個方法,回顧下前面的註冊路由的方法,比如app.get

app.get = function (path) {
  this.lazyrouter();

  var route = this._router.route(path);
  route.get.apply(route, Array.prototype.slice.call(arguments, 1));
  return this;
}

結合上面講的路由架構,我們在註冊路由的時候,應該給router新增對應的layerrouterouter.route的程式碼就不難寫出了:

proto.route = function route(path) {
  var route = new Route();
  var layer = new Layer(path, route.dispatch.bind(route));     // 引數是path和回撥函式

  layer.route = route;

  this.stack.push(layer);

  return route;
}

Layer和Route建構函式

上面程式碼新建了RouteLayer例項,這兩個類的建構函式其實也挺簡單的。只是引數的申明和初始化:

// layer.js
module.exports = Layer;

function Layer(path, fn) {
  this.path = path;

  this.handle = fn;
  this.method = '';
}
// route.js
module.exports = Route;

function Route() {
  this.stack = [];
  this.methods = {};    // 一個加快查詢的hash表
}

route.get

前面我們看到了app.get其實通過下面這行程式碼,最終呼叫的是route.get

route.get.apply(route, Array.prototype.slice.call(arguments, 1));

也知道了route.get這種動詞處理函式,其實就是往route.stack上新增layer,那我們的route.get也可以寫出來了:

var methods = ["get", "post"];
methods.forEach(function (method) {
  Route.prototype[method] = function () {
    // 支援傳入多個回撥函式
    var handles = flatten(slice.call(arguments));

    // 為每個回撥新建一個layer,並加到stack上
    for (var i = 0; i < handles.length; i++) {
      var handle = handles[i];

      // 每個handle都應該是個函式
      if (typeof handle !== "function") {
        var type = toString.call(handle);
        var msg =
          "Route." +
          method +
          "() requires a callback function but got a " +
          type;
        throw new Error(msg);
      }

      // 注意這裡的層級是layer.route.layer
      // 前面第一個layer已經做個path的比較了,所以這裡是第二個layer,path可以直接設定為/
      var layer = new Layer("/", handle);
      layer.method = method;
      this.methods[method] = true; // 將methods對應的method設定為true,用於後面的快速查詢
      this.stack.push(layer);
    }
  };
});

這樣,其實整個router的結構就構建出來了,後面就看看怎麼用這個結構來處理請求了,也就是router.handle方法。

router.handle

前面說了app.handle實際上是呼叫的router.handle,也知道了router的結構是在stack上新增了layerrouter,所以router.handle需要做的就是從router.stack上找出對應的layerrouter並執行回撥函式:

// 真正處理路由的函式
proto.handle = function handle(req, res, done) {
  var self = this;
  var idx = 0;
  var stack = self.stack;

  // next方法來查詢對應的layer和回撥函式
  next();
  function next() {
    // 使用第三方庫parseUrl獲取path,如果沒有path,直接返回
    var path = parseUrl(req).pathname;
    if (path == null) {
      return done();
    }

    var layer;
    var match;
    var route;

    while (match !== true && idx < stack.length) {
      layer = stack[idx++]; // 注意這裡先執行 layer = stack[idx]; 再執行idx++;
      match = layer.match(path); // 呼叫layer.match來檢測當前路徑是否匹配
      route = layer.route;

      // 沒匹配上,跳出當次迴圈
      if (match !== true) {
        continue;
      }

      // layer匹配上了,但是沒有route,也跳出當次迴圈
      if (!route) {
        continue;
      }

      // 匹配上了,看看route上有沒有對應的method
      var method = req.method;
      var has_method = route._handles_method(method);
      // 如果沒有對應的method,其實也是沒匹配上,跳出當次迴圈
      if (!has_method) {
        match = false;
        continue;
      }
    }

    // 迴圈完了還沒有匹配的,就done了,其實就是404
    if (match !== true) {
      return done();
    }

    // 如果匹配上了,就執行對應的回撥函式
    return layer.handle_request(req, res, next);
  }
};

上面程式碼還用到了幾個LayerRoute的例項方法:

layer.match(path): 檢測當前layerpath是否匹配。

route._handles_method(method):檢測當前routemethod是否匹配。

layer.handle_request(req, res, next):使用layer的回撥函式來處理請求。

這幾個方法看起來並不複雜,我們後面一個一個來實現。

到這裡其實還有個疑問。從他整個的匹配流程來看,他尋找的其實是router.stack.layer這一層,但是最終應該執行的回撥卻是在router.stack.layer.route.stack.layer.handle。這是怎麼通過router.stack.layer找到最終的router.stack.layer.route.stack.layer.handle來執行的呢?

這要回到我們前面的router.route方法:

proto.route = function route(path) {
  var route = new Route();
  var layer = new Layer(path, route.dispatch.bind(route));

  layer.route = route;

  this.stack.push(layer);

  return route;
}

這裡我們new Layer的時候給的回撥其實是route.dispatch.bind(route),這個方法會再去route.stack上找到正確的layer來執行。所以router.handle真正的流程其實是:

  1. 找到path匹配的layer
  2. 拿出layer上的route,看看有沒有匹配的method
  3. layermethod都有匹配的,再呼叫route.dispatch去找出真正的回撥函式來執行。

所以又多了一個需要實現的函式,route.dispatch

layer.match

layer.match是用來檢測當前path是否匹配的函式,用到了一個第三方庫path-to-regexp,這個庫可以將path轉為正規表示式,方便後面的匹配,這個庫在之前寫過的react-router原始碼中也出現過。

var pathRegexp = require("path-to-regexp");

module.exports = Layer;

function Layer(path, fn) {
  this.path = path;

  this.handle = fn;
  this.method = "";

  // 新增一個匹配正則
  this.regexp = pathRegexp(path);
  // 快速匹配/
  this.regexp.fast_slash = path === "/";
}

然後就可以新增match例項方法了:

Layer.prototype.match = function match(path) {
  var match;

  if (path != null) {
    if (this.regexp.fast_slash) {
      return true;
    }

    match = this.regexp.exec(path);
  }

  // 沒匹配上,返回false
  if (!match) {
    return false;
  }

  // 不然返回true
  return true;
};

layer.handle_request

layer.handle_request是用來呼叫具體的回撥函式的方法,其實就是拿出layer.handle來執行:

Layer.prototype.handle_request = function handle(req, res, next) {
  var fn = this.handle;

  fn(req, res, next);
};

route._handles_method

route._handles_method就是檢測當前route是否包含需要的method,因為之前新增了一個methods物件,可以用它來進行快速查詢:

Route.prototype._handles_method = function _handles_method(method) {
  var name = method.toLowerCase();

  return Boolean(this.methods[name]);
};

route.dispatch

route.dispatch其實是router.stack.layer的回撥函式,作用是找到對應的router.stack.layer.route.stack.layer.handle並執行。

Route.prototype.dispatch = function dispatch(req, res, done) {
  var idx = 0;
  var stack = this.stack; // 注意這個stack是route.stack

  // 如果stack為空,直接done
  // 這裡的done其實是router.stack.layer的next
  // 也就是執行下一個router.stack.layer
  if (stack.length === 0) {
    return done();
  }

  var method = req.method.toLowerCase();

  // 這個next方法其實是在router.stack.layer.route.stack上尋找method匹配的layer
  // 找到了就執行layer的回撥函式
  next();
  function next() {
    var layer = stack[idx++];
    if (!layer) {
      return done();
    }

    if (layer.method && layer.method !== method) {
      return next();
    }

    layer.handle_request(req, res, next);
  }
};

到這裡其實Express整體的路由結構,註冊和執行流程都完成了,貼下對應的官方原始碼:

Router類https://github.com/expressjs/express/blob/master/lib/router/index.js

Layer類https://github.com/expressjs/express/blob/master/lib/router/layer.js

Route類https://github.com/expressjs/express/blob/master/lib/router/route.js

中介軟體

其實我們前面已經隱含了中介軟體,從前面的結構可以看出,一個網路請求過來,會到router的第一個layer,然後呼叫next到到第二個layer,匹配上layerpath就執行回撥,然後一直這樣把所有的layer都走完。所以中介軟體是啥?中介軟體就是一個layer,他的path預設是/,也就是對所有請求都生效。按照這個思路,程式碼就簡單了:

// application.js

// app.use就是呼叫router.use
app.use = function use(fn) {
  var path = "/";

  this.lazyrouter();
  var router = this._router;
  router.use(path, fn);
};

然後在router.use裡面再加一層layer就行了:

proto.use = function use(path, fn) {
  var layer = new Layer(path, fn);

  this.stack.push(layer);
};

總結

  1. Express也是用原生APIhttp.createServer來實現的。
  2. Express的主要工作是將http.createServer的回撥函式拆出來了,構建了一個路由結構Router
  3. 這個路由結構由很多層layer組成。
  4. 一箇中介軟體就是一個layer
  5. 路由也是一個layerlayer上有一個path屬性來表示他可以處理的API路徑。
  6. path可能有不同的method,每個method對應layer.route上的一個layer
  7. layer.route上的layer雖然名字和router上的layer一樣,但是功能側重點並不一樣,這也是原始碼中讓人困惑的一個點。
  8. layer.route上的layer的主要引數是methodhandle,如果method匹配了,就執行對應的handle
  9. 整個路由匹配過程其實就是遍歷router.layer的一個過程。
  10. 每個請求來了都會遍歷一遍所有的layer,匹配上就執行回撥,一個請求可能會匹配上多個layer
  11. 總體來看,Express程式碼給人的感覺並不是很完美,特別是Layer類肩負兩種職責,跟軟體工程強調的單一職責原則不符,這也導致RouterLayerRoute三個類的呼叫關係有點混亂。而且對於繼承和原型的使用都是很老的方式。可能也是這種不完美催生了Koa的誕生,下一篇文章我們就來看看Koa的原始碼吧。
  12. Express其實還對原生的reqres進行了擴充套件,讓他們變得更好用,但是這個其實只相當於一個語法糖,對整體架構沒有太大影響,所以本文就沒涉及了。

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

參考資料

Express官方文件:http://expressjs.com/

Express官方原始碼:https://github.com/expressjs/express/tree/master/lib

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

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

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

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

相關文章