Node.js精進(5)——HTTP

2022-06-27 09:01:12

  HTTP(HyperText Transfer Protocol)即超文字傳輸協定,是一種獲取網路資源(例如影象、HTML檔案)的應用層協定,它是網際網路資料通訊的基礎,由請求和響應構成。

  在 Node.js 中,提供了 3 個與之相關的模組,分別是 HTTP、HTTP2 和 HTTPS,後兩者分別是對 HTTP/2.0 和 HTTPS 兩個協定的實現。

  HTTP/2.0 是 HTTP/1.1 的擴充套件版本,主要基於 Google 釋出的 SPDY 協定,引入了全新的二進位制分幀層,保留了 1.1 版本的大部分語意。

  HTTPS(HTTP Secure)是一種構建在SSL或TLS上的HTTP協定,簡單的說,HTTPS就是HTTP的安全版本。

  本節主要分析的是 HTTP 模組,它是 Node.js 網路的關鍵模組。

  本系列所有的範例原始碼都已上傳至Github,點選此處獲取。

一、搭建 Web 伺服器

  Web 伺服器是一種讓網路使用者可以存取託管檔案的軟體,常用的有 IIS、Nginx 等。

  Node.js 與 ASP.NET、PHP 等不同,它不需要額外安裝 Web 伺服器,因為通過它自身包含的模組就能快速搭建出 Web 伺服器。

  執行下面的程式碼,在瀏覽器位址列中輸入 http://localhost:1234 就能存取一張純文字內容的網頁。

const http = require('http');
const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('strick');
})
server.listen(1234);

  res.end() 在流一節中已分析過,用於關閉寫入流。

1)createServer()

  createServer() 用於建立一個 Web 伺服器,原始碼存於lib/http.js檔案中,內部就一行程式碼,範例化一個 Server 類。

function createServer(opts, requestListener) {
  return new Server(opts, requestListener);
}

  Server 類的實現存於lib/_http_server.js檔案中,由原始碼可知,http.Server 繼承自 net.Server,而 net 模組可建立基於流的 TCP 和 IPC 伺服器。

  http.createServer() 在範例化 net.Server 的過程中,會監聽 request 和 connection 兩個事件。

function Server(options, requestListener) {
  if (!(this instanceof Server)) return new Server(options, requestListener);
  // 當 createServer() 第一個引數型別是函數時的處理(上面範例中的用法)
  if (typeof options === 'function') {
    requestListener = options;
    options = {};
  } else if (options == null || typeof options === 'object') {
    options = { ...options };
  } else {
    throw new ERR_INVALID_ARG_TYPE('options', 'object', options);
  }
  storeHTTPOptions.call(this, options);
  // 繼承於 net.Server 類
  net.Server.call(
    this,
    { allowHalfOpen: true, noDelay: options.noDelay,
      keepAlive: options.keepAlive,
      keepAliveInitialDelay: options.keepAliveInitialDelay });

  if (requestListener) {
      // 當 req 和 res 兩個引數都生成後,就會觸發該事件
    this.on('request', requestListener);
  }

  // 官方註釋:與此類似的選項,懶得寫自己的檔案
  // http://www.squid-cache.org/Doc/config/half_closed_clients/
  // https://wiki.squid-cache.org/SquidFaq/InnerWorkings#What_is_a_half-closed_filedescriptor.3F
  this.httpAllowHalfOpen = false;
  // 三次握手後觸發 connection 事件
  this.on('connection', connectionListener);

  this.timeout = 0;                 // 超時時間,預設禁用
  this.maxHeadersCount = null;      // 最大響應頭數,預設不限制
  this.maxRequestsPerSocket = 0;
  setupConnectionsTracking(this);
}

2)listen()

  listen() 方法用於監聽埠,它就是 net.Server 中的 server.listen() 方法。

ObjectSetPrototypeOf(Server.prototype, net.Server.prototype);

3)req 和 res

  範例化 Server 時的 requestListener() 回撥函數中有兩個引數 req(請求物件) 和 res(響應物件),它們的生成過程比較複雜。

  簡單概括就是通過 TCP 協定傳輸過來的二進位制資料,會被 http_parser 模組解析成符合 HTTP 協定的報文格式。

  在將請求首部解析完畢後,會觸發一個 parserOnHeadersComplete() 回撥函數,在回撥中會建立 http.IncomingMessage 範例,也就是 req 引數。

  而在這個回撥的最後,會呼叫 parser.onIncoming() 方法,在這個方法中會建立 http.ServerResponse 範例,也就是 res 引數。

  最後觸發在範例化 Server 時註冊的 request 事件,並將 req 和 res 兩個引數傳遞到 requestListener() 回撥函數中。

  生成過程的順序如下所示,原始碼細節在此不做展開。

lib/_http_server.js : connectionListener()
lib/_http_server.js : connectionListenerInternal()

lib/_http_common.js : parsers = new FreeList('parsers', 1000, function parsersCb() {})
lib/_http_common.js : parserOnHeadersComplete() => parser.onIncoming()

