1 前言

既然我们已经有单元测试框架来测试软件了,我们肯定不想已经写好的代码丢失掉。

对于重要的文件,一个必不可少的功能肯定是备份, 这样在丢失文件之后可以重新恢复。

今天我们就来写个简单的文件备份软件,类似 Git 这样的版本系统可以当作是高级版本的文件系统,因为它还支持切换到不同版本,对比版本间的差异等等功能,而我们不打算实现一个版本管理系统,只实现基础的文件备份功能。

2 实现思路

2.1 校验文件是否变更

我们不可能备份都将所有的文件备份一次,这样做效率太低了,我们应该只备份发生变更的文件,那么如何高效地判断文件是否发生变更呢?

最简单粗暴的方式是把文件读取出来,然后与以备份的文件作对比,但是这样的效率太低,并且算法复杂度是: O(N), 即运行时间是随着文件内容增长而增长的,文件越长,对比越慢。

最优算法的复杂度是 O(1), 我们希望可以通过常数时间内比较完文件内容。

我们可以使用 密码哈希算法(Cryptographic hash algorithms), 来实现判断文件是否发生变更,它有两个显著的特征:

  1. hash 函数的结果是定长,不会因输入变化而增加或减少
  2. 只要输入的任意bit生成变更, hash 函数生成的结果都会不一样

因此我们可以将文件的内容使用密码哈希函数如 sha1 来hash, 通过比较两次的哈希结果是否一致来判断文件是否发生变更。

2.2 判断文件是否被备份

判断文件是否被备份就很直接了,只需要看下当前文件是否在目标路径存在。

再结合上文提到的,只备份内容发生变更的文件,那么我们可以使用哈希函数的结果作为目标路径的备份文件名。

假设有文件 src/a.txt, 它的文件内容的哈希结果是 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8, 那么我们使用哈希值作为文件名备份到 dst, 即 dst/86f7e437faa5a7fce15d1ddcb9eaeaea377667b8.

对于文件 a.txt, 只需要判断 dst 是否存在 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8, 就知道 a.txt 是否被备份;

更巧妙的是,如果的 a.txt 文件内容发生变化,那么它的哈希值就一定不再会是 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 那么查找文件不存在,也可以当作是未备份,直接重新备份。

下面的序列图就是low level design:

2.3 性能优化

备份涉及到非常多的文件IO操作,而IO恰恰就是 Nodejs 最擅长的领域, 毕竟曾经的 NodeJS 还有个项目叫做 io.js.

NodeJS 的异步IO是基于 libuv, 但是我们不需要支持使用 libuv 的API, 只需要把文件相关的操作封装在 Promise 里面,NodeJS就会帮我们在处理底层的 IO 调度, 尽可能地并发处理IO, 避免阻塞.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export const hashExisting = (rootDir: string): Promise<PathHashPair[]> => {
    const pattern = `${rootDir}/**/*`;
    return new Promise((resolve, reject) => {
        glob(pattern)
            .then(matches => Promise.all(
                matches.map(path => statPath(path))
            ))
            .then((pairs: PathStatPair[]) => pairs.filter(
                ([path, stat]) => stat.isFile()))
            .then((pairs: PathStatPair[]) => Promise.all(
                pairs.map(([path, stat]) => readPath(path))))
            .then((pairs: PathContentPair[]) => Promise.all(
                pairs.map(([path, content]) => hashPath(path, content))
            ))
            .then((pairs: PathHashPair[]) => resolve(pairs))
            .catch(err => reject(err))
    })
}

更多关于 Promise 的内容,可以查看这本书,它的解释非常到位.

2.4 测试文件系统

备份文件的设计我们已经分析和实现完了,接下来肯定是需要编写单元测试来测试我们的函数的,我们的文件备份涉及到非常多的文件操作,免不了要和文件系统打交道,包括创建文件,查找文件等等。

单元测试的其中一个原则就是要尽量屏蔽掉外部系统的依赖,以保证我们只聚焦在测试功能本身,文件系统的读写更像是集成测试需要做的事情, 各种操作也很容易把文件目录结构给搞乱,导致单元测试失败。

所以我们希望可以使用一个 mock object 来把文件系统 mock 掉,mock-fs 这个库做的就是这样的事情, 它可以把程序中的文件操作都 mock 掉,实际操作的是内存对象而非文件系统.

我们就可以在每个单元测试运行时,任意构造任何想要的文件目录,并且保证文件操作都是在操纵内存对象, 而不会直接作用到文件系统,保证单元测试的相互隔离。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import mock from 'mock-fs'

