如何处理不同的文件系统

Node.js 提供了许多文件系统特性。但并非所有文件系统都相同。以下是在处理不同文件系统时,为保持代码简洁和安全而建议的最佳实践。

文件系统行为

在与文件系统交互之前,你需要了解其行为。不同的文件系统行为各异,其特性也或多或少:大小写敏感、大小写不敏感、大小写保留、Unicode 形式保留、时间戳精度、扩展属性、inode、Unix 权限、备用数据流等。

警惕通过 process.platform 推断文件系统行为。例如,不要因为你的程序运行在 Darwin 上就假设你在处理一个大小写不敏感的文件系统(HFS+),因为用户可能正在使用一个大小写敏感的文件系统(HFSX)。同样,不要因为你的程序运行在 Linux 上就假设你正在处理一个支持 Unix 权限和 inode 的文件系统,因为你可能正在使用一个不支持这些功能的特定外部驱动器、USB 或网络驱动器。

操作系统可能不容易让你推断文件系统行为,但并非无计可施。与其维护一个包含所有已知文件系统及其行为的列表(这总是会不完整),你可以探测文件系统以了解其实际行为。通过探测一些易于检测的特性是否存在,通常足以推断出其他较难探测的特性的行为。

请记住,一些用户可能在工作树的不同路径上挂载了不同的文件系统。

避免采用“最小公分母”方法

你可能会倾向于让你的程序表现得像一个“最小公分母”的文件系统,例如将所有文件名规范化为大写,将所有文件名规范化为 NFC Unicode 形式,以及将所有文件时间戳规范化为 1 秒精度。这就是“最小公分母”方法。

不要这样做。你将只能与在各方面都具有完全相同“最小公分母”特征的文件系统安全地交互。你将无法以用户期望的方式处理更高级的文件系统,并且会遇到文件名或时间戳冲突。你几乎肯定会因一系列复杂的连锁事件而丢失和损坏用户数据,并且会产生难以甚至不可能解决的错误。

当你以后需要支持一个只具有 2 秒或 24 小时时间戳精度的文件系统时会发生什么?当 Unicode 标准更新,包含一个略有不同的规范化算法时(过去曾发生过这种情况),又会发生什么?

“最小公分母”方法倾向于通过仅使用“可移植”的系统调用来创建可移植的程序。但这会导致程序存在漏洞,实际上并不可移植。

采用“超集”方法

通过采用“超集”方法,充分利用你支持的每个平台的优势。例如,一个可移植的备份程序应该能在 Windows 系统之间正确同步 btimes(文件或文件夹的创建时间),并且不应销毁或更改 btimes,即使 Linux 系统不支持 btimes。同样,这个可移植的备份程序应该能在 Linux 系统之间正确同步 Unix 权限,并且不应销毁或更改 Unix 权限,即使 Windows 系统不支持 Unix 权限。

通过让你的程序表现得像一个更高级的文件系统来处理不同的文件系统。支持所有可能特性的超集:大小写敏感、大小写保留、Unicode 形式敏感、Unicode 形式保留、Unix 权限、高精度纳秒时间戳、扩展属性等。

一旦你的程序支持了大小写保留,如果需要与大小写不敏感的文件系统交互,你总可以实现大小写不敏感。但如果你的程序放弃了大小写保留,你就无法安全地与保留大小写的文件系统交互。对于 Unicode 形式保留和时间戳精度保留也是如此。

如果一个文件系统提供给你的文件名是大小写混合的,那么就保持文件名的确切大小写。如果文件系统提供给你的文件名是混合 Unicode 形式或 NFC 或 NFD(或 NFKC 或 NFKD),那么就保持文件名的确切字节序列。如果文件系统提供给你一个毫秒级时间戳,那么就保持时间戳的毫秒级精度。

当你处理一个功能较弱的文件系统时,你总是可以根据程序运行所在文件系统的行为,使用比较函数进行适当的降级处理。如果你知道文件系统不支持 Unix 权限,那么就不应该期望能读到你写入的相同 Unix 权限。如果你知道文件系统不保留大小写,那么当你的程序创建 abc 时,你应该准备好在目录列表中看到 ABC。但如果你知道文件系统确实保留大小写,那么在检测文件重命名时,或者如果文件系统是大小写敏感的,你应该将 ABC 视为与 abc 不同的文件名。

大小写保留

你可能创建了一个名为 test/abc 的目录,但有时会惊讶地发现 fs.readdir('test') 返回 ['ABC']。这不是 Node.js 的错误。Node 返回的是文件系统存储的文件名,并非所有文件系统都支持大小写保留。有些文件系统会将所有文件名转换为大写(或小写)。

Unicode 形式保留

大小写保留和 Unicode 形式保留是类似的概念。要理解为什么应该保留 Unicode 形式,请先确保你理解为什么应该保留大小写。正确理解后,Unicode 形式保留同样简单。

Unicode 可以使用几种不同的字节序列来编码相同的字符。几个字符串可能看起来一样,但字节序列不同。在使用 UTF-8 字符串时,请注意你的期望是否符合 Unicode 的工作方式。正如你不会期望所有 UTF-8 字符都只编码为一个字节一样,你也不应该期望几个在人眼看来相同的 UTF-8 字符串具有相同的字节表示。这可能是你对 ASCII 的期望,但对 UTF-8 则不然。