lib/_http_server.js : parserOnIncoming() => server.emit('request', req, res)

  在上述過程中,parsers 變數使用了FreeList資料結構(如下所示),一種動態分配記憶體的方案,適合由大小相同的物件組成的記憶體池。

class FreeList {
  constructor(name, max, ctor) {
    this.name = name;
    this.ctor = ctor;
    this.max = max;
    this.list = [];
  }
  alloc() {
    return this.list.length > 0 ?
      this.list.pop() :
      ReflectApply(this.ctor, this, arguments);  // 執行回撥函數
  }
  free(obj) {
    if (this.list.length < this.max) {
      this.list.push(obj);
      return true;
    }
    return false;
  }
}

  parsers 維護了一個固定長度(1000)的佇列(記憶體池),佇列中的元素都是範例化的 HTTPParser。

  當 Node.js 接收到一個請求時,就從佇列中索取一個 HTTPParser 範例,即呼叫 parsers.alloc()。

  解析完報文後並沒有將其馬上釋放,如果佇列還沒滿就將其壓入其中,即呼叫 parsers.free(parser)。

  如此便實現了 parser 範例的反覆利用,當並行量很高時,就能大大減少範例化所帶來的效能損耗。

二、通訊

  Node.js 提供了request()方法顯式地發起 HTTP 請求,著名的第三方庫axios的伺服器端版本就是基於 request() 方法封裝的。

1)GET 和 POST

  GET 和 POST 是兩個最常用的請求方法,主要區別包含4個方面:

  • 語意不同,GET是獲取資料,POST是提交資料。
  • HTTP協定規定GET比POST安全,因為GET只做讀取,不會改變伺服器中的資料。但這只是規範,並不能保證請求方法的實現也是安全的。
  •  GET請求會把附加引數帶在URL上,而POST請求會把提交資料放在報文內。在瀏覽器中,URL長度會被限制,所以GET請求能傳遞的資料有限,但HTTP協定其實並沒有對其做限制,都是瀏覽器在控制。
  • HTTP協定規定GET是冪等的,而POST不是,所謂冪等是指多次請求返回的相同結果。實際應用中,並不會這麼嚴格,當GET獲取動態資料時,每次的結果可能會有所不同。

  在下面的例子中,發起了一次 GET 請求,存取上一小節中建立的 Server,options 引數中包含域名、埠、路徑、請求方法。

const http = require('http');
const options = {
  hostname: 'localhost',
  port: 1234,
  path: '/test?name=freedom',
  method: 'GET'
};
const req = http.request(options, res => {
  console.log(res.statusCode);
  res.on('data', d => {
    console.log(d.toString());   // strick
  });
});
req.end();

  res 和 req 都是可寫流,res 註冊了 data 事件接收資料,而在請求的最後,必須手動關閉 req 可寫流。

  POST 請求的構造要稍微複雜點,在 options 引數中,會新增請求首部,下面增加了內容的MIME型別和內容長度。

  req.write() 方法可傳送一塊請求內容,如果沒有設定 Content-Length,則資料將自動使用 HTTP 分塊傳輸進行編碼,以便伺服器知道資料何時結束。 Transfer-Encoding: chunked 檔頭會被新增。

const http = require('http');
const data = JSON.stringify({
  name: 'freedom'
});
const options = {
  hostname: 'localhost',
  port: 1234,
  path: '/test',
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Content-Length': data.length
  }
};
const req = http.request(options, res => {
  console.log(res.statusCode);
  res.on('data', d => {
    console.log(d.toString());   // strick
  });
});
req.write(data);
req.end();

  在 Server 中,若要接收請求的引數,需要做些處理。

  GET 請求比較簡單,讀取 req.url 屬性,解析 url 中的引數就能得到請求引數。

  POST 請求就需要註冊 data 事件,下面程式碼中只考慮了最簡單的場景,直接獲取然後字串格式化。

const server = http.createServer((req, res) => {
  console.log(req.url);          // /test?name=freedom
  req.on('data', d => {
    console.log(d.toString());   // {"name":"freedom"}
  });
})

  在 KOA 的外掛中有一款koa-bodyparser,基於co-body庫,可解析 POST 請求的資料,將結果附加到 ctx.request.body 屬性中。

  而 co-body 依賴了raw-body庫,它能將多塊二進位制資料流組合成一塊整體,剛剛的請求資料可以像下面這樣接收。

const getRawBody = require('raw-body');
const server = http.createServer((req, res) => {
  getRawBody(req).then(function (buf) {
    // <Buffer 7b 22 6e 61 6d 65 22 3a 22 66 72 65 65 64 6f 6d 22 7d>
    console.log(buf);
  });
})

2)路由

  在開發實際的 Node.js 專案時,路由是必不可少的。

  下面是一個極簡的路由演示,先範例化URL類,再讀取路徑名稱,最後根據 if-else 語句返回響應。

