HTTP请求之gzip压缩知多少

3次阅读

共计 5014 个字符,预计需要花费 13 分钟才能阅读完成。

gzip 压缩简介

什么是 gzip 压缩,gzip 压缩是基于 deflate 中的算法进行压缩的,gzip 会产生自己的数据格式,gzip 压缩对于所需要压缩的文件,首先使用 LZ77 算法进行压缩,再对得到的结果进行 huffman 编码,根据实际情况判断是要用动态 huffman 编码还是静态 huffman 编码,最后生成相应的 gz 压缩文件。

gzip 和 deflate 的区别

大家可能注意到了,在我们的 HTTP 请求头中,会存在 accept-encoding 字段,其实就是在告诉服务器,客户端所能接受的文件的压缩格式,其中包括 gzip、deflate 和 br 等。
那么常见的 gzip 和 deflate 有什么区别呢?
1、GZIP,好像是一个不透明的或原子的功能。事实上,HTTP 定义了一种机制,一个 Web 客户机和 Web 服务器同意一压缩方案可以用来发送内容,这由使用 Accept-Encoding 和 Content-Encoding 标头完成。有两种常用的 HTTP 压缩:DEFLATE 和 GZIP。
2、DEFLATE 是一个无专利的压缩算法,它可以实现无损数据压缩,有众多开源的实现算法。该标准的实施库大多数人用的是 zlib 的。zlib 库提供用于压缩和解压缩使用 DEFLATE/INFLATE 的数据。zlib 库还提供了一种数据格式,混淆的命名 ZLIB,它包装 DEFLATE 压缩数据,具有报头和校验和。
总而言之,GZIP 是使用 DEFLATE 进行压缩数据的另一个压缩库。事实上,GZIP 的大多数实现实际使用 zlib 库的内部进行 DEFLATE/ INFLATE 压缩操作。GZIP 产生其自己的数据格式,混淆的命名 GZIP,它包装 DEFLATE 压缩数据,具有报头和校验和。而由于最初的规定不统一问题,大多数情况下已经启用 deflate 压缩。

gzip 压缩原理

接下来进入本文的正题之一,gzip 压缩的流程是怎么样的?
其实 gzip 压缩一般都是针对文本文件进行压缩,至于原因后面会介绍到,首先 LZ77 算法基于文本文件对文本内容,即文件中的字符串进行首次压缩,接下来,利用哈夫曼编码,转换为 010111.. 等 2 进制进行存储。那么具体的算法是怎样的呢?

1、LZ77 算法

