讲讲存档文件的包装设计

2022-06-30 23:36:12 +08:00
 kinglisky

原文发在掘金,一点实现想法,还请赐教~

https://juejin.cn/post/7114706551373299719

楔子

为什么讲这个?很简单,因为做需求碰到了,没找到什么特别有用的最佳实践,这里分享一些自己的思路。

需求背景是最近在撸的一个编辑器,编辑器基于 Electron 实现,桌面端编辑类的软件有个存档就很正常了。

存档文件

归档文件,又作存档文件,是由一个或多个计算机文件以及元数据组成的文件,用于将多个数据文件收集到一个文件中,以便于传输和存储,或者压缩以减少存储空间。也称打包文件,归档并压缩时常称为压缩文件。通常会存储目录结构,错误检测与纠正信息,注释,有时还使用加密

存档文件十分常见,最常见的如:

或者说一种文件格式就是一种存档表现,存档文件大多支持以下一个或多个特性

最好理解的就是 zip 文件,其支持了多个文件的存储、压缩、加密与校验( CRC 校验文件完整性),其也是很多存档文件包装的常用格式。

存档文件格式

咋一看不同软件存档格式都是不一样的,但其内部实现一般逃不出以下的套路:

专有格式文件

这类存档文件一般由专业软件产生,其经过严格设计,比较典型的例子就是 Photoshop 所使用的 PSD 文件,其文件规范指定一系列字节区间数据定义。

附:Adobe Photoshop File Formats Specification

其他类似的文件还有 PDFFBX 及 Office 早期的存档文件 DOC 、XLS 、PPT 都为专有的二进制存档文件,这类专有存档格式依赖其开放的文件标准,没公开其文件规范则很难进行解析。

基于现有文件包装

鲁迅曾说过:

“这个世界上本没有那么多文件,改后缀的人多了,也便成了新文件”

很好理解,很多软件生成的存档文件不过是将常见的文件进行二次包装修改后缀所得,常用于包装的格式有:JSON 、XML/HTML 与 ZIP 。

基于 ZIP

Sketch 文件就是个很典型的例子,其文件本质就是一个 zip 文件,改后缀后可直接看到文件内容:

附:Sketch File format

还有就是常见的 Office 存档( DOCX 、XLSX 、PPTX...),其本质还是个 ZIP 包,文件的后缀中的 X 表示其内部文件描述是基于 Office Open XML 实现的。

基于 JSON

excalidraw 的存档文件( excalidraw )与 processon 的存档文件( pos )其都是基于单个 JSON 文件封装。

基于 XML/HTML

顺手扒了下语雀的存档文件( lake ),其存档是基于单个 XML/HTML 实现的。

如何查看原始文件格式?

是否有方法可以快速知晓一个文件是否为包装格式?这时候就需要一个可以查看二进制内容的编辑器了,通过编辑器查看文件数据与组织结构,可以通过一些特定的标志判别出文件格式。

语雀 lake

processon pos

zip

对于 JSON 与 XML 一类的文本格式包装,通过 hex editor 是可以直接知晓内部数据结构的,但对于二进制文件而言,就需要一些特殊的文件标识来确定文件格式了。

以 ZIP 文件为例,其文件规范中一些文件头字段是固定的,如头部的 50 4B 03 04,这就是一个明显标识,我们可以通过其确定文件为压缩文件。

隐藏文件格式

当有人简单包装文件格式时,就一定会有人想把文件内容隐藏。

例如存档文件中涉及一些核心技术实现或是隐私数据,这时候隐藏存档文件内容就很重要了。

该如何实现呢?

上面讨论过了,文件存档不外乎两种思路:

专有格式的存档天然具有隐蔽性,只要不公开格式规范是很难破解存档信息的,当然其设计维护的成本也是比较高的。

包装类型的存档类型文件想要隐藏原始信息就需要对原始文件进行重新编码,以隐藏原始的格式特征。这里可以参考 Figma 存档文件( fig ),其存档文件明显是经过编码处理的。

至于具体的编码规则可以自行定义,一般是将原始文件转为 Buffer/ArrayBuffer 再针对其字节编码,例如:

文件读取解析时使用相反操作即可,只要不惧加解密与读写的性能维护的成本,相信您一定可以设计出最为隐蔽的文件~

自定义存档文件实现

实际演示一个基于 Zip 文件封装文件的例子,先来实现 Zip 文件的读写:

import fs from 'fs';
import path from 'path';
import AdmZip from 'adm-zip';

interface IArchiveFileWriteOptions {
    // 存档文件路径
    dest: string;
    files: Array<{
        // zip 文件内的文件路径
        dest: string;
        // 需要写入 zip 本地文件路径
        local?: string;
        // 需要写入 zip 数据
        source?: Buffer | string;
    }>;
}

class ZipFile {
    async read(entry: string): Promise<AdmZip> {
        return new AdmZip(entry);
    }

    async write(options: IArchiveFileWriteOptions): Promise<void> {
        const { dest, files } = options;
        const zip = new AdmZip();
        // 往 zip 容器中写入文件
        files.forEach((file) => {
            const { dest: destName, source, local } = file;
            if (source) {
                if (Buffer.isBuffer(source)) {
                    zip.addFile(destName, source);
                    return;
                }
                zip.addFile(destName, Buffer.from(source, 'utf-8'));
                return;
            }
            if (local) {
                zip.addLocalFile(local, destName);
                return;
            }
        });
        const zipFileBuffer = await zip.toBufferPromise();
        await fs.promises.writeFile(dest, zipFileBuffer);
    }
}