const server = http.createServer((req, res) => {
  // 範例化 URL 類
  const url = new URL(req.url, 'http://localhost:1234');
  const { pathname } = url;
  // 簡易路由
  if(pathname === '/') {
    res.end('main');
  }else if(pathname === '/test') {
    res.end('test');
  }
});

  上述寫法,不能應用於實際專案中,無論是在維護性,還是可讀性方面都欠妥。下面通過一個開源庫,來簡單瞭解下路由系統的執行原理。

  在 KOA 的外掛中,有一個專門用於路由的koa-router(如下所示),先範例化 Router 類,然後註冊一個路由,再掛載路由中介軟體。

var Koa = require('koa');
var Router = require('koa-router');

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

router.get('/', (ctx, next) => {
  // ctx.router available
});

app.use(router.routes()).use(router.allowedMethods());

  Router() 建構函式中僅僅是初始化一些變數,在註冊路由時會呼叫 register() 方法,將路徑和回撥函數繫結。

methods.forEach(function (method) {
  Router.prototype[method] = function (name, path, middleware) {
    var middleware;
    if (typeof path === 'string' || path instanceof RegExp) {
      middleware = Array.prototype.slice.call(arguments, 2);
    } else {
      middleware = Array.prototype.slice.call(arguments, 1);
      path = name;
      name = null;
    }
    this.register(path, [method], middleware, {
      name: name
    });
    return this;
  };
});

  在 register() 函數中,會將範例化一個 Layer 類,就是一個路由範例,並加到內部的陣列中,下面是刪減過的原始碼。

Router.prototype.register = function (path, methods, middleware, opts) {
  opts = opts || {};
  // 路由陣列
  var stack = this.stack;
  // 範例化路由
  var route = new Layer(path, methods, middleware, {
    end: opts.end === false ? opts.end : true,
    name: opts.name,
    sensitive: opts.sensitive || this.opts.sensitive || false,
    strict: opts.strict || this.opts.strict || false,
    prefix: opts.prefix || this.opts.prefix || "",
    ignoreCaptures: opts.ignoreCaptures
  });
  // add parameter middleware
  Object.keys(this.params).forEach(function (param) {
    route.param(param, this.params[param]);
  }, this);
  // 加到陣列中
  stack.push(route);
  return route;
};

  在註冊中介軟體時,首先會呼叫 router.routes() 方法,在該方法中會執行匹配到的路由(路徑和請求方法相同)的回撥。

  其中 layerChain 是一個陣列,它會先新增一個處理陣列的回撥函數,再合併一個或多個路由回撥(一條路徑可以宣告多個回撥),

  在處理完匹配路由的所有回撥函數後,再去執行下一個中介軟體。

Router.prototype.routes = Router.prototype.middleware = function () {
  var router = this;
  var dispatch = function dispatch(ctx, next) {
    var path = router.opts.routerPath || ctx.routerPath || ctx.path;
    /**
     * 找出所有匹配的路由,可能宣告了相同路徑和請求方法的路由
     * matched = {
     *   path: [],            路徑匹配
     *   pathAndMethod: [],   路徑和方法匹配
     *   route: false         路由是否匹配
     * }
     */
    var matched = router.match(path, ctx.method);
    var layerChain, layer, i;

    if (ctx.matched) {
      ctx.matched.push.apply(ctx.matched, matched.path);
    } else {
      ctx.matched = matched.path;
    }
    // 將 router 掛載到 ctx 上,供其他中介軟體使用
    ctx.router = router;
    // 沒有匹配的路由,就執行下一個中介軟體
    if (!matched.route) return next();

    var matchedLayers = matched.pathAndMethod   // 路徑和請求方法都匹配的陣列
    // 最後一個 matchedLayer
    var mostSpecificLayer = matchedLayers[matchedLayers.length - 1]
    ctx._matchedRoute = mostSpecificLayer.path;
    if (mostSpecificLayer.name) {
      ctx._matchedRouteName = mostSpecificLayer.name;
    }
    /**
     * layerChain 是一個陣列,先新增一個處理陣列的回撥函數,再合併一個或多個路由回撥
     * 目的是在執行路由回撥之前,將請求引數掛載到 ctx.params 上
     */
    layerChain = matchedLayers.reduce(function(memo, layer) {
      memo.push(function(ctx, next) {
        // 正則匹配的捕獲陣列
        ctx.captures = layer.captures(path, ctx.captures);
        // 請求引數物件,key 是引數名,value 是引數值
        ctx.params = layer.params(path, ctx.captures, ctx.params);
        ctx.routerName = layer.name;
        return next();
      });
      // 註冊路由時的回撥,stack 有可能是陣列
      return memo.concat(layer.stack);
    }, []);
    // 在處理完匹配路由的所有回撥函數後,執行下一個中介軟體
    return compose(layerChain)(ctx, next);
  };
  dispatch.router = this;
  return dispatch;
};

  另一個 router.allowedMethods() 會對異常行為做統一的預設處理,例如不支援的請求方法,不存在的狀態碼等。

 

 

參考資料:

餓了麼網路面試題

深入理解Node.js原始碼之HTTP

官網HTTP

Node HTTP Server 原始碼解讀

node http server原始碼解析

Node 原始碼 —— http 模組

通過原始碼解析 Node.js 中一個 HTTP 請求到響應的歷程

koa-router原始碼解析

koa-router原始碼解讀