上篇文章中,我介紹了 HTTP 協議中的 Accept-Encoding 和 Content-Encoding 機制。它可以很好地用於文字類響應正文的壓縮,減少網路資料的傳輸,已被廣泛使用。但 HTTP 請求的發起方瀏覽器,無法事先知曉要訪問的服務端是否支援解壓,所以現階段的瀏覽器沒有壓縮請求正文。
有一些通訊協議基於 HTTP 做了擴充套件,他們的客戶端和服務端是專用的,完全可以針對請求正文進行壓縮,例如 WebDAV 客戶端就是這麼做的。
實際的 Web 專案中,存在請求正文非常大的場景,例如發表長篇部落格,上報用於除錯的網路資料等等。這些資料如果能在本地壓縮後再提交,就可以節省大量流量、減少傳輸時間。本文介紹如何對 HTTP 請求正文進行壓縮,包含如何在服務端解壓、如何在客戶端壓縮兩個部分。
開始之前,先來介紹本文涉及的三種資料壓縮格式:
- DEFLATE,是一種使用 Lempel-Ziv 壓縮演算法(LZ77)和哈夫曼編碼的壓縮格式。詳見 RFC 1951;
- ZLIB,是一種使用 DEFLATE 的壓縮格式,對應 HTTP 中的 Content-Encoding: deflate。詳見 RFC 1950;
- GZIP,也是一種使用 DEFLATE 的壓縮格式,對應 HTTP 中的 Content-Encoding: gzip。詳見 RFC 1952;
Content-Encoding 中的 deflate,實際上是 ZLIB。為了清晰,本文將 DEFLATE 稱之為 RAW DEFLATE,ZLIB 和 GZIP 都是 RAW DEFLATE 的不同 Wrapper。
解壓請求正文
服務端收到請求正文後,需要分析請求頭中的 Content-Encoding 欄位,才能知道正文采用了哪種壓縮格式。本文規定用 gzip、deflate 和 deflate-raw 分別表示請求正文采用 GZIP、ZLIB 和 RAW DEFLATE 壓縮格式。
Nginx
Nginx 沒有類似於 Apache 的 SetInputFilter
指令,不能直接給請求新增處理邏輯,還好有 OpenResty。OpenResty 通過整合 Lua 及大量 Lua 庫,極大地提升了 Nginx 的功能豐富度和可擴充套件性。而 LuaJIT 中的 FFI 庫,允許純 Lua 程式碼呼叫外部 C 函式,使用 C 資料結構。
把這一切結合起來,就能方便地實現這個需求:首先安裝 OpenResty;下載並解壓 Zlib 庫的 FFI 版;然後在 Nginx 的配置中,通過 lua_package_path
指令將這個庫引入;再新建一個 lua 檔案,如 request-compress.lua
,呼叫 Zlib 庫實現解壓功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
local ffi = require "ffi" local zlib = require "zlib" local function reader(s) local done return function() if done then return end done = true return s end end local function writer() local t = {} return function(data, sz) if not data then return table.concat(t) end t[#t + 1] = ffi.string(data, sz) end end local encoding = ngx.req.get_headers()['Content-Encoding'] if encoding == 'gzip' or encoding == 'deflate' or encoding == 'deflate-raw' then ngx.req.clear_header('Content-Encoding'); ngx.req.read_body() local body = ngx.req.get_body_data() if body then local write = writer() local map = { gzip = 'gzip', deflate = 'zlib', ['deflate-raw'] = 'deflate' } local format = map[encoding] zlib.inflate(reader(body), write, nil, format) ngx.req.set_body_data(write()) end end |
我們的 Nginx 一般都是擋在最前面,背後還有 PHP、Node.js 等實際服務。這段程式碼從 Content-Encoding 請求頭中獲取請求壓縮格式,並在解壓後移除了這個頭部。這樣對於 Nginx 背後的服務來說,完全感知不到跟平常有什麼不一樣。
現在還差最後一步,找到 Nginx 中配置 xxx_pass(proxy_pass、uwsgi_pass、fastcgi_pass 等)的地方,加入 lua 處理邏輯:
1 2 3 4 5 6 |
location ~ .php$ { access_by_lua_file /your/path/to/request-compress.lua; fastcgi_pass 127.0.0.1:9000; #... ... } |
這個配置目的是讓這個 lua 邏輯工作在 Nginx 的 Access 階段。
到此為止,基於 OpenResty 的解壓方案已經寫好。它能否按預期正常工作呢?我決定先放一放,後面再驗證。
Node.js
Node.js 內建了對 Zlib 庫的封裝。使用 Node.js 也可以輕鬆應對壓縮內容。直接上程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
var http = require('http'); var zlib = require('zlib'); http.createServer(function (req, res) { var buffers = []; var zlibStream; var encoding = req.headers['content-encoding']; switch(encoding) { case 'gzip': zlibStream = zlib.createUnzip(); break; case 'deflate': zlibStream = zlib.createInflate(); break; case 'deflate-raw': zlibStream = zlib.createInflateRaw(); break; } zlibStream.on('data', function(chunk) { buffers.push(chunk); }); zlibStream.on('end', function(chunk) { res.end(Buffer.concat(buffers).toString()); }); req.pipe(zlibStream); }).listen(8361, '127.0.0.1'); |
這段程式碼將請求正文解壓之後,直接做出輸出返回,它可以正常工作,但僅作示意。實際專案中,這些通用邏輯應該放在框架層統一處理,業務層程式碼無需關心。後面我會基於 ThinkJS 寫一個外掛,專門處理這個邏輯。
PHP
PHP 也內建了處理這些壓縮格式的函式,以下是例項程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
$encoding = $_SERVER['HTTP_CONTENT_ENCODING']; $rawBody = file_get_contents('php://input'); $body = ''; switch($encoding) { case 'gzip': $body = gzdecode($rawBody); break; case 'deflate': $body = gzinflate(substr($rawBody, 2, -4)) . PHP_EOL . PHP_EOL; break; case 'deflate-raw': $body = gzinflate($rawBody); break; } echo $body; |
可以看到,ZLIB 格式的壓縮資料去掉頭尾,就是 RAW DEFLATE,可以直接用 gzinflate 解壓。跟前面一樣,如果採用 PHP 解壓方案,也應該在框架層統一處理。
小結一下:在 Nginx 統一解壓的好處是無論後端掛接什麼服務,都可以做到無感知,壞處是需要升級為 OpenResty;在 Web 框架中處理更靈活,但不同語言不同專案需要分別處理,效能方面應該也有差別。如何選擇,要看各自實際情況。
壓縮請求正文
瀏覽器
通過 pako 這個 JS 庫,可以在瀏覽器中使用 ZLib 庫的大部分功能。它也能用於 Node.js 環境,但 Node.js 中一般用官方的 Zlib 就可以了。
pako 的瀏覽器版可以在這裡下載,我們只需要壓縮功能,使用 pako_deflate.min.js
即可。這個檔案有 27.3KB,gzip 後 9.1KB,算很小的了。它同時支援 GZIP、ZLIB 和 RAW DEFLATE 三種壓縮格式,如果只保留一種應該還能更小。
下面是使用 pako 庫在瀏覽器中實現壓縮請求正文的示例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
var rawBody = 'content=test'; var rawLen = rawBody.length; var bufBody = new Uint8Array(rawLen); for(var i = 0; i var format = 'gzip'; // gzip | deflate | deflate-raw var buf; switch(format) { case 'gzip': buf = window.pako.gzip(bufBody); break; case 'deflate': buf = window.pako.deflate(bufBody); break; case 'deflate-raw': buf = window.pako.deflateRaw(bufBody); break; } var xhr = new XMLHttpRequest(); xhr.open('POST', '/node/'); xhr.setRequestHeader('Content-Encoding', format); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; '); xhr.send(buf); |
這段程式碼本身沒什麼好多說的,十分簡單。這裡有一個最終的 DEMO 頁面,大家可以實際體驗下。在這個 DEMO 中,針對 Zepto 原始碼壓縮後能夠減少 70% 的體積,十分可觀。這個 DEMO 服務端使用的是前面介紹的 Node.js 解壓方案。
Gzip + Curl
使用 Curl 命令,可以將 Gzip 命令生成的 GZIP 壓縮資料 POST 給服務端。例如:
1 2 3 |
echo "content=Web%20%E5%AE%89%E5%85%A8%E6%98%AF%E4%B8%80%E9%A1%B9%E7%B3%BB%E7%BB%9F%E5%B7%A5%E7%A8%8B%EF%BC%8C%E4%BB%BB%E4%BD%95%E7%BB%86%E5%BE%AE%E7%96%8F%E5%BF%BD%E9%83%BD%E5%8F%AF%E8%83%BD%E5%AF%BC%E8%87%B4%E6%95%B4%E4%B8%AA%E5%AE%89%E5%85%A8%E5%A0%A1%E5%9E%92%E5%9C%9F%E5%B4%A9%E7%93%A6%E8%A7%A3%E3%80%82" | gzip -c > data.txt.gz curl -v --data-binary @data.txt.gz -H'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' -H'Content-Encoding: gzip' -X POST https://qgy18.com/node/ |
通過下圖可以清晰的看到整個資料傳輸過程:
本文到此馬上就要結束了。對於本文沒有提及的移動 APP,如果有 POST 大資料的場景,也可以使用本文方案,以較小的成本換取使用者流量的節省和網路效能的提升,更妙的是這個方案具有良好的相容性(不支援請求正文壓縮的老版本 APP,自然不會在請求頭帶上 Content-Encoding 欄位,直接會跳過服務端的解壓邏輯),非常值得嘗試!
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式