本文我们来讨论一个nodejs的内置标准模块: zlib。
本来这就是一个常规的nodejs技术实现的探讨,而且由于zlib这个技术,在实际的开发应用中,使用到的场景和机会很少,至少笔者在工作中,从来没有直接使用过这个库。但在成文的过程中,笔者却发现了很多和数据压缩技术相关的有趣的内容,而且也觉得它们对于相关信息技术领域的理解和认知也是非常重要的,值得分享和讨论,所以也在本文中,就扩展了数据压缩和算法等相关的内容。
数据压缩
数据压缩,是信息技术领域中一种非常重要而常用的数据和信息的处理方式。数据压缩的底层逻辑是认为所有的信息经过数字化之后的,在实际信息承载的内容和表示形式之间,在不同程度上都有一些落差和冗余。很多情况下,这些冗余会不必要的占用和消耗很多存储、计算和传输的资源,需要进行优化和处理。
我们稍微举一个容易理解的例子,比如下面两个字符串: "12345678"和"11111111",它们的长度是一样的,也就是说它们的原始形式在计算机的存储中,占有的空间也是一样的,都是8个字符,但直观的我们就可以体会,两者承载的有效信息是不同的,前者显然更丰富一点,而且基本上无法简化;而后者则有很多冗余的信息,可以用"8个1"这种简单形式来表达。数据压缩的基本思想,就是将数据中的冗余信息,用更紧凑的编码方式来进行表示,从而在形式上减小存储或传输所需的空间。
前面的例子只是一个非常简单而通俗的描述,但实际上它是有相关的理论基础的,那就是香农在1948年提出的信息熵(Information Entropy),它也是信息论中一个核心概念,它定量描述了信息的不确定性程度。信息熵的理论认为,对于一个离散随机事件X,其可能取值为{x1,x2,...,xn},概率分布为{p1,p2,...,pn},则其信息熵H(X)可以定义为:
H(X) = -Σ p(xi) log2 p(xi) (i=1,2,...,n)
信息论和信息熵的公式向我们指出了信息的实质就是"对于不确定性的描述和消除"。在这个公式中,熵值H(X)=0,表示事件完全确定,没有不确定性;熵值越大,表示随机事件的不确定性越大,信息量也就越大;对于一个只能取两个值的事件(如抛掷均匀硬币),其熵值最大为1;对于一个可以取n个值的事件,其最大熵为log2n。使用"熵"这个热力学的概念,也是香农的天才灵机一现,他巧妙的借助这一概念,将信息所能够包含的内容,和热力学中分子运动的复杂程度相类比,形象而又有力的阐述了信息论的内核。
在创建了信息论和信息熵后,这个理论在信息技术中得到了广泛的应用,特别是通信和编码等领域。这个理论也指出了,一个信息的"可压缩"的程度,也是和其信息熵是相关的,信息熵越大,内涵越丰富,可压缩性就越小,而且所有的压缩都是有一个理论极限,就是其信息熵的值。这也很容易解释为什么像文本这样的信息比较好压缩(压缩前后的信息编码规模比值比较大),而像视频、图片这样的文件非常难压缩,因为它们本质上已经被编码压缩了一遍,熵值是很大的,再次压缩的边界效益就比较低了。
在具体的信息压缩操作上,可以大致分为无损压缩和有损压缩两个大的类别。
无损压缩和有损压缩
对于逻辑严密,计算精确的数字化信息处理技术而言,当然希望信息的压缩操作是"无损失"的。就是在压缩信息解压后,数据能够完全恢复原样,对后续处理而言,这个过程是完全透明的。所以,这种压缩方法,经常用于一般信息,如文本文件、程序、数据库等等场合。
但同时人们在应用实践中也发现,在另外一些应用场合,特别是和对人类的内容呈现的场景,并不一定要求信息的完全精确的还原。这个想法,其实利用了人类的生物认知和感受缺陷,例如人眼难以分辨图形间微弱的色彩差异,人眼(其实是人脑)的角分辨率有限,人的听觉能感受到的声波频率范围有限等等。这样,对于图像、音视频这种应用场景,有一些信息和细节,其实是可以丢弃和忽略的。 对于呈现目标而言,所呈现的信息和原始信息相比,就是"有损失"的。也就是说,我们可以利用这个特性,设计出来对于信息的压缩处理方法,可以有意的忽略和简化一些信息,在不太影响信息感受的情况下,获得更好的压缩效率。相对而言,这些多媒体信息本身的规模也非常庞大,在这些领域,对信息进行更高效的压缩和处理,其实是一种刚性需求。
今天,人类社会已经完全进入到信息技术时代,多媒体技术也已经完全渗透到人类的日常生活当中,图像、音频、语音、视频等技术大量而广泛的使用,有损压缩的技术和生态体系还在不断发展,在这些方面,其实是比无损压缩丰富而庞大的多的。遗憾的是,鉴于篇幅和内容的限制,本文无法展开讨论。本文后续的内容,主要专注于讨论无损压缩算法和实现特别是在nodejs中使用zlib实现的相关内容。
压缩算法
在讨论具体的实现和操作之前,笔者觉得还是有必要稍微了解一些压缩算法的相关概念和理论基础。
压缩算法的评估和指标
首先,作为开发者,我们需要理解,如何评估一个压缩算法,是比较好的压缩算法。当然,应该包括以下指标和方便:
- 压缩率
首先当然就是压缩率了。就是一个软件或者算法能够在多大程度上,将数据和信息进行压缩,到尽量小的程度。这个可以简单的使用压缩前后的信息的字节数的比例来衡量。
这个定义非常简单而清晰,但实际情况却是远为复杂的。不同类型,不同内容的信息,可能对于不同的压缩算法都有不同的影响,很难有一个算法适应在所有的应用场景。
- 速度
压缩操作的目的,并不是为了压缩。所以我们希望这个过程尽可能的快。但通常情况下,速度和压缩率是矛盾的,更高的压缩率,通常需要更高级和复杂的压缩算法,压缩的过程和时间也越长。具体选择何种方式和算法,需要开发者或者用户,根据不同的情况进行权衡和选择。
- 资源占用
有一个比较容易让人们忽视的地方,是压缩操作过程中,压缩算法对于计算和内存资源的占用。因为现代化的操作系统都是多任务系统,压缩操作过分占用了CPU和内存,就会对其他程序造成影响,这是我们不希望看到的。所以我们要求压缩算法尽可能的高效简洁,不会占用太多计算资源,并且能够在压缩完成后及时释放这些资源占用。
- 可移植性
压缩算法应当易于在各种主流编程语言平台和操作系统平台上进行开发和移植,这样就可以将同一份数据,在不同的系统中进行一致的操作,大大减少在不同系统间转换的工作,提升数据和信息的处理效率和适用性。
- 灵活性
有一些压缩算法提供了一些灵活的选项,比如可选的压缩级别,让用户可以根据自己的实际情况和需求,在压缩时间和压缩比例之间进行权衡和选择,这对于用户是比较友好的。
- 开放性
数据压缩算法的开放性,对于其作为信息技术的基础设施的一部分是非常重要的。开放的技术和算法,有利于使用同行评议的方式对技术发展进行审视和评估;开源软件可以简化软件授权,降低软件采购和使用的费用。这些对于一项信息技术的推广和发展,都是比较有利的。
zlib程序库
在了解了一般性的对于压缩算法的要求之后,我们来具体了解一下在nodejs的zlib中,相关的算法和技术选择。
zlib并不是nodejs独有的技术,也不是一种压缩算法,而是一个封装了压缩算法的独立开发程序库。nodejs将其进行集成,作为一个系统模块提供给开发者使用。zlib使用ANSI C语言编写,可以方便的在几乎所有操作系统和处理器架构上编译运行,移植性非常好,而且作为开源软件程序库,zlib可以在任何场合免费使用,消除了软件和应用的授权成本。
在技术实现方面,zlib使用Deflate作为其基础的压缩算法。这个算法相对内存使用的效率比较高,在嵌入式系统或资源受限的硬件平台上也可以很好的工作,此外zlib还支持数据流压缩可以用于处理流式的数据,还提供了如头部数据操作、错误检测、钩子函数、自定义的内存分配和压缩策略等高级特性,这些都大大扩展了zlib的使用场景。让其成为现今这个互联网技术体系内,应用最为广泛的压缩算法程序和实现库。
Deflate算法
前面已经提到,zlib的主要压缩算法是Deflate。Deflate的原意是"放气",就是将一个充气的物体如轮胎或者气球进行放气,让其的瘪下去,体积变小,来形容数据压缩的过程,这个比喻非常形象和直观,但笔者觉得其实并不是特别恰当,因为压缩的本质是减小信息的占有空间,而非简单的将信息释放。
Deflate算法采用了一种叫做LZ77的压缩机制,它其实结合了两个主要步骤:
- 数据重复查找
算法程序会沿着数据流移动一个滑动窗口,并在窗口内查找重复出现的数据片段。一旦找到,就可以用一个指针(包含距离和长度)编码来替代重复数据。
- 霍夫曼编码(Huffman Coding)
对于无法找到重复的部分,再使用霍夫曼编码进行进一步压缩。其实这个编码方式,才是很多压缩算法的核心,我们在后面的章节中有更深入的探讨。
Deflate借助滑动窗口技术,可以很好的处理流式数据,并且可以减少内存使用,还通过提供多种内存级别选项,让开发者客户可以权衡内存使用和压缩率。
Deflate压缩操作的逆操作,当然就是Inflate(充气)了。Deflate压缩算法,也包括了这一部分的实现,从而完成一个完整闭环的数据处理流程。
霍夫曼编码
霍夫曼编码(Huffman Coding)是一种熵编码压缩算法,其核心思想是将出现概率较高的字符分配较短的二进制编码,而将概率较低的字符则使用较长的二进制,从而在整体上减小最终编码后数据的大小,达到无损压缩数据的目的。
我们先通过一个非常简化的案例,希望读者能够从原理和概念上对它有一个粗浅的认识。比如,我们想要对下列信息进行编码(为方便讨论,我们已经将信息转换成为4位一组的二进制数组,二进制形式的总长度为32):
1000 1001 1000 1000 0111 1000 0111 1010
首先,我们对这段信息进行频率分析并进行排序,得到的结果如下:
1000:4,0111:2,1001: 1,1010: 1
然后,我们需要提出一个有限长度的编码序列,序列中的编码长度可变,这个序列的特性是任何一个编码,都不是另一个编码的前缀,从而来避免编码的二义性,比如0-10-110-1110,编码序列的排序规则是从短到长。
随后,我们就可以用这个编码序列中的编码,按照编码长度和数量,依次进行映射,得到
1000 1001 1000 1000 0111 1000 0111 1010
0 110 0 0 10 0 10 1110
所以,011000100101110 就是最后得到的编码,这时二进制长度为15,显然信息得到了压缩。解压缩时,程序从数组开始进行遍历,依次将编码拆分成为编码数组,由于前缀编码的特性,可以进行唯一编码的拆分,然后进行反映射,还原原始信息。
当然,这只是非常基础的概念性原理,在工程实践中霍夫曼编码其实是考虑到压缩信息的动态性和计算性能效率的,它会使用一种树状结构(霍夫曼树)来帮助构建编码,下图就是它的基本结构和工作过程(这里使用字符编码更清楚一点):
首先根据字符频率构造一个优先队列,每次从队列取出频率最小的两个节点,构造一个新的内部节点作为它们的父节点,这时新节点的频率为两个子节点频率之和;以此频率,将新节点插入频率优先队列;重复此过程直到队列只剩一个根节点,就构建成为最终的霍夫曼树;然后根据左0右1的规则和树的路径为节点和树叶(需编码项目)分配编码,路径的长度决定编码的长度和数量;最后使用编码映射,对原始信息进行替换,得到最终的编码。
霍夫曼编码是非常经典的编码和压缩算法,它的应用早已超过了狭义的文件压缩的范畴,很多压缩软件、程序库、压缩格式、通信协议都和它相关,比如Deflate算法、zip/rar压缩文件、JPEG、MP3、gzip等等应用的核心,都有霍夫曼编码的影子。
zlib模块和应用
这一章节,我们来具体讨论一下,在nodejs中是如何使用zlib模块,来进行数据的处理的。
一般过程
实际在应用中的操作过程,是比较简单的。zlib模块提供了deflate方法来进行信息压缩,inflate方法来进行解压操作。这两个方法都是异步方法,输入输出的默认格式都是Buffer,实际应用可以根据需要进行promise化,和进行信息格式的转换,笔者编写了一个示例程序如下:
js
const { deflate, inflate } = require('node:zlib');
// zip
const z = (data) => new Promise((r,j)=>{
if (typeof data === "string") {
data = Buffer.from(data);
} else if (typeof data === "object"){
data = Buffer.from( JSON.stringify(data));
};
deflate(data, (err, buffer) => {
if (err) {
console.error('An error occurred:', err);
process.exitCode = 1;
}
// result
r(buffer);
});
});
// unzip buffer to string
const uz = (data) => new Promise((r,j)=>{
if (typeof data === "string") data = Buffer.from(data,"base64");
inflate(data, (err, buffer) => {
if (err) {
console.error('An error occurred:', err);
process.exitCode = 1;
}
r(buffer.toString());
});
});
// data
const odata = new Array(10).fill("China中国").join("");
const start = async()=>{
let buf = await z(odata);
console.log(Buffer.from(odata).length,buf.length);
let t = await uz(buf);
console.log("Inflate:", t);
}; start();
上面的程序,有一个有意思的地方,就是要压缩的基本信息,默认长度为11,压缩后的长度为19;重复5次,长度为55,压缩长度23;重复10次,压缩长度也是23。这个特性说明,数据越长,重复和冗余项目越多,压缩效果越好。极短的信息,其实是没有必要压缩的。
压缩格式
nodejs zlib库中,提供了很多和压缩和解压缩相关的方法。笔者觉得,它们的底层实现和功能其实都大同小异,但稍微不同的就是对于不同的压缩标准和格式进行了适配和优化。从文档上来看,nodejs zlib库支持以下几种压缩格式和方式:
- DefalteRaw
完全原始的defalte数据处理。相关方法包括zlib.DeflatRaw()和zlib.InflateRaw()。
- Deflate
在DefalteRaw的基础上,增加了zlib头信息,从而能够在HTTP场景中作为一个压缩协议使用。相关方法包括zlib.Deflate()和zlib.Inflate()。
- Gzip
同样基于deflate算法,但增加的是gzip头信息。实际上在一般的Web应用中,gzip的应用可能还要广泛一点。可能是由于技术继承性的原因(Unix/Linux系统原生支持gzip和.gz文件格式),gzip是Web服务器和浏览器软件默认支持的数据压缩格式。
相关方法包括 zlib.Gzip()和zlib.Unzip(),其中Unzip方法兼容Gzip和Defalte压缩数据(通过检测数据头信息实现)。还有一个zlib.Gunzip(),可以用于解压处理gzip流。
- Brotli
从时间和历史上来看,gzip技术已经使用了很长的时间,有点不适应于新的互联网应用环境了。所以,在Web应用的开发中,相关于HTTP流处理相关的操作,除了传统的gzip之外,zlib提供并建议使用Brotli-based Streams方式。
Brotli是一种由Google开发并且开源的无损数据压缩算法,可以理解为其为新的HTTPS和HTTP2时代设计和优化的新一代压缩算法。Brotli压缩率比传统的gzip更高(提高约20%),并且特别适合于HTML、CSS和JS等文本内容,能显著有效减小实际数据的传输大小。此外,采用Brotli模式解压数据流时,解压缩延迟较低,有利于Web页面加载等实时应用场景。在HTTP协议中使用Brotli时,需要在HTTP头部的Content-Encoding标记设置Brotli压缩(Content-Encoding标记为br,如Accept-Encoding: gzip, deflate, sdch, br),并且服务器和客户端都需要实现Brotli编解码支持。Brotli算法在2015年引入,现在已经基本上得到了主流的浏览器、Web服务平台和技术的广泛支持。
这里补充一点有趣的信息,就是Brotli算法,它其实内置了一个HTML/CSS文本的字典,所以可以获得对于这种类型文本编码的更好的性能。通常认为Brotli的压缩率较高,解压性能也比较好,但是压缩性能相对差一点,所以更适合做压缩信息的预处理(如相对固定的静态文件),而不太适合于实施压缩解压的场景。
zlib中Brotli算法相关的方法包括zlib.brotliCompress()和zlib.brotiliDecompress()等方法。但更建议的使用方式是使用Brotli-based streams,即基于Brotli压缩算法传输数据的数据流。下面来自nodejs技术文档示例代码,演示了如何从一个文件中创建Brotli流:
js
const stream = zlib.createBrotliCompress({
chunkSize: 32 * 1024,
params: {
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: fs.statSync(inputFile).size,
},
});
上述几种压缩方法,zlib都将其封装成为对应的类,可以在实例化之后操作,或者直接提供了方法的快捷方法,开发者可以根据喜好和实际情况选择使用。
HTTP服务集成
下面我们通过一段示例代码,来理解一下压缩算法如何与HTTP服务结合起来并产生效用的。示例代码来自Nodejs官方技术文档,笔者做了适当的编辑和裁剪。
js
// 引用
const zlib = require('node:zlib');
const http = require('node:http');
const fs = require('node:fs');
const { pipeline } = require('node:stream');
// 服务器
http.createServer((request, response) => {
// response content stream
const raw = fs.createReadStream('index.html');
// Store both a compressed and an uncompressed version of the resource.
response.setHeader('Vary', 'Accept-Encoding');
let acceptEncoding = request.headers['accept-encoding'] || "";
const onError = (err) => {
if (err) {
// If an error occurs, there's not much we can do because
// the server has already sent the 200 response code and
// some amount of data has already been sent to the client.
// The best we can do is terminate the response immediately
// and log the error.
response.end();
console.error('An error occurred:', err);
}
};
// Note: This is not a conformant accept-encoding parser.
// See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
if (/\bdeflate\b/.test(acceptEncoding)) {
response.writeHead(200, { 'Content-Encoding': 'deflate' });
pipeline(raw, zlib.createDeflate(), response, onError);
} else if (/\bgzip\b/.test(acceptEncoding)) {
response.writeHead(200, { 'Content-Encoding': 'gzip' });
pipeline(raw, zlib.createGzip(), response, onError);
} else if (/\bbr\b/.test(acceptEncoding)) {
response.writeHead(200, { 'Content-Encoding': 'br' });
pipeline(raw, zlib.createBrotliCompress(), response, onError);
} else {
response.writeHead(200, {});
pipeline(raw, response, onError);
}
}).listen(1337);
// 客户端
const request = http.get({ host: 'localhost',
path: '/',
port: 1337,
headers: { 'Accept-Encoding': 'br,gzip,deflate' } });
request.on('response', (response) => {
const output = fs.createWriteStream('example.com_index.html');
const onError = (err) => {
if (err) {
console.error('An error occurred:', err);
process.exitCode = 1;
}
};
switch (response.headers['content-encoding']) {
case 'br':
pipeline(response, zlib.createBrotliDecompress(), output, onError);
break;
// Or, just use zlib.createUnzip() to handle both of the following cases:
case 'gzip':
pipeline(response, zlib.createGunzip(), output, onError);
break;
case 'deflate':
pipeline(response, zlib.createInflate(), output, onError);
break;
default:
pipeline(response, output, onError);
break;
}
});
这段代码的级别过程是,服务端会创建一个HTTP服务,当客户端请求时,服务端会读取一个本地文件,再根据客户端请求可以接收的压缩形式,将文件内容进行压缩后,传输给客户端。客户端收到响应,可以从HTTP头中,得知响应数据的压缩格式,调用对应的解压缩算法,并将结果,保存在本地文件当中。这段程序,基本上就是实现了一个Web文件服务器和工作的功能。
这段代码的要点如下:
- 压缩格式和算法,是由客户端请求和服务端协商得到的
- 相关文件操作、请求和响应,都是使用数据流的方式,效率最高
- 在压缩过程中,zlib.createxxx()方法,可以创建传输流,它可以接收文件读取流作为输入,压缩处理后又作为写入流,输出到HTTP响应中;类似的,在客户端的响应处理也是类似
- 使用pipeline方法,可以连接输入流、传输流(压缩或解压)、输入流
Flush
flush的原意是"冲洗",可以想象抽水马桶冲洗的形象。在压缩数据操作过程中,开发者可能希望主动阶段性的传输压缩的内容并清空暂存区,而不是等待自动处理,可以使用flush方法。下面的代码可以帮助我们理解这个场景和概念:
js
const zlib = require('node:zlib');
const http = require('node:http');
const { pipeline } = require('node:stream');
http.createServer((request, response) => {
// For the sake of simplicity, the Accept-Encoding checks are omitted.
response.writeHead(200, { 'content-encoding': 'gzip' });
const output = zlib.createGzip();
let i;
pipeline(output, response, (err) => {
if (err) {
// If an error occurs, there's not much we can do because
// the server has already sent the 200 response code and
// some amount of data has already been sent to the client.
// The best we can do is terminate the response immediately
// and log the error.
clearInterval(i);
response.end();
console.error('An error occurred:', err);
}
});
i = setInterval(() => {
output.write(`The current time is ${Date()}\n`, () => {
// The data has been passed to zlib, but the compression algorithm may
// have decided to buffer the data for more efficient compression.
// Calling .flush() will make the data available as soon as the client
// is ready to receive it.
output.flush();
});
}, 1000);
}).listen(1337);
这个例子中,在http服务请求处理方法中,先使用createGzip创建一个写数据流,并和reponse连接了起来。随后,这个写数据流,每隔一秒钟,就生成并输出一段信息,但需要同时调用flush,将这个信息压缩后立刻写入response,就会传输给客户端,达到了可控内容输出的效果。
压缩级别和场景
以下内容,主要来自claude的说明,笔者进行了简单的整理。
deflate压缩有多个压缩级别可选,一共有9级,不同级别在压缩速度和压缩率之间存在权衡。以常规的HTML和文本内容为例:
-
deflate的默认压缩级别为6,稍稍偏向于压缩优先,它的压缩率在75%左右。
-
较低级别(1-3)压缩速度很快,适合对响应时间要求较高的场合,它的压缩率在65%左右,但它的速度比默认级别快2~3倍
-
较高级别(7-9)压缩时CPU开销较大,压缩速度较慢,通常任务比默认级别慢2~3倍,对于大文件更甚。当然它的压缩比也更高,可以达到80%以上
在了解了这些情况后,我们就可以在实际使用时需要根据场景权衡,对响应时间要求较高的实时通信可选低级别压缩 对带宽成本较高时可选高级别,以获取更小文件传输量;而一般网站静态资源常用默认级别6,在时间和空间上做适度权衡;对于文件存储,或者压缩文件后进行的传输,我们可以选择更高的压缩级别,获得更小的磁盘空间占用。
常数和选项
zlib库,提供了zlib.constants对象,保存了开发中可能需要的常数设置,在zlib原始代码中,它们在zlib.h中定义。详细内容可以参考Nodejs技术文档和zlib技术文档。
其他zlib对象和应用选项还可能包括:
- windowsBits: 滑动窗口长度
- memLevel: 内存级别,和memLevel结合来控制压缩流所使用的内存大小
- chunkSize: 流数据块大小
- level: 压缩级别
- flush:数据缓存区输出和刷新策略
小结
本文讨论了nodejs中,内置的zlib模块相关的内容,包括基本过程,相关的方法和参数,扩展应用方式等等。还以此为契机,更深入的讨论了数据压缩相关的内容,包括数据压缩的基本概念,类型,压缩算法,zlib,deflate和霍夫曼编码等。