你可能创建了一个名为 test/café 的目录(NFC Unicode 形式,字节序列为 <63 61 66 c3 a9>,且 string.length === 5),但有时会惊讶地发现 fs.readdir('test') 返回 ['café'](NFD Unicode 形式,字节序列为 <63 61 66 65 cc 81>,且 string.length === 6)。这不是 Node.js 的错误。Node.js 返回的是文件系统存储的文件名,并非所有文件系统都支持 Unicode 形式保留。

例如,HFS+ 会将所有文件名规范化为一种几乎总是与 NFD 形式相同的形式。不要期望 HFS+ 的行为与 NTFS 或 EXT4 相同,反之亦然。不要试图通过规范化来永久更改数据,以此作为掩盖文件系统之间 Unicode 差异的拙劣抽象。这只会制造问题而不能解决任何问题。相反,应该保留 Unicode 形式,并仅将规范化用作比较函数。

Unicode 形式不敏感

Unicode 形式不敏感和 Unicode 形式保留是两种经常被混淆的不同文件系统行为。正如大小写不敏感有时被错误地实现为在存储和传输文件名时永久地将文件名规范化为大写一样,Unicode 形式不敏感有时也被错误地实现为在存储和传输文件名时永久地将文件名规范化为某种 Unicode 形式(在 HFS+ 的情况下是 NFD)。在不牺牲 Unicode 形式保留的情况下实现 Unicode 形式不敏感是可能的,而且要好得多,方法是仅将 Unicode 规范化用于比较。

比较不同的 Unicode 形式

Node.js 提供了 string.normalize('NFC' / 'NFD'),你可以用它将 UTF-8 字符串规范化为 NFC 或 NFD。你绝不应该存储这个函数的输出,而只应将其用作比较函数的一部分,以测试两个 UTF-8 字符串在用户看来是否相同。

你可以使用 string1.normalize('NFC') === string2.normalize('NFC')string1.normalize('NFD') === string2.normalize('NFD') 作为你的比较函数。使用哪种形式并不重要。

规范化速度很快,但你可能希望使用一个缓存作为比较函数的输入,以避免多次规范化同一个字符串。如果字符串不在缓存中,则对其进行规范化并缓存。注意不要存储或持久化这个缓存,只把它当作缓存来用。

请注意,使用 normalize() 要求你的 Node.js 版本包含 ICU(否则 normalize() 只会返回原始字符串)。如果你从网站下载最新版本的 Node.js,它将包含 ICU。

时间戳精度

你可能将文件的 mtime(修改时间)设置为 1444291759414(毫秒级精度),但有时会惊讶地发现 fs.stat 返回的新 mtime 是 1444291759000(1 秒级精度)或 1444291758000(2 秒级精度)。这不是 Node.js 的错误。Node.js 返回的是文件系统存储的时间戳,并非所有文件系统都支持纳秒、毫秒或 1 秒的时间戳精度。有些文件系统甚至对 atime 时间戳的精度非常粗糙,例如某些 FAT 文件系统的精度为 24 小时。

不要通过规范化损坏文件名和时间戳

文件名和时间戳是用户数据。就像你绝不会自动重写用户文件数据,将其转换为大写或将 CRLF 换行符规范化为 LF 一样,你也不应该通过大小写/Unicode 形式/时间戳规范化来更改、干扰或损坏文件名或时间戳。规范化只应用于比较,绝不能用于修改数据。

规范化实际上是一种有损的哈希码。你可以用它来测试某些类型的等价性(例如,几个字符串尽管字节序列不同,但看起来是否相同),但你绝不能用它来替代实际数据。你的程序应该按原样传递文件名和时间戳数据。

你的程序可以创建 NFC 形式的新数据(或它喜欢的任何 Unicode 形式组合),或者使用小写或大写文件名,或者使用 2 秒精度的时间戳,但你的程序不应该通过强制进行大小写/Unicode 形式/时间戳规范化来损坏现有的用户数据。相反,应该采用“超集”方法,在你的程序中保留大小写、Unicode 形式和时间戳精度。这样,你就能安全地与同样做法的文件系统交互。

适当使用规范化比较函数

确保你适当地使用大小写/Unicode 形式/时间戳比较函数。如果你正在处理一个大小写敏感的文件系统,不要使用大小写不敏感的文件名比较函数。如果你正在处理一个 Unicode 形式敏感的文件系统(例如 NTFS 和大多数保留 NFC、NFD 或混合 Unicode 形式的 Linux 文件系统),不要使用 Unicode 形式不敏感的比较函数。如果你正在处理一个纳秒级时间戳精度的文件系统,不要以 2 秒的精度比较时间戳。

为比较函数中的微小差异做好准备

小心确保你的比较函数与文件系统的比较函数相匹配(如果可能,探测文件系统以了解其实际比较方式)。例如,大小写不敏感比简单的 toLowerCase() 比较要复杂得多。实际上,toUpperCase() 通常比 toLowerCase() 更好(因为它对某些外语字符的处理方式不同)。但更好的做法是探测文件系统,因为每个文件系统都有其内置的大小写比较表。

举个例子,苹果的 HFS+ 将文件名规范化为 NFD 形式,但这种 NFD 形式实际上是当前 NFD 形式的一个旧版本,有时可能与最新的 Unicode 标准的 NFD 形式略有不同。不要期望 HFS+ NFD 总是与 Unicode NFD 完全相同。