如何压缩 HTTP 请求正文

whhbst 9年前
   <p><a href="http://www.open-open.com/lib/view/open1460866410410.html">上篇文章中</a>,我介绍了 HTTP 协议中的 Accept-Encoding 和 Content-Encoding 机制。它可以很好地用于文本类<strong>响应正文</strong>的压缩,减少网络数据的传输,已被广泛使用。但 HTTP 请求的发起方浏览器,无法事先知晓要访问的服务端是否支持解压,所以现阶段的浏览器没有压缩<strong>请求正文</strong>。</p>    <p>有一些通讯协议基于 HTTP 做了扩展,他们的客户端和服务端是专用的,完全可以针对请求正文进行压缩,例如 WebDAV 客户端就是这么做的。</p>    <p>实际的 Web 项目中,会存在请求正文非常大的场景,例如发表长篇博客,上报用于调试的网络数据等等。这些数据如果能在本地压缩后再提交,就可以节省大量流量、减少传输时间。本文介绍如何对 HTTP 请求正文进行压缩,包含如何在服务端解压、如何在客户端压缩两个部分。</p>    <p>开始之前,先来介绍本文涉及的三种数据压缩格式:</p>    <ul>     <li>DEFLATE,是一种使用 Lempel-Ziv 压缩算法(LZ77)和哈夫曼编码的压缩格式。详见 <a href="/misc/goto?guid=4959671115316382482">RFC 1951</a>;</li>     <li>ZLIB,是一种使用 DEFLATE 的压缩格式,对应 HTTP 中的 Content-Encoding: deflate。详见 <a href="/misc/goto?guid=4959671115403926368">RFC 1950</a>;</li>     <li>GZIP,也是一种使用 DEFLATE 的压缩格式,对应 HTTP 中的 Content-Encoding: gzip。详见 <a href="/misc/goto?guid=4959671115483015579">RFC 1952</a>;</li>    </ul>    <p>Content-Encoding 中的 deflate,实际上是 ZLIB。为了清晰,本文将 DEFLATE 称之为 RAW DEFLATE,ZLIB 和 GZIP 都是 RAW DEFLATE 的不同 Wrapper。</p>    <h3>解压请求正文</h3>    <p>服务端收到请求正文后,需要分析请求头中的 Content-Encoding 字段,才能知道正文采用了哪种压缩格式。本文规定用 gzip、deflate 和 deflate-raw 分别表示请求正文采用 GZIP、ZLIB 和 RAW DEFLATE 压缩格式。</p>    <p>Nginx</p>    <p>Nginx 没有类似于 Apache 的 <code>SetInputFilter</code> 指令,不能直接给请求添加处理逻辑,还好有 <a href="/misc/goto?guid=4958857006007377534">OpenResty</a>。OpenResty 通过集成 Lua 及大量 Lua 库,极大地提升了 Nginx 的功能丰富度和可扩展性。而 LuaJIT 中的 FFI 库,允许纯 Lua 代码调用外部 C 函数,使用 C 数据结构。</p>    <p>把这一切结合起来,就能方便地实现这个需求:首先安装 OpenResty;下载并解压 <a href="/misc/goto?guid=4959671269016351052">Zlib 库的 FFI 版</a>;然后在 Nginx 的配置中,通过 <code>lua_package_path</code> 指令将这个库引入;再新建一个 lua 文件,如 <code>request-compress.lua</code>,调用 Zlib 库实现解压功能:</p>    <pre>  <code class="language-lua">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  </code></pre>    <p>我们的 Nginx 一般都是挡在最前面,背后还有 PHP、Node.js 等实际服务。这段代码从 Content-Encoding 请求头中获取请求压缩格式,并在解压后移除了这个头部。这样对于 Nginx 背后的服务来说,完全感知不到跟平常有什么不一样。</p>    <p>现在还差最后一步,找到 Nginx 中配置 xxx_pass(proxy_pass、uwsgi_pass、fastcgi_pass 等)的地方,加入 lua 处理逻辑:</p>    <pre>  <code class="language-lua">location ~ \.php$ {      access_by_lua_file /your/path/to/request-compress.lua;        fastcgi_pass 127.0.0.1:9000;      #... ...  }  </code></pre>    <p>这个配置目的是让这个 lua 逻辑工作在 Nginx 的 Access 阶段。</p>    <p>到此为止,基于 OpenResty 的解压方案已经写好。它能否按预期正常工作呢?我决定先放一放,后面再验证。</p>    <p>Node.js</p>    <p>Node.js 内置了对 <a href="/misc/goto?guid=4959671269120381994">Zlib</a> 库的封装。使用 Node.js 也可以轻松应对压缩内容。直接上代码:</p>    <pre>  <code class="language-lua">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');  </code></pre>    <p>这段代码将请求正文解压之后,直接做出输出返回,它可以正常工作,但仅作示意。实际项目中,这些通用逻辑应该放在框架层统一处理,业务层代码无需关心。</p>    <p>PHP</p>    <p>PHP 也内置了处理这些压缩格式的函数,以下是实例代码:</p>    <pre>  <code class="language-lua">$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;  </code></pre>    <p>可以看到,ZLIB 格式的压缩数据去掉头尾,就是 RAW DEFLATE,可以直接用 gzinflate 解压。跟前面一样,如果采用 PHP 解压方案,也应该在框架层统一处理。</p>    <p>小结一下:在 Nginx 统一解压的好处是无论后端挂接什么服务,都可以做到无感知,坏处是需要替换为 OpenResty;在 Web 框架中处理更灵活,但不同语言不同项目需要分别处理,性能方面应该也有差别。如何选择,要看各自实际情况。</p>    <h3>压缩请求正文</h3>    <p>浏览器</p>    <p>通过 <a href="/misc/goto?guid=4959671269220450305">pako</a> 这个 JS 库,可以在浏览器中使用 Zlib 库的大部分功能。它也能用于 Node.js 环境,但 Node.js 中一般用官方的 Zlib 就可以了。</p>    <p>pako 的浏览器版可以在<a href="/misc/goto?guid=4959671269320880012">这里</a>下载,我们只需要压缩功能,使用 <code>pako_deflate.min.js</code> 即可。这个文件有 27.3KB,gzip 后 9.1KB,算很小的了。它同时支持 GZIP、ZLIB 和 RAW DEFLATE 三种压缩格式,如果只保留一种应该还能更小。</p>    <p>下面是使用 pako 库在浏览器中实现压缩请求正文的示例代码:</p>    <pre>  <code class="language-lua">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);  </code></pre>    <p>这段代码本身没什么好多说的,十分简单。这里有一个最终的 <a href="/misc/goto?guid=4959671269428541960">DEMO 页面</a>,大家可以实际体验下。在这个 DEMO 中,针对 Zepto 源码压缩后能够减少 70% 的体积,十分可观。这个 DEMO 服务端使用的是前面介绍的 Node.js 解压方案。</p>    <p>Gzip + Curl</p>    <p>使用 Curl 命令,可以将 Gzip 命令生成的 GZIP 压缩数据 POST 给服务端。例如:</p>    <pre>  <code class="language-lua">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/  </code></pre>    <p>通过下图可以清晰的看到整个数据传输过程:</p>    <p><img alt="request body compress" src="http://static.open-open.com/lib/uploadImg/20160420/20160420105316_653.png"></p>    <p>本文到此马上就要结束了。对于本文没有提及的移动 APP,如果有 POST 大数据的场景,也可以使用本文方案,以较小的成本换取用户流量的节省和网络性能的提升,更妙的是这个方案具有良好的兼容性(不支持请求正文压缩的老版本 APP,自然不会在请求头带上 Content-Encoding 字段,直接会跳过服务端的解压逻辑),非常值得尝试!</p>    <p>来源:<a href="/misc/goto?guid=4959671269529490882">JerryQu 的小站</a></p>