(async function main() {
    const zipFile = new ZipFile();
    const dest = path.resolve(__dirname, 'demo.myfile');
    await zipFile.write({
        dest,
        files: [
            {
                dest: 'content.text',
                source: '扶桑若木',
            },
        ],
    });
    console.log('write:', dest);
    const zipRes = await zipFile.read(dest);
    console.log('content.text --->', zipRes.readAsText('content.text'));
})();

目前并未对 demo.myfile 进行加密处理,所以可以看到 zip 文件头的标识:

接下来针对原始 zip 文件做 AES 加密处理:

import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import AdmZip from 'adm-zip';
import { streamToBuffer, bufferToStream } from './src/utils/stream';

import type { Transform } from 'stream';

interface IMyFileWriteOptions {
    // 存档文件路径
    dest: string;
    files: Array<{
        // zip 文件内的文件路径
        dest: string;
        // 需要写入 zip 本地文件路径
        local?: string;
        // 需要写入 zip 数据
        source?: Buffer | string;
    }>;
}

class MyCipher {
    algorithm: string = 'aes-128-cbc';
    password: string = '0000111122223333';
    salt: string = '0000111122223333';
    iv: string = '0000111122223333';

    get keyBuffer(): Buffer {
        return crypto.scryptSync(this.password, this.salt, 16);
    }

    get ivBuffer(): Buffer {
        return Buffer.from(this.iv, 'utf-8');
    }

    async createEncipher(): Promise<Transform> {
        return crypto.createCipheriv(this.algorithm, this.keyBuffer, this.ivBuffer);
    }

    async createDecipher(): Promise<Transform> {
        return crypto.createDecipheriv(this.algorithm, this.keyBuffer, this.ivBuffer);
    }
}

class MyFile {
    private MyCipher = new MyCipher();

    async read(entry: string): Promise<AdmZip> {
        const decipher = await this.MyCipher.createDecipher();
        const readStream = fs.createReadStream(entry);
        // 读取文件流 -> 解密
        const zipBuffer = await streamToBuffer(readStream.pipe(decipher));
        return new AdmZip(zipBuffer);
    }

    async write(options: IMyFileWriteOptions): Promise<void> {
        const { dest, files } = options;
        const zip = new AdmZip();
        // 往 zip 容器中写入文件
        files.forEach((file) => {
            const { dest: destName, source, local } = file;
            if (source) {
                if (Buffer.isBuffer(source)) {
                    zip.addFile(destName, source);
                    return;
                }
                zip.addFile(destName, Buffer.from(source, 'utf-8'));
                return;
            }
            if (local) {
                zip.addLocalFile(local, destName);
                return;
            }
        });
        const zipFileBuffer = await zip.toBufferPromise();
        const encipher = await this.MyCipher.createEncipher();
        const writeStream = fs.createWriteStream(dest);
        return new Promise((resolve) => {
            // zip buffer -> 加密 -> 写入文件
            bufferToStream(zipFileBuffer)
                .pipe(encipher)
                .pipe(writeStream)
                .on('close', () => {
                    resolve();
                });
        });
    }
}

(async function main() {
    const myFile = new MyFile();
    const dest = path.resolve(__dirname, 'demo.myfile');
    await myFile.write({
        dest,
        files: [
            {
                dest: 'content.text',
                source: '扶桑若木',
            },
        ],
    });
    console.log('write:', dest);
    const zipRes = await myFile.read(dest);
    console.log('content.text --->', zipRes.readAsText('content.text'));
})();

Zip 文件头已经看不到了~

存档文件清单

虽然讨论了很多关于存档文件包装与编码的实现,但实际针对存档内容组织也是很重要的一环,例如:

这些都需要详细设计,考虑后期升级与版本管理之类的操作~

其他

一些文件格式参考

1415 次点击
所在节点    分享发现
8 条回复
icyalala
2022-06-30 23:58:37 +08:00
我觉得,二进制文档的设计可以再详细展开说一下,
比如常见的规范 (例如 RIFF 那一坨)、向前兼容向后兼容、版本号、字节序、流式读写、完整性校验这些
thedrwu
2022-07-01 00:25:21 +08:00
可能现在没人听说过 OLE Structured Storage 了
geelaw
2022-07-01 06:04:51 +08:00
@thedrwu #2 IStorage 和 IStream 的噩梦,其实很多人天天都在用,例如 doc/ppt/xls/one 都是。
kinglisky
2022-07-01 09:38:27 +08:00
@thedrwu 学到了~
kinglisky
2022-07-01 09:38:45 +08:00
@icyalala 还在踩坑,哈哈~
codehz
2022-07-01 09:42:39 +08:00
还可以用 SQLite3 数据库作文存档格式(官方也对比了多种方法的优缺点 https://www.sqlite.org/appfileformat.html
SeanTheSheep
2022-07-01 16:58:51 +08:00
@codehz 以前还用 SQLite 数据库存配置信息,现在直接 JSON 一把梭
@icyalala 版本号,完整性校验的确很重要
codehz
2022-07-01 18:07:21 +08:00
@SeanTheSheep 配置信息通常确实不用数据库存((
版本号的话 sqlite3 给了一个应用版本号的全局字段(指不依赖具体表),拿来做迁移非常方便(
相比 zip 这种,读写速度更好而且也可以压缩(手动 deflate 存字段,甚至配合自定义函数和视图以及触发器可以做透明压缩,无需修改程序其他部分的代码)

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/863324

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX