|
| 1 | ++++ |
| 2 | +title = "重新造轮子系列(二):文件备份" |
| 3 | +date = 2025-03-02T11:57:00-08:00 |
| 4 | +lastmod = 2025-03-02T21:11:37-08:00 |
| 5 | +tags = ["reinvent"] |
| 6 | +categories = ["ReInvent: 重新造轮子系列"] |
| 7 | +draft = false |
| 8 | +toc = true |
| 9 | ++++ |
| 10 | + |
| 11 | +## <span class="section-num">1</span> 前言 {#前言} |
| 12 | + |
| 13 | +既然我们已经有[单元测试]({{< relref "reinvent_unit_test" >}})框架来测试软件了,我们肯定不想已经写好的代码丢失掉。 |
| 14 | + |
| 15 | +对于重要的文件,一个必不可少的功能肯定是备份, 这样在丢失文件之后可以重新恢复。 |
| 16 | + |
| 17 | +今天我们就来写个简单的文件备份软件,类似 Git 这样的版本系统可以当作是高级版本的文件系统,因为它还支持切换到不同版本,对比版本间的差异等等功能,而我们不打算实现一个版本管理系统,只实现基础的文件备份功能。 |
| 18 | + |
| 19 | + |
| 20 | +## <span class="section-num">2</span> 实现思路 {#实现思路} |
| 21 | + |
| 22 | +{{< figure src="/ox-hugo/reinvent_file_backup_design.png" >}} |
| 23 | + |
| 24 | + |
| 25 | +### <span class="section-num">2.1</span> 校验文件是否变更 {#校验文件是否变更} |
| 26 | + |
| 27 | +我们不可能备份都将所有的文件备份一次,这样做效率太低了,我们应该只备份发生变更的文件,那么如何高效地判断文件是否发生变更呢? |
| 28 | + |
| 29 | +最简单粗暴的方式是把文件读取出来,然后与以备份的文件作对比,但是这样的效率太低,并且算法复杂度是: O(N), 即运行时间是随着文件内容增长而增长的,文件越长,对比越慢。 |
| 30 | + |
| 31 | +最优算法的复杂度是 `O(1)`, 我们希望可以通过常数时间内比较完文件内容。 |
| 32 | + |
| 33 | +我们可以使用 [密码哈希算法(Cryptographic hash algorithms)](https://en.wikipedia.org/wiki/Cryptographic_hash_function), 来实现判断文件是否发生变更,它有两个显著的特征: |
| 34 | + |
| 35 | +1. hash 函数的结果是定长,不会因输入变化而增加或减少 |
| 36 | +2. 只要输入的任意bit生成变更, hash 函数生成的结果都会不一样 |
| 37 | + |
| 38 | +因此我们可以将文件的内容使用密码哈希函数如 `sha1` 来hash, 通过比较两次的哈希结果是否一致来判断文件是否发生变更。 |
| 39 | + |
| 40 | + |
| 41 | +### <span class="section-num">2.2</span> 判断文件是否被备份 {#判断文件是否被备份} |
| 42 | + |
| 43 | +判断文件是否被备份就很直接了,只需要看下当前文件是否在目标路径存在。 |
| 44 | + |
| 45 | +再结合上文提到的,只备份内容发生变更的文件,那么我们可以使用哈希函数的结果作为目标路径的备份文件名。 |
| 46 | + |
| 47 | +假设有文件 `src/a.txt`, 它的文件内容的哈希结果是 `86f7e437faa5a7fce15d1ddcb9eaeaea377667b8`, 那么我们使用哈希值作为文件名备份到 `dst`, 即 `dst/86f7e437faa5a7fce15d1ddcb9eaeaea377667b8`. |
| 48 | + |
| 49 | +对于文件 `a.txt`, 只需要判断 `dst` 是否存在 `86f7e437faa5a7fce15d1ddcb9eaeaea377667b8`, 就知道 `a.txt` 是否被备份; |
| 50 | + |
| 51 | +更巧妙的是,如果的 `a.txt` 文件内容发生变化,那么它的哈希值就一定不再会是 `86f7e437faa5a7fce15d1ddcb9eaeaea377667b8` 那么查找文件不存在,也可以当作是未备份,直接重新备份。 |
| 52 | + |
| 53 | +下面的序列图就是low level design: |
| 54 | + |
| 55 | +{{< figure src="/ox-hugo/reinvent_file_backup_lowlevel_design.png" >}} |
| 56 | + |
| 57 | + |
| 58 | +### <span class="section-num">2.3</span> 性能优化 {#性能优化} |
| 59 | + |
| 60 | +备份涉及到非常多的文件IO操作,而IO恰恰就是 Nodejs 最擅长的领域, 毕竟曾经的 NodeJS 还有个项目叫做 `io.js`. |
| 61 | + |
| 62 | +NodeJS 的异步IO是基于 [libuv](https://github.com/libuv/libuv), 但是我们不需要支持使用 `libuv` 的API, 只需要把文件相关的操作封装在 `Promise` 里面,NodeJS就会帮我们在处理底层的 IO 调度, 尽可能地并发处理IO, 避免阻塞. |
| 63 | + |
| 64 | +```js |
| 65 | +export const hashExisting = (rootDir: string): Promise<PathHashPair[]> => { |
| 66 | + const pattern = `${rootDir}/**/*`; |
| 67 | + return new Promise((resolve, reject) => { |
| 68 | + glob(pattern) |
| 69 | + .then(matches => Promise.all( |
| 70 | + matches.map(path => statPath(path)) |
| 71 | + )) |
| 72 | + .then((pairs: PathStatPair[]) => pairs.filter( |
| 73 | + ([path, stat]) => stat.isFile())) |
| 74 | + .then((pairs: PathStatPair[]) => Promise.all( |
| 75 | + pairs.map(([path, stat]) => readPath(path)))) |
| 76 | + .then((pairs: PathContentPair[]) => Promise.all( |
| 77 | + pairs.map(([path, content]) => hashPath(path, content)) |
| 78 | + )) |
| 79 | + .then((pairs: PathHashPair[]) => resolve(pairs)) |
| 80 | + .catch(err => reject(err)) |
| 81 | + }) |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +更多关于 `Promise` 的内容,可以查看[这本书](https://javascript.info/async),它的解释非常到位. |
| 86 | + |
| 87 | + |
| 88 | +### <span class="section-num">2.4</span> 测试文件系统 {#测试文件系统} |
| 89 | + |
| 90 | +备份文件的设计我们已经分析和实现完了,接下来肯定是需要编写单元测试来测试我们的函数的,我们的文件备份涉及到非常多的文件操作,免不了要和文件系统打交道,包括创建文件,查找文件等等。 |
| 91 | + |
| 92 | +单元测试的其中一个原则就是要尽量屏蔽掉外部系统的依赖,以保证我们只聚焦在测试功能本身,文件系统的读写更像是集成测试需要做的事情, 各种操作也很容易把文件目录结构给搞乱,导致单元测试失败。 |
| 93 | + |
| 94 | +所以我们希望可以使用一个 mock object 来把文件系统 mock 掉,[`mock-fs`](https://github.com/tschaub/mock-fs) 这个库做的就是这样的事情, 它可以把程序中的文件操作都 mock 掉,实际操作的是内存对象而非文件系统. |
| 95 | + |
| 96 | +{{< figure src="/ox-hugo/reivent_file_backup_mock_fs.jpg" >}} |
| 97 | + |
| 98 | +我们就可以在每个单元测试运行时,任意构造任何想要的文件目录,并且保证文件操作都是在操纵内存对象,而不会直接作用到文件系统,保证单元测试的相互隔离。 |
| 99 | + |
| 100 | +```js |
| 101 | +import mock from 'mock-fs' |
| 102 | + |
| 103 | +describe('checks for pre-existing hashes using mock filesystem', () => { |
| 104 | + beforeEach(() => { |
| 105 | + mock({ |
| 106 | + 'bck-0-csv-0': {}, |
| 107 | + 'bck-1-csv-1': { |
| 108 | + '0001.csv': 'alpha.js,abcd1234', |
| 109 | + 'abcd1234.bck': 'alpha.js content' |
| 110 | + }, |
| 111 | + 'bck-4-csv-2': { |
| 112 | + '0001.csv': ['alpha.js,abcd1234', |
| 113 | + 'beta.txt,bcde2345'].join('\n'), |
| 114 | + '3024.csv': ['alpha.js,abcd1234', |
| 115 | + 'gamma.png,3456cdef', |
| 116 | + 'subdir/renamed.txt,bcde2345'].join('\n'), |
| 117 | + '3456cdef.bck': 'gamma.png content', |
| 118 | + 'abcd1234.bck': 'alpha content', |
| 119 | + 'bcde2345.bck': 'beta.txt became subdir/renamed.txt' |
| 120 | + } |
| 121 | + }) |
| 122 | + }) |
| 123 | + |
| 124 | + afterEach(() => { |
| 125 | + mock.restore() |
| 126 | + }) |
| 127 | +}) |
| 128 | +``` |
| 129 | + |
| 130 | +上面的代码就构造出下如下的文件目录: |
| 131 | + |
| 132 | +```sh |
| 133 | +├── bck-0-csv-0 |
| 134 | +├── bck-1-csv-1 |
| 135 | +│ ├── 0001.csv |
| 136 | +│ └── abcd1234.bck |
| 137 | +└── bck-4-csv-2 |
| 138 | +├── 0001.csv |
| 139 | +├── 3028.csv |
| 140 | +├── 3456cdef.bck |
| 141 | +├── abcd1234.bck |
| 142 | +└── bcde2345.bck |
| 143 | +``` |
| 144 | + |
| 145 | + |
| 146 | +## <span class="section-num">3</span> 使用示例 {#使用示例} |
| 147 | + |
| 148 | +```sh |
| 149 | +> tree . |
| 150 | +. |
| 151 | +├── backup.ts |
| 152 | +├── check-existing-files.ts |
| 153 | +├── hash-existing-promise.ts |
| 154 | +├── main.ts |
| 155 | +├── manifest.ts |
| 156 | +├── reinvent_file_backup.org |
| 157 | +├── run-hash-existing-promise.ts |
| 158 | +├── stream-copy.ts |
| 159 | +└── test |
| 160 | + ├── bck-0-csv-0 |
| 161 | + ├── bck-1-csv-1 |
| 162 | + │ ├── 0001.csv |
| 163 | + │ └── abcd1234.bck |
| 164 | + ├── bck-4-csv-2 |
| 165 | + │ ├── 0001.csv |
| 166 | + │ ├── 3028.csv |
| 167 | + │ ├── 3456cdef.bck |
| 168 | + │ ├── abcd1234.bck |
| 169 | + │ └── bcde2345.bck |
| 170 | + ├── test-backup.js |
| 171 | + ├── test-find-mock.js |
| 172 | + └── test-find.js |
| 173 | + |
| 174 | +5 directories, 18 files |
| 175 | + |
| 176 | +> npx tsx main.ts -s . -d /tmp/backup -f json -v |
| 177 | +[INFO] Destination directory ensured: /tmp/backup |
| 178 | +[INFO] Starting backup from '.' to '/tmp/backup' |
| 179 | +[INFO] Copied 8 files from /Users/ramsayleung/code/javascript/reinvent/file_backup to /tmp/backup |
| 180 | +Backup completed in: 15.96ms |
| 181 | +Backup completed successfully! |
| 182 | + |
| 183 | +> ls -alrt /tmp/backup |
| 184 | +total 88 |
| 185 | +drwxrwxrwt 23 root wheel 736 2 Mar 17:06 .. |
| 186 | +-rw-r--r--@ 1 ramsayleung wheel 1056 2 Mar 21:02 6bd385393bd0e4a4f9a3b68eea500b88165033b1.bck |
| 187 | +-rw-r--r--@ 1 ramsayleung wheel 1649 2 Mar 21:02 8b0bc65c42ca2ae9095bb1c340844080f2f054da.bck |
| 188 | +-rw-r--r--@ 1 ramsayleung wheel 9795 2 Mar 21:02 464240b6ef1f03652fefc56152039c0f8d105cfe.bck |
| 189 | +-rw-r--r--@ 1 ramsayleung wheel 636 2 Mar 21:02 d0f548d134e99f1fcc2d1c81e1371f48d9f3ca0c.bck |
| 190 | +-rw-r--r--@ 1 ramsayleung wheel 182 2 Mar 21:02 7fa1b33f68d734b406ddb58e3f85f199851393db.bck |
| 191 | +-rw-r--r--@ 1 ramsayleung wheel 666 2 Mar 21:02 369034de6e5b7ee0e867c6cfca66eab59f834447.bck |
| 192 | +-rw-r--r--@ 1 ramsayleung wheel 2533 2 Mar 21:02 02d5c238d29f9e49d2a1f525e7db5f420a654a3f.bck |
| 193 | +-rw-r--r--@ 1 ramsayleung wheel 3512 2 Mar 21:02 964c0245a5d8cb217d64d794952c80ddf2aecca8.bck |
| 194 | +drwxr-xr-x@ 11 ramsayleung wheel 352 2 Mar 21:02 . |
| 195 | +-rw-r--r--@ 1 ramsayleung wheel 1030 2 Mar 21:02 0000000000.json |
| 196 | +``` |
| 197 | + |
| 198 | +为什么 `file_backup` 目录里面有 18 个文件,只备份了8个文件呢?因为 `test` 目录里面所有的文件都是空的,所以备份时就跳过了。 |
| 199 | + |
| 200 | + |
| 201 | +## <span class="section-num">4</span> 总结 {#总结} |
| 202 | + |
| 203 | +我们就完成了一个文件备份软件的开发,功能当然还非常简单,还有非常多优化的空间,比如现在 `src` 目录的所有文件都会被平铺到 `dst` 目录,如果我们可以保存目录结构,那么就更好用了。 |
| 204 | + |
| 205 | +另外,使用哈希函数值作为文件名的确很巧妙,但是对于用户而已,如果不逐个打开文件,根本不知道哪个文件是对应哪个源文件等等。 |
| 206 | + |
| 207 | +如果想要实现一个更健壮易用的备份文件,可以参考下关于这 [`rsync` 系列的文章](https://michael.stapelberg.ch/posts/2022-06-18-rsync-overview/), `rsync` 是Linux 上非常流行的增量备份的文件,不仅可以备份本地文件,更可以把文件备份把远程服务器,非常强大。 |
| 208 | + |
| 209 | + |
| 210 | +## <span class="section-num">5</span> 参考 {#参考} |
| 211 | + |
| 212 | +- <https://third-bit.com/sdxjs/file-backup/> |
| 213 | +- <https://en.wikipedia.org/wiki/Cryptographic_hash_function> |
| 214 | +- <https://michael.stapelberg.ch/posts/2022-06-18-rsync-overview/> |
| 215 | +- <https://javascript.info/async> |
0 commit comments