如何使用不同的文件系统

Node.js 暴露了文件系统的许多功能。但并非所有文件系统都相同。以下是在使用不同的文件系统时,使您的代码保持简单和安全的建议最佳实践。

文件系统行为

在使用文件系统之前,您需要了解它的行为方式。不同的文件系统的行为方式不同,并且具有或多或少的特性:区分大小写、不区分大小写、保留大小写、Unicode 形式保留、时间戳分辨率、扩展属性、inode、Unix 权限、备用数据流等。

谨防从 process.platform 推断文件系统行为。例如,不要因为您的程序在 Darwin 上运行,就假设您正在使用不区分大小写的文件系统 (HFS+),因为用户可能正在使用区分大小写的文件系统 (HFSX)。同样,不要因为您的程序在 Linux 上运行,就假设您正在使用支持 Unix 权限和 inode 的文件系统,因为您可能位于特定的外部驱动器、USB 或网络驱动器上,而这些驱动器不支持这些特性。

操作系统可能不容易推断文件系统行为,但并非一切都丢失了。您可以探测文件系统以查看它的实际行为,而不是保留每个已知文件系统和行为的列表(这总是会不完整)。某些易于探测的特性的存在或不存在,通常足以推断其他更难探测的特性的行为。

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

避免最低公分母方法

您可能会试图使您的程序像最低公分母文件系统一样运行,方法是将所有文件名标准化为大写,将所有文件名标准化为 NFC Unicode 形式,并将所有文件时间戳标准化为 1 秒分辨率。这将是最低公分母方法。

不要这样做。您只能安全地与在每个方面都具有完全相同的最低公分母特性的文件系统进行交互。您将无法以用户期望的方式使用更高级的文件系统,并且您会遇到文件名或时间戳冲突。您肯定会通过一系列复杂的依赖事件丢失和损坏用户数据,并且您会创建难以甚至不可能解决的错误。

当您以后需要支持仅具有 2 秒或 24 小时时间戳分辨率的文件系统时会发生什么?当 Unicode 标准发展为包含稍微不同的标准化算法时(就像过去发生的那样)会发生什么?

最低公分母方法往往会尝试通过仅使用“可移植”系统调用来创建可移植程序。这会导致程序泄漏并且实际上不可移植。

采用超集方法

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

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

一旦您的程序中具有保留大小写的功能,如果需要与不区分大小写的文件系统交互,您总是可以实现不区分大小写的功能。但是,如果在您的程序中放弃保留大小写,您就无法安全地与保留大小写的文件系统进行交互。Unicode 形式保留和时间戳分辨率保留也是如此。

如果文件系统为您提供大小写混合的文件名,请将文件名保持为给定的确切大小写。如果文件系统为您提供混合 Unicode 形式或 NFC 或 NFD(或 NFKC 或 NFKD)的文件名,请将文件名保持为给定的确切字节序列。如果文件系统为您提供毫秒时间戳,请将时间戳保持为毫秒分辨率。

当您使用较小的文件系统时,您可以始终适当地进行降采样,并使用文件系统行为所需的比较函数,该文件系统是您的程序运行所在的文件系统。如果您知道文件系统不支持 Unix 权限,那么您不应该期望读取与您写入的相同的 Unix 权限。如果您知道文件系统不保留大小写,那么当您的程序创建 abc 时,您应该准备好在目录列表中看到 ABC。但是,如果您知道文件系统确实保留大小写,那么在检测文件重命名或文件系统是否区分大小写时,您应该认为 ABCabc 是不同的文件名。

保留大小写

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

Unicode 形式保留

保留大小写和 Unicode 形式保留是相似的概念。要理解为什么应该保留 Unicode 形式,请确保您首先理解为什么应该保留大小写。如果理解正确,Unicode 形式保留也同样简单。

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

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

例如,HFS+ 会将所有文件名标准化为一种几乎总是与 NFD 形式相同的形式。不要期望 HFS+ 的行为与 NTFS 或 EXT4 相同,反之亦然。不要尝试通过标准化永久更改数据,作为一种泄漏的抽象来掩盖文件系统之间的 Unicode 差异。这会产生问题,而不会解决任何问题。相反,保留 Unicode 形式,并将标准化仅用作比较函数。

Unicode 形式不区分

Unicode 形式不区分和 Unicode 形式保留是两种不同的文件系统行为,经常被彼此混淆。正如有时通过在存储和传输文件名时永久将文件名标准化为大写来错误地实现不区分大小写一样,有时通过在存储和传输文件名时永久将文件名标准化为特定的 Unicode 形式(在 HFS+ 的情况下为 NFD)来错误地实现 Unicode 形式不区分。可以通过仅使用 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 的 bug。 Node.js 返回的是文件系统存储的时间戳,并非所有文件系统都支持纳秒、毫秒或 1 秒的时间戳精度。 某些文件系统对于 atime 时间戳的精度甚至非常粗糙,例如,某些 FAT 文件系统为 24 小时。

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

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

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

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

适当使用规范化比较函数

确保您适当地使用大小写/ Unicode 形式/时间戳比较函数。 如果您在区分大小写的文件系统上工作,请勿使用不区分大小写的文件名比较函数。 如果您在 Unicode 形式敏感的文件系统上工作(例如,NTFS 和大多数 Linux 文件系统,它们保留 NFC 和 NFD 或混合 Unicode 形式),请勿使用 Unicode 形式不敏感的比较函数。 如果您在纳秒时间戳分辨率的文件系统上工作,请勿以 2 秒分辨率比较时间戳。

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

请注意,您的比较函数与文件系统的比较函数相匹配(或者如果可能,探测文件系统以查看它实际如何比较)。 例如,不区分大小写比简单的 toLowerCase() 比较更复杂。 事实上,toUpperCase() 通常比 toLowerCase() 更好(因为它以不同的方式处理某些外语字符)。 但更好的方法是探测文件系统,因为每个文件系统都有其自己的内置的大小写比较表。

例如,Apple 的 HFS+ 将文件名规范化为 NFD 形式,但此 NFD 形式实际上是当前 NFD 形式的旧版本,有时可能与最新 Unicode 标准的 NFD 形式略有不同。 不要期望 HFS+ NFD 始终与 Unicode NFD 完全相同。