describe('checks for pre-existing hashes using mock filesystem', () => {
    beforeEach(() => {
        mock({
            'bck-0-csv-0': {},
            'bck-1-csv-1': {
                '0001.csv': 'alpha.js,abcd1234',
                'abcd1234.bck': 'alpha.js content'
            },
            'bck-4-csv-2': {
                '0001.csv': ['alpha.js,abcd1234',
                             'beta.txt,bcde2345'].join('\n'),
                '3024.csv': ['alpha.js,abcd1234',
                             'gamma.png,3456cdef',
                             'subdir/renamed.txt,bcde2345'].join('\n'),
                '3456cdef.bck': 'gamma.png content',
                'abcd1234.bck': 'alpha content',
                'bcde2345.bck': 'beta.txt became subdir/renamed.txt'
            }
        })
    })

    afterEach(() => {
        mock.restore()
    })
})

上面的代码就构造出下如下的文件目录:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
├── bck-0-csv-0
├── bck-1-csv-1
│   ├── 0001.csv
│   └── abcd1234.bck
└── bck-4-csv-2
├── 0001.csv
├── 3028.csv
├── 3456cdef.bck
├── abcd1234.bck
└── bcde2345.bck

3 使用示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
> tree .
.
├── backup.ts
├── check-existing-files.ts
├── hash-existing-promise.ts
├── main.ts
├── manifest.ts
├── reinvent_file_backup.org
├── run-hash-existing-promise.ts
├── stream-copy.ts
└── test
    ├── bck-0-csv-0
    ├── bck-1-csv-1
    │   ├── 0001.csv
    │   └── abcd1234.bck
    ├── bck-4-csv-2
    │   ├── 0001.csv
    │   ├── 3028.csv
    │   ├── 3456cdef.bck
    │   ├── abcd1234.bck
    │   └── bcde2345.bck
    ├── test-backup.js
    ├── test-find-mock.js
    └── test-find.js

5 directories, 18 files

> npx tsx main.ts -s . -d /tmp/backup -f json -v
[INFO] Destination directory ensured: /tmp/backup
[INFO] Starting backup from '.' to '/tmp/backup'
[INFO] Copied 8 files from /Users/ramsayleung/code/javascript/reinvent/file_backup to /tmp/backup
Backup completed in: 15.96ms
Backup completed successfully!

> ls -alrt /tmp/backup
total 88
drwxrwxrwt  23 root         wheel   736  2 Mar 17:06 ..
-rw-r--r--@  1 ramsayleung  wheel  1056  2 Mar 21:02 6bd385393bd0e4a4f9a3b68eea500b88165033b1.bck
-rw-r--r--@  1 ramsayleung  wheel  1649  2 Mar 21:02 8b0bc65c42ca2ae9095bb1c340844080f2f054da.bck
-rw-r--r--@  1 ramsayleung  wheel  9795  2 Mar 21:02 464240b6ef1f03652fefc56152039c0f8d105cfe.bck
-rw-r--r--@  1 ramsayleung  wheel   636  2 Mar 21:02 d0f548d134e99f1fcc2d1c81e1371f48d9f3ca0c.bck
-rw-r--r--@  1 ramsayleung  wheel   182  2 Mar 21:02 7fa1b33f68d734b406ddb58e3f85f199851393db.bck
-rw-r--r--@  1 ramsayleung  wheel   666  2 Mar 21:02 369034de6e5b7ee0e867c6cfca66eab59f834447.bck
-rw-r--r--@  1 ramsayleung  wheel  2533  2 Mar 21:02 02d5c238d29f9e49d2a1f525e7db5f420a654a3f.bck
-rw-r--r--@  1 ramsayleung  wheel  3512  2 Mar 21:02 964c0245a5d8cb217d64d794952c80ddf2aecca8.bck
drwxr-xr-x@ 11 ramsayleung  wheel   352  2 Mar 21:02 .
-rw-r--r--@  1 ramsayleung  wheel  1030  2 Mar 21:02 0000000000.json

为什么 file_backup 目录里面有 18 个文件,只备份了8个文件呢?因为 test 目录里面所有的文件都是空的,所以备份时就跳过了。

4 总结

我们就完成了一个文件备份软件的开发,功能当然还非常简单,还有非常多优化的空间,比如现在 src 目录的所有文件都会被平铺到 dst 目录,如果我们可以保存目录结构,那么就更好用了。

另外,使用哈希函数值作为文件名的确很巧妙,但是对于用户而已,如果不逐个打开文件,根本不知道哪个文件是对应哪个源文件等等。

如果想要实现一个更健壮易用的备份文件,可以参考下关于这 rsync 系列的文章 , rsync 是Linux 上非常流行的增量备份的文件,不仅可以备份本地文件,更可以把文件备份把远程服务器,非常强大。

5 参考