LZ77 算法的核心思想,是对字符串的重复利用,在扫描整个文本的过程中,判断之前的字符串中,有没有出现过类似的字符串或子字符串,通过这样的方式来压缩你的文本长度,举个简单的栗子:
文本内容如下:
http://www.qq.com
http://www.paipai.com
我们把相同的内容括起来
http://www.qq.com
(http://www.)paipai(.com)
用信息对替换之后的结果是
http://www.qq.com
(18,11)paipai(22,4)
其中
(18,11) 中,18(起点到下一个起点,包含换行)为相同内容块与当前位置之间的距离,11 为相同内容的长度。
(22,4)中,22 为相同内容块与当前位置之间的距离,4 为相同内容的长度。
由于信息对的大小,小于被替换内容的大小,所以文件得到了压缩。

算法具体实现,首先你要明确几个名词概念:
1. 前向缓冲区
每次读取数据的时候,先把一部分数据预载入前向缓冲区。为移入滑动窗口做准备

2. 滑动窗口
一旦数据通过缓冲区,那么它将移动到滑动窗口中,并变成字典的一部分。

3. 短语字典
从字符序列 S1…Sn,组成 n 个短语。比如字符(A,B,D) , 可以组合的短语为{(A),(A,B),(A,B,D),(B),(B,D),(D)}, 如果这些字符在滑动窗口里面,就可以记为当前的短语字典,因为滑动窗口不断的向前滑动,所以短语字典也是不断的变化。

在遍历整个文本的字符串的过程中,算法通过判断前向缓冲区中,是否存在滑动窗口中的最长子字符串,实现字符串的“复用”,具体可以看下图:

目前滑动窗口中存在的字符串,除去单字符的,则有 AB、BD、ABD,而前向缓冲区中,存在 AB、ABC、BC,最长的子字符串为 AB,则可以将 ABC 压缩为(0,2,C),其中 0 表示在滑动窗口中的偏移量,2 表示读取两个字符,C 表示未匹配的字符。接下来我们看一个完整的压缩案例你就懂了:(图片引自:https://www.jb51.net/article/…)

那么解压过程呢?其实解压过程是非常快速的,如下:


可以看出,LZ77 算法的耗时,主要在于压缩阶段中,判断前向缓冲区和滑动窗口中的最长字符串匹配,也就是说,选择滑动窗口大小,以及前向缓冲区大小,对你的 LZ77 算法的时间复杂度有着关键的影响,其次,合适的滑动窗口大小、缓冲区大小能帮助你对文件进行更好的压缩,提高压缩率。
而在整个解压的过程,只需要通过简单的字符读取,根据偏移量复用子字符串,就完成了解压过程,整个过程没有复杂的算法耗时,这也正符合了我们在网络请求中的适用场景,通过在服务端对文件的一次压缩,提供给所有的用户使用,在客户端对数据进行解压,而解压基本没有什么耗时,因此能大大提高我们的页面资源加载速度。

2、huffman 编码

当你对文件中的文本进行压缩后,接下来其实就是文本的存储了,ASCII 编码中,一个字符对应一个字节,通过 8 位来表示,但是我们真的不能在优化了吗?huffman 编码告诉我们,我们还能再压缩!huffman 的原理,本质是通过判断字符在文本中的出现频次,规定每个字符的编码,而非固定 8 位编码,举个简单的栗子:
字符串:AAABCB
按照以往的存储方式 需要 6 个字节,48bits。
但是我们可以看到,A 出现了 3 次,B 出现了 2 次,C 仅仅出现了 1 次,于是我们换一种存储方式,
用 0 来表示 A,10 来表示 B,11 来表示 C
最后的压缩结果是 000101110,最后我们只用了 9 个 bits 来存储了这个字符串!
这就是 huffman 的根本原理。
可以看到我们的关键在于,为字符生成相应的编码,所以这个过程需要我们构造一个哈弗曼树,什么是哈夫曼树呢?哈弗曼树是通过一系列值,将这些值作为叶子节点,注意是叶子节点,构造成一个树,这个树要求,(叶子节点的权重 叶子节点的深度) 所有叶子节点总数 n 的值达到最小。具体的构造过程如下:(图片引自 http://www.360doc.com/content…)

其中,节点值表示字符出现的频次,叶子节点的层级变相意味着 字符编码的长度,其实这也很好理解,我们希望出现频次越多的字符,编码越短越好,频次较少的字符,编码长度较长也无所谓。这也和哈夫曼树的定义完美契合。

可以看到字符串:
abbbbccccddde

a b c d e
1 4 4 3 1
经过我们的 huffman 编码后,分别表示如下:
a 为 110
b 为 00
c 为 01
d 为 10
e 为 111
聪明的你可能还留意到了,通过 huffman 编码后的二进制字符,是没有二义性的。
可以看到我们上述的案例,动态生成了 huffman 编码,所以最后传输数据的时候,还需要把 huffman 树的信息一起传递过去,当然,这里还存在 静态 huffman 和动态 huffman 的区别
静态 Huffman 编码就是使用 gzip 自己预先定义好了一套编码进行压缩,解压缩的时候也使用这套编码,这样不需要传递用来生成树的信息。
动态 Huffman 编码就是使用统计好的各个符号的出现次数,建立 Huffman 树,产生各个符号的 Huffman 编码,用这产生的 Huffman 编码进行压缩,这样需要传递生成树的信息。
Gzip 在为一块进行 Huffman 编码之前,会同时建立静态 Huffman 树,和动态 Huffman 树,然后根据要输出的内容和生成的 Huffman 树,计算使用静态 Huffman 树编码,生成的块的大小,以及计算使用动态 Huffman 树编码,生成块的大小。然后进行比较,使用生成块较小的方法进行 Huffman 编码。
对于静态树来说,不需要传递用来生成树的那部分信息。动态树需要传递这个信息。而当文件比较小的时候,传递生成树的信息得不偿失,反而会使压缩文件变大。也就是说对于文件比较小的时候,就可能会出现使用静态 Huffman 编码比使用动态 Huffman 编码,
生成的块小。

如何开启 gzip

竟然 gzip 这么棒,赶紧在我们的项目中用起来吧!
express 和 koa 默认都没有开启 gzip 压缩,所以需要自行引入中间件的形式开启压缩,针对 text 文件进行文本压缩。

const Koa = require("koa");
const path = require("path")
var compress = require('koa-compress')
const app = new Koa();

const static = require('koa-static')


app.use(compress({filter: function (content_type) {return /text/g.test(content_type)
  },
  threshold: 1,
  flush: require('zlib').Z_SYNC_FLUSH
}))

app.use(async(ctx, next) => {
  //ctx 代表响应 ctx.compress = trus 代表允许压缩
  // ctx.compress = true
  // ctx.body = "hello"
  await next()})


app.use(static(path.join( __dirname, 'public'),{gzip:true}
))
// 可以配置一个或多个
// app.use(static(__dirname + '/static')))

app.listen(3000);

需要注意 koa 的中间件使用 和 express 是不一样的。
其次,通过 filter 函数来判断是否要进行 gzip 压缩。

除了 nginx 或 server 层做 gzip 压缩。
也可以在构建的时候压缩,生成相应的 gz 文件。

const CompressionWebpackPlugin = require('compression-webpack-plugin');

webpackConfig.plugins.push(
    new CompressionWebpackPlugin({asset: '[path].gz[query]',
      algorithm: 'gzip',
      test: new RegExp('\\.(js|css)$'),
      threshold: 10240,
      minRatio: 0.8
    })
)

关于 gzip 的 Q & A

Q:gzip 是否仅限制于文本文件,对于二进制文件呢?音频?图片等资源呢?
A:gzip 压缩不仅适用于文本文件,其实也可以对图片等二进制文件进行压缩,但是不推荐这么做,由于 gzip 的压缩过程首先基于字符串进行 LZ77 处理,接下里再通过 huffman 编码,然而,大多数的二进制文件,已经有自己的编码和压缩方式了,对二进制文件进行在压缩,可能导致文件更大,同时,会增大客户端解压缩的压力,带来不必要的 CPU 损耗。

一些开发者使用 HTTP 压缩那些已经本地已经压缩过的文件,而这些已经压缩过的文件再次被 GZip 压缩时,是不能提高性能的,表现在如下两个方面。
首先,HTTP 压缩需要成本。Web 服务器获得需要的内容,然后压缩它,最后将它发送到客户端。如果内容不能被进一步压缩,你只是在浪费 CPU 做无意义的任务。
其次,采用 HTTP 压缩已经被过压缩的东西并不能使它更小。事实上,添加标头,压缩字典,并校验响应体实际上使它变得更大,如下图所示:

Q:gzip 压缩过得文件,可以再进行一次 gzip 压缩吗?多次呢?
A:可以进行再压缩,但是 gzip 首次压缩过后的文件,本身已经是二进制文件,对文件进行再压缩只会带来没必要的性能损耗,同时可能导致文件增大。

Q:需要对所有文本内容都进行 GZIP 压缩吗?
A:答案是否定的,可以看到我们的压缩过程中,需要执行算法,假如传输的字符串仅仅几十个字符,那么执行算法是完全没有必要的,并且,对过短的字符进行压缩,可能反而导致传输数据量增大,例如可能要传输动态构造的 huffman 树信息等。

Q:在哪个阶段对文件进行 gzip 压缩比较合适?
A:在本地构建部署的时候,通过 webpack 生成最终的 gz 压缩文件部署上服务器,是最高效的,假如通过引入中间件,在请求到来时再对文本文件进行压缩,只会让用户的等待时间更长,所以笔者认为,在 webpack 打包构建的时候做这件事是最合理的。

finally

谢谢大家的观看!本期节目到此结束~ 下期再会~

正文完
 0