数据压缩管道:zlib 与 Transform 组合流水线
本节目标
- 用内置
zlib的createGzip/createGunzip(本质是 Transform 流)做流式压缩 - 把"自定义转换流 + 压缩流"组合成一条多段流水线
- 理解压缩比,并验证"压缩→解压"能无损还原原始数据
压缩是流的绝佳秀场:zlib.createGzip() 本身就是一个 Transform——读入原始字节块,输出压缩后的字节块,边读边压,内存恒定。把它 pipe 到文件流上,就能流式压缩任意大文件。更进一步,你还能在压缩前插入自己的转换流(如加密),形成"多段流水线"。
// 运行环境:Node.js 14+(用内置 zlib,无需联网/装包)
// 保存为 buf-l6.js,执行:node buf-l6.js
const fs = require('fs')
const path = require('path')
const zlib = require('zlib')
const { Transform } = require('stream')
const { pipeline } = require('stream/promises')
const src = path.join(__dirname, 'big.json')
// 造一份可压缩的"大"文本(重复内容越多,压缩比越高)
const payload = JSON.stringify({
ts: Date.now(),
items: Array.from({ length: 50000 }, (_, i) => ({ id: i, name: 'item-' + i, note: '重复句子用于提升压缩率' }))
})
fs.writeFileSync(src, payload)
// 自定义转换流:XOR 流式加密(演示"在压缩前插入一段变换")
class Xor extends Transform {
constructor(key) { super(); this.key = Buffer.from(key); this.i = 0 }
_transform(chunk, enc, cb) {
const out = Buffer.alloc(chunk.length)
for (let j = 0; j < chunk.length; j++) out[j] = chunk[j] ^ this.key[this.i++ % this.key.length]
cb(null, out)
}
}
async function main() {
// 1) 基础:源 → gzip 压缩 → .gz
const gz = path.join(__dirname, 'big.json.gz')
await pipeline(fs.createReadStream(src), zlib.createGzip(), fs.createWriteStream(gz))
// 2) 组合流水线:源 → XOR 加密 → gzip 压缩 → .enc.gz
const enc2 = path.join(__dirname, 'big-enc.gz')
await pipeline(
fs.createReadStream(src),
new Xor('secret'), // 先加密(自定义 Transform)
zlib.createGzip(), // 再压缩(内置 Transform)
fs.createWriteStream(enc2)
)
// 3) 还原:注意顺序必须"反着来"——先 gunzip 再 xor
const back = path.join(__dirname, 'big-back.json')
await pipeline(
fs.createReadStream(enc2),
zlib.createGunzip(),
new Xor('secret'),
fs.createWriteStream(back)
)
const original = fs.readFileSync(src, 'utf8')
const restored = fs.readFileSync(back, 'utf8')
console.log('原始大小 :', (original.length / 1024).toFixed(1), 'KB')
console.log('gzip 压缩 :', (fs.statSync(gz).size / 1024).toFixed(1), 'KB (压缩比 ' + (fs.statSync(gz).size / original.length * 100).toFixed(1) + '%)')
console.log('加密+压缩 :', (fs.statSync(enc2).size / 1024).toFixed(1), 'KB')
console.log('解压还原一致:', original === restored)
}
main().catch((e) => { console.error(e); process.exit(1) })
.finally(() => {
const clean = ['big.json', 'big.json.gz', 'big-enc.gz', 'big-back.json']
clean.forEach((f) => { try { fs.unlinkSync(path.join(__dirname, f)) } catch {} })
})压缩比 = 压缩后大小 / 原始大小。文本、JSON、日志这种"重复多"的内容压缩比很高(可能压到 10%~30%);已经是压缩格式(如 PNG、MP4、zip)的文件再 gzip 几乎压不动,甚至变大。
和前面课程的呼应:zlib 的 DEFLATE 算法 = LZ77(找重复串)+ Huffman 编码(最优前缀码)。你会在 crypto 章亲手实现 Huffman——那正是 gzip 内部的"后半段"。
名词解释
- 压缩管道(Compression Pipeline):把"压缩流"接入数据管道,使数据在流动过程中被实时压缩,无需先把整份数据载入内存。文件备份、网络传输前压缩都靠它。
- DEFLATE:gzip/zip 背后的核心算法,组合了 LZ77(用"前面出现过的位置+长度"替代重复内容)与 Huffman 编码(给高频字节更短的编码)。
- 流式压缩(Streaming Compression):一边读原始数据、一边输出压缩数据,内存只保留当前块。这意味着你能"边下载边解压""边生成边压缩"超大文件。
- 流水线组合:多个
Transform用pipeline串起来,每段各司其职(如 加密 → 压缩 → 打包)。关键规则:还原时顺序必须完全相反(解包 → 解压 → 解密)。
课后练习
- 为什么"加密 → 压缩"这个顺序,比"压缩 → 加密"更常见?
- 答案:加密后的数据是"看起来随机"的字节,几乎不含重复模式,再压缩几乎压不动(压缩比≈100%);而先压缩(去重)→再加密,既省空间又安全。所以工程上通常"先压缩后加密"。
- 还原时
new Xor('secret')放在gunzip之后,顺序反了会怎样?- 答案:会得到乱码。因为压缩时顺序是"原文 → xor → gzip",数据在 gzip 之后才被 xor 打乱;还原必须先
gunzip得到 xor 后的字节,再 xor 一次才恢复原文。顺序颠倒就等于"用错钥匙开错锁"。
- 答案:会得到乱码。因为压缩时顺序是"原文 → xor → gzip",数据在 gzip 之后才被 xor 打乱;还原必须先
- 对一个已经是
.zip的文件再做gzip,体积会变小吗?- 答案:基本不会,甚至略增。zip 本身已是 DEFLATE 压缩,内部几乎没有可压缩的重复模式;再压只是多一层头开销。这提醒我们:压缩"已经压缩过的内容"通常徒劳。
总结
这一节把前面所有概念汇成了一条流水线,是 Buffer 与 Stream 的"毕业设计"。我想让你带走两个认知:第一,压缩本质也是流——zlib.createGzip 和你之前写的 Upper、Xor 是同一个 Transform 家族的成员,区别只是它做的事情更精妙;第二,流水线的顺序是有语义的,组合容易、还原难,还原必须严格逆序,这是工程里"可逆管道"的通用铁律(想想你每天用的管道:压缩→加密→传输,落地时一定是传输→解密→解压)。最后,压缩比这件事本身就是一面镜子:它告诉你"数据里有多少冗余"——日志、JSON、文本冗余多,压完惊喜;图片、视频、压缩包冗余少,再压徒劳。理解了这层,你就不会再问"为什么我的 MP4 用 gzip 没变小"这种经典问题了。走到这里,你已经能用 Node 原生的 Buffer + Stream + zlib,搭出和真实世界压缩备份系统同构的流水线。