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 請求到響應的歷程