上文介紹了 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 庫實現解壓功能:
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 處理邏輯:
location ~ \.php$ {
access_by_lua_file /your/path/to/request-compress.lua;
fastcgi_pass 127.0.0.1:9000;
#... ...
}
這個配置目的是讓這個 lua 邏輯工作在 Nginx 的 Access 階段。
到此爲止,基於 OpenResty 的解壓方案已經寫好。它能否按預期正常工作呢?我決定先放一放,後面再驗證。
Nodejs:
Node.js 內置了對 Zlib 庫的封裝。使用 Node.js 也可以輕鬆應對壓縮內容。直接上代碼:
var http = require('http');
var zlib = require('zlib');
http.createServer(function (req, res) {
var zlibStream;
var encoding = req.headers['content-encoding'];
switch(encoding) {
case 'gzip':
zlibStream = zlib.createGunzip();
break;
case 'deflate':
zlibStream = zlib.createInflate();
break;
case 'deflate-raw':
zlibStream = zlib.createInflateRaw();
break;
}
res.writeHead(200, {'Content-Type': 'text/plain'});
req.pipe(zlibStream).pipe(res);
}).listen(8361, '127.0.0.1');
這段代碼將請求正文解壓之後,直接做爲輸出返回,它可以正常工作,但僅作示意。實際項目中,這些通用邏輯應該放在框架層統一處理,業務層代碼無需關心。
PHP
PHP 也內置了處理這些壓縮格式的函數,以下是實例代碼:
$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 庫在瀏覽器中實現壓縮請求正文的示例代碼:
var rawBody = 'content=test';
var rawLen = rawBody.length;
var bufBody = new Uint8Array(rawLen);
for(var i = 0; i < rawLen; i++) {
bufBody[i] = rawBody.charCodeAt(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; charset=UTF-8');
xhr.send(buf);
這段代碼本身沒什麼好多說的,十分簡單。這裏有一個最終的 DEMO 頁面,大家可以實際體驗下。在這個 DEMO 中,針對 Zepto 源碼壓縮後能夠減少 70% 的體積,十分可觀。這個 DEMO 服務端使用的是前面介紹的 Node.js 解壓方案。
Gzip + Curl
使用 Curl 命令,可以將 Gzip 程序生成的 GZIP 壓縮數據 POST 給服務端。例如:
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 字段,直接會跳過服務端的解壓邏輯),非常值得嘗試!