数据压缩管道:zlib 与 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 内部的"后半段"。

名词解释

课后练习

  1. 为什么"加密 → 压缩"这个顺序,比"压缩 → 加密"更常见?
    • 答案:加密后的数据是"看起来随机"的字节,几乎不含重复模式,再压缩几乎压不动(压缩比≈100%);而先压缩(去重)→再加密,既省空间又安全。所以工程上通常"先压缩后加密"。
  2. 还原时 new Xor('secret') 放在 gunzip 之后,顺序反了会怎样?
    • 答案:会得到乱码。因为压缩时顺序是"原文 → xor → gzip",数据在 gzip 之后才被 xor 打乱;还原必须先 gunzip 得到 xor 后的字节,再 xor 一次才恢复原文。顺序颠倒就等于"用错钥匙开错锁"。
  3. 对一个已经是 .zip 的文件再做 gzip,体积会变小吗?
    • 答案:基本不会,甚至略增。zip 本身已是 DEFLATE 压缩,内部几乎没有可压缩的重复模式;再压只是多一层头开销。这提醒我们:压缩"已经压缩过的内容"通常徒劳。

总结

这一节把前面所有概念汇成了一条流水线,是 Buffer 与 Stream 的"毕业设计"。我想让你带走两个认知:第一,压缩本质也是流——zlib.createGzip 和你之前写的 Upper、Xor 是同一个 Transform 家族的成员,区别只是它做的事情更精妙;第二,流水线的顺序是有语义的,组合容易、还原难,还原必须严格逆序,这是工程里"可逆管道"的通用铁律(想想你每天用的管道:压缩→加密→传输,落地时一定是传输→解密→解压)。最后,压缩比这件事本身就是一面镜子:它告诉你"数据里有多少冗余"——日志、JSON、文本冗余多,压完惊喜;图片、视频、压缩包冗余少,再压徒劳。理解了这层,你就不会再问"为什么我的 MP4 用 gzip 没变小"这种经典问题了。走到这里,你已经能用 Node 原生的 Buffer + Stream + zlib,搭出和真实世界压缩备份系统同构的流水线。