如何壓縮 HTTP 請求正文

上文介紹了 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/

通過下圖可以清晰的看到整個數據傳輸過程:

request body compress

本文到此馬上就要結束了。對於本文沒有提及的移動 APP,如果有 POST 大數據的場景,也可以使用本方案,以較小的成本換取用戶流量的節省和網絡性能的提升。更妙的是這個方案具有良好的兼容性(不支持請求正文壓縮的老版本 APP,自然不會在請求頭帶上 Content-Encoding 字段,直接會跳過服務端的解壓邏輯),非常值得嘗試!

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章