http請求頭與響應頭的應用
本文要聊聊瀏覽器可愛的頭頭頭……們。

Chap1 發現headers
當我們隨便開啟一個網址(比如大家經常拿來測試網路的百度)時,開啟 Network
,會看到如下請求頭,響應頭:

究竟這些headers都有什麼用呢? 咱們挨個探個究竟。
Chap2 headers用途
2.1 Content-Type
Content-Type
表示請求頭或響應頭的內容型別。作為請求頭時,利用它可以進行body-parser。 Sooo~ What is body-parser
?
body-parser是node常用的中介軟體,其作用是:
Parse incoming request bodies in a middleware before your handlers, available under the req.body property.
即在處理資料之前用中介軟體對post請求體進行解析。body-parser的例子為:
下面的例子展示瞭如何給路由新增 body parser
。通常,這是在 express
中最為推薦的使用 body-parser
的方法。
var express = require('express') var bodyParser = require('body-parser') var app = express() // create application/json parser var jsonParser = bodyParser.json() // create application/x-www-form-urlencoded parser var urlencodedParser = bodyParser.urlencoded({ extended: false }) // POST /login gets urlencoded bodies app.post('/login', urlencodedParser, function (req, res) { if (!req.body) return res.sendStatus(400) res.send('welcome, ' + req.body.username) }) // POST /api/users gets JSON bodies app.post('/api/users', jsonParser, function (req, res) { if (!req.body) return res.sendStatus(400) // create user in req.body }) 複製程式碼
body-parser
核心原始碼為:
// this uses a switch for static require analysis switch (parserName) { case 'json': parser = require('./lib/types/json') break case 'raw': parser = require('./lib/types/raw') break case 'text': parser = require('./lib/types/text') break case 'urlencoded': parser = require('./lib/types/urlencoded') break } 複製程式碼
以 json
為例:
var contentType = require('content-type') //... /** * Get the charset of a request. * * @param {object} req * @api private */ function getCharset (req) { try { return (contentType.parse(req).parameters.charset || '').toLowerCase() } catch (e) { return undefined } } //... // assert charset per RFC 7159 sec 8.1 var charset = getCharset(req) || 'utf-8' if (charset.substr(0, 4) !== 'utf-') { debug('invalid charset') next(createError(415, 'unsupported charset "' + charset.toUpperCase() + '"', { charset: charset, type: 'charset.unsupported' })) return } 複製程式碼
可以看出:其背後工作原理就是通過分析請求頭中的 Content-Type
的型別,根據不同的型別進行相應資料處理,我們自己模擬一下:
step1: 先建立 server.js
:
req.on('end',function (params) { let r = Buffer.concat(arr).toString(); // body-parser解析請求,根據不同的格式進行不同的解析 if (req.headers['content-type'] === www.js){ let querystring = require('querystring'); r = querystring.parse(r); // a=1&b=2 console.log(r,1); } else if (req.headers['content-type'] === 'application/json'){ console.log(JSON.parse(r),2); } else{ console.log(r,3); } res.end('end'); }) 複製程式碼
step2: 客戶端模擬請求:
let opts = { host:'localhost', port:3000, path:'/hello', headers:{ 'a':1, 'Content-Type':'application/json', "Content-Length":7 //模擬的時候需要帶上長度,不然客戶端會當成沒有傳遞資料 } } let http = require('http'); let client = http.request(opts,function (res) { res.on('data',function (data) { console.log(data.toString()); }) }); client.end("{\"a\":1}"); // 表示把請求發出去 複製程式碼
step3: 測試。 先啟動server,再啟動client,服務端收到按照 application/json
格式解析的資料: { a: 1 } 2
. Content-Type
與 body-parser
之間的關係就先分析到這裡了。 後面我們接著看請求頭。
2.2 Range:bytes
請求頭通過Range:bytes可以請求資源的某一部分。利用這個欄位可模擬部分讀取。如下:
http.createServer(function (req, res) { let range = req.headers['range']; }) 複製程式碼
server:
let http = require('http'); let fs = require('fs'); let path = require('path'); // 當前要下載的檔案的大小 let size = fs.statSync(path.join(__dirname, 'my.txt')).size; let server = http.createServer(function (req, res) { let range = req.headers['range']; // 0-3 if (range) { // 模擬請求 curl -v --header "Range:bytes=0-3" http://localhost:3000 let [, start, end] = range.match(/(\d*)-(\d*)/); start = start ? Number(start) : 0; end = end ? Number(end) : size - 1; // 10個位元組 size 10(0-9) res.setHeader('Content-Range', `bytes ${start}-${end}/${size - 1}`); fs.createReadStream(path.join(__dirname, 'my.txt'), { start, end }).pipe(res); } else { // 會把檔案的內容寫給客戶端 fs.createReadStream(path.join(__dirname, 'my.txt')).pipe(res); //可讀流可以通過pipe導到可寫流 } }); server.listen(3000); 複製程式碼
client:
let opts = { host:'localhost', port:3000, headers:{} } let http = require('http'); let start = 0; let fs = require('fs'); function download() { opts.headers.Range = `bytes=${start}-${start+3}`; start+=4; console.log(`start is ${start}`) let client = http.request(opts,function (res) { let total = res.headers['content-range'].split('/')[1]; // console.log(half) res.on('data',function (data) { fs.appendFileSync('./download1.txt',data); }); res.on('end',function () { setTimeout(() => { if ((!pause)&&(start < total)) download(); }, 1000); }) }); client.end(); } download() 複製程式碼
分段讀取新增暫停功能,監聽使用者輸入
let pause = false; process.stdin.on('data',function (data) { if (data.toString().includes('p')){ pause = true }else{ pause = false; download() } }) 複製程式碼
測試結果:

分段讀取有以下好處:
- 提高讀取速度,多執行緒並行,分塊讀取
- 斷點續傳
模擬並行下載:
let halfFlag = 20 function download() { opts.headers.Range = `bytes=${start}-${start+3}`; start+=4; console.log(`start is ${start}`) let client = http.request(opts,function (res) { let total = res.headers['content-range'].split('/')[1]; let halfFlag = Math.floor(total/2) // console.log(half) res.on('data',function (data) { fs.appendFileSync('./download1.txt',data); }); res.on('end',function () { setTimeout(() => { if ((!pause)&&(start < halfFlag)) download(); }, 1000); }) }); client.end(); } let half = halfFlag function downloadTwo() { opts.headers.Range = `bytes=${half}-${half+3}`; half+=4; console.log(`half is ${half}`) let client = http.request(opts,function (res) { let total = res.headers['content-range'].split('/')[1]; res.on('data',function (data) { fs.appendFileSync('./download2.txt',data); }); res.on('end',function () { setTimeout(() => { if (!pause&½ < total) downloadTwo(); }, 1000); }) }); client.end(); } download(); downloadTwo(); 複製程式碼
執行結果,會把原檔案分成兩部分下載到download1.txt和download2.txt。 測試:

理論上,這樣的下載方式會比第一種方法節約一半的時間。但是實際中的檔案下載怎樣實現加速以及並行下載的,還有待考究。
2.3 Cache-Control與Expires之強制快取
Response Header響應頭中 Cache-Control: max-age=1233
可以設定相對當前的時間的強制快取,與它相關的 Expires
可以設定某個絕對時間點限定讀取快取的時間。 模擬實現:
let url = require('url'); // 專門用來處理url路徑的核心模組 // http://username:password@hostname:port/pathname?query let server = http.createServer(async function (req,res) { console.log(req.url) let { pathname,query} = url.parse(req.url,true); // true就是將query轉化成物件 let readPath = path.join(__dirname, 'public', pathname); try { let statObj = await stat(readPath); // 根客戶端說 10s 內走快取 res.setHeader('Cache-Control','max-age=10'); res.setHeader('Expires',new Date(Date.now()+10*1000).toGMTString()); // 10s之內的請求都會走cache 返回200, (from disk cache)不發生請求 if (statObj.isDirectory()) { let p = path.join(readPath, 'index.html'); await stat(p); // 如果當前目錄下有html那麼就返回這個檔案 fs.createReadStream(p).pipe(res); } else { fs.createReadStream(readPath).pipe(res); } }catch(e){ res.statusCode = 404; res.end(`Not found`); } }).listen(3000); 複製程式碼
測試:

10s內重新整理:

2.4 對比快取之Last-Modified和If-Modified-Since
對比響應頭Last-Modified and 與請求頭If-Modified-Since,可以通過檔案修改時間看檔案是否修改,從而決定是重新請求還是走快取。 模擬如下: step1 不設定強制快取
res.setHeader('Cache-Control','no-cache'); 複製程式碼
step2 應用檔案修改時間比對是否修改,
res.setHeader('Last-Modified', statObj.ctime.toGMTString()); if (req.headers['if-modified-since'] === statObj.ctime.toGMTString()) { res.statusCode = 304; res.end(); return; // 走快取 } fs.createReadStream(readPath).pipe(res); 複製程式碼
測試:

2.5 對比快取之Etag和 If-None-Match
對比響應頭:Etag 與請求頭:If-None-Match,Etag和If-None-Match如果相等,即返回304。 etag如何新增?
根據檔案內容,生成一個md5的摘要,給實體加一個標籤。
這種方法雖然比較耗效能,但是能夠更加精確的對比出檔案是否進行了修改。依靠檔案修改時間進行對比並不夠準確。因為有時檔案有改動Last-Modified發生了變化,但是檔案的內容可能根本沒有變化。所以這種方案要優於2.4.
實現方法:
let rs = fs.createReadStream(p); let md5 = crypto.createHash('md5'); // 不能寫完響應體再寫頭 let arr = []; rs.on('data',function (data) { md5.update(data); arr.push(data); }); 複製程式碼
設定Etag
rs.on('end',function () { let r = md5.digest('base64'); res.setHeader('Etag', r); if (req.headers['if-none-match'] === r ){ res.statusCode = 304; res.end(); return; } res.end(Buffer.concat(arr)); }) 複製程式碼
測試:

2.6 Accept-Encoding
依靠請求頭: Accept-Encoding: gzip, deflate, br告訴服務端可接受的資料格式。服務端返回後會把資料格式通過響應格式通過Content-Encoding來標記。 在客戶端接受gzip的格式下,後端可通過檔案壓縮處理傳遞,提高效能。 node api中提供了zlib模組:
zlib模組提供通過 Gzip 和 Deflate/Inflate 實現的壓縮功能
下面我們來應用zlib與請求頭Accept-Encoding來實現壓縮功能。
let zlib = require('zlib'); let fs = require('fs'); let path = require('path'); function gzip(filePath) { let transform = zlib.createGzip();//轉化流通過transform壓縮,然後再寫 fs.createReadStream(filePath).pipe(transform).pipe(fs.createWriteStream(filePath+'.gz')); } gzip('2.txt') 複製程式碼
解壓:
function gunzip(filePath) { let transform = zlib.createGunzip(); fs.createReadStream(filePath).pipe(transform).pipe(fs.createWriteStream(path.basename(filePath,'.gz'))); } 複製程式碼
path.basename(filePath,'.gz')
用來去掉filePath檔名的字尾 .gz
。
根據請求頭接受的型別後端的具體操作 :
if(req.url === '/download'){ res.setHeader('Content-Disposition', 'attachment' ) return fs.createReadStream(path.join(__dirname, '1.html')).pipe(res); } 複製程式碼
let http = require('http'); let fs = require('fs'); let path = require('path'); let zlib = require('zlib'); http.createServer(function (req,res) { if(req.url === '/download'){ res.setHeader('Content-Disposition', 'attachment' ) return fs.createReadStream(path.join(__dirname, '1.html')).pipe(res); } let rule = req.headers['accept-encoding']; if(rule){ if(rule.match(/\bgzip\b/)){ res.setHeader('Content-Encoding','gzip'); fs.createReadStream(path.join(__dirname, '1.html')) .pipe(zlib.createGzip()) .pipe(res); } else if (rule.match(/\bdeflate\b/)){ res.setHeader('Content-Encoding', 'deflate'); fs.createReadStream(path.join(__dirname, '1.html')) .pipe(zlib.createDeflate()) .pipe(res); }else{ fs.createReadStream(path.join(__dirname, '1.html')).pipe(res); } }else{ fs.createReadStream(path.join(__dirname, '1.html')).pipe(res); } }).listen(3000); 複製程式碼
test deflate:
curl -v --header "Accept-Encoding:deflate" http://localhost:3000 * Rebuilt URL to: http://localhost:3000/ *Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 3000 (#0) > GET / HTTP/1.1 > Host: localhost:3000 > User-Agent: curl/7.54.0 > Accept: */* > Accept-Encoding:deflate > < HTTP/1.1 200 OK < Content-Encoding: deflate < Date: Thu, 23 Aug 2018 03:01:13 GMT < Connection: keep-alive < Transfer-Encoding: chunked 複製程式碼
test others:
curl -v --header "Accept-Encoding:nn" http://localhost:3000 * Rebuilt URL to: http://localhost:3000/ *Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 3000 (#0) > GET / HTTP/1.1 > Host: localhost:3000 > User-Agent: curl/7.54.0 > Accept: */* > Accept-Encoding:nn > < HTTP/1.1 200 OK < Date: Thu, 23 Aug 2018 03:02:51 GMT < Connection: keep-alive < Transfer-Encoding: chunked < <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> 你好 </body> * Connection #0 to host localhost left intact </html>% 複製程式碼
2.7 referer

referer表示請求檔案的網址,請求時會攜帶。為了防止自己網站的檔案被外網直接引用,可以通過比較referer,即請求的地址,與本地地址比較,設定防盜鏈。
let http =require('http'); let fs = require('fs'); let url = require('url'); let path = require('path'); // 這是百度的伺服器 let server = http.createServer(function (req,res) { let { pathname } = url.parse(req.url); let realPath = path.join(__dirname,pathname); fs.stat(realPath,function(err,statObj) { if(err){ res.statusCode = 404; res.end(); }else{ let referer = req.headers['referer'] || req.headers['referred']; if(referer){ let current = req.headers['host'] // 代表的是當前圖片的地址 referer = url.parse(referer).host // 引用圖片的網址 if (current === referer){ fs.createReadStream(realPath).pipe(res); }else{ fs.createReadStream(path.join(__dirname,'images/2.jpg')).pipe(res); } }else{ fs.createReadStream(realPath).pipe(res); } } }) }).listen(3000); 複製程式碼
2.8 Accept-Language
請求頭:Accept-Language: zh-CN,zh;q=0.9 多個語言用 ',' 分隔,權重用 '=' 表示',沒有預設權重為1
後端根據請求接受語言的權重一次查詢,查詢到就返回,找不到就用預設語言
let langs = { en:'hello world', 'zh-CN':'你好世界', zh:'你好', ja: 'こんにちは、世界' } let defualtLanguage = 'en' // 多語言之服務端方案:來做 (瀏覽器會發一個頭) 前端來做 // 通過url實現多語言 let http = require('http'); http.createServer(function (req,res) { let lan = req.headers['accept-language']; //[[zh,q=0.9],[zh-CN]] =>[{name:'zh-CN',q=1},{name:'zh',q:0.9}] if(lan){ lan = lan.split(','); lan = lan.map(l=>{ let [name,q] = l.split(';'); q = q?Number(q.split('=')[1]):1 return {name,q} }).sort((a,b)=>b.q-a.q); // 排出 權重陣列 for(let i = 0 ;i <lan.length;i++){ // 將每個人的名字 取出來 let name= lan[i].name; if(langs[name]){ //去語言包查詢 查詢到就返回 res.end(langs[name]); return; } } res.end(langs[defualtLanguage]); // 預設語言 }else{ res.end(langs[defualtLanguage]); // 預設語言 } }).listen(3000); 複製程式碼
測試:
