这一章节,我们来讨论一下nodejs中,相关文件和文件系统方面的内容。
Javascript原本是为浏览器环境而设计的,起码在设计之初,显然是无所谓文件系统的。但如果要将其移植到服务器环境,对于文件系统和文件的支持和功能,是无法避免的。
因此,nodejs提供了一些专门的模块,包括fs(file system)、path等,用于相关的功能和处理。照例,本文的主要参考内容,来自nodejs官方技术文档,并以版本20为基础。
我们先来谈谈何为文件系统,和其操作的通用方法论。
文件系统和其通用方法论
作为一个非常基础的功能和需求,所有主流的应用和开发平台,都有文件系统模块。而且它们的实现和使用方式,也应该是类似的。笔者认为,一个相对成熟完整的支持开发用的功能化文件系统模块,应该具备以下一些功能:
- 文件系统级别的功能,如列表文件夹、文件的复制、移动和删除
- 读取文件信息和状态
- 读取文件内容
- 以扩展或者覆盖的写入文件内容
- 可以使用信息流的方式处理文件的读写
在后面的内容,我们就可以看到,nodejs对于文件系统模块功能和规划和设计,就是围绕着这些要点展开的。
nodejs文件系统模块
fs模块
在nodejs中,文件系统相关的操作模块的名称,就是fs(file system),作为内置模块,可以在nodejs应用中,直接引用,无需安装配置。
其实,对linux比较熟悉的开发者可能会发现,这个模块的很多方法,基本上就是操作系统中文件系统相关命令的复刻。熟悉了操作系统的文件操作,也能够很快的理解fs模块所提供的功能和特性。
fs方法操作模式
作为js的运行环境,nodejs应用一般都使用异步和回调的方式工作。fs模块也是如此。但基于开发便利性的考量,对于很多文件操作方法,fs模块还提供了其他的操作模式,让开发者可以根据情况和喜好选择使用。具体包括:
- 回调模式
这是最常见也是默认的模式,使用一个回调方法来操作文件。回调函数的定义,详见
- Promise模式
在比较新的nodejs版本中,提供了使用promise对文件操作方法。,可以使用await的方式使用。笔者觉得这应该也是将回调方法封装了一下,重新取了一个名字而已,就是一个语法糖
- 同步方法模式
为了更容易使用,fs模块为很多操作方法,提供了同步操作的版本。这些方法是可以直接调用的,不需要await修饰,使用起来很方便。但需要注意,一般情况下,这种同步方法的调用,需要配套相应的错误处理。这些方法名称就是在原型方法后面加一个"Sync"后缀,具体可以查看相关的技术文档。
下面的例子,可以用于理解这三种模式的操作细节:
js
// callback模式
const { unlink } = require('node:fs');
unlink('/tmp/hello', (err) => {
if (err) throw err;
console.log('successfully deleted /tmp/hello');
});
// promise模式
const { unlink } = require('node:fs/promises');
(async function(path) {
try {
await unlink(path);
console.log(`successfully deleted ${path}`);
} catch (error) {
console.error('there was an error:', error.message);
}
})('/tmp/hello');
// 同步方法模式
const { unlinkSync } = require('node:fs');
try {
unlinkSync('/tmp/hello');
console.log('successfully deleted /tmp/hello');
} catch (err) {
// handle the error
}
这里可能需要特别注意一下promise模式的方法引用方式。并且要注意,在promise模式下,有一个子类FileHandle,很多核心和重要的方法如read、write等是由这个承载的。
对于同步方法,应该只有部分方法可用使用这个模式工作,以技术文档为准。
常数
fs提供了一套常数fs.contants,来定义文件操作相关的文件状态、类型、权限等标记信息。在实际操作中,会大量使用,如果需要做很多文件处理和操作,相关开发者应当知晓并熟悉(下表)。
js
constants: [Object: null prototype] {
UV_FS_SYMLINK_DIR: 1,
UV_FS_SYMLINK_JUNCTION: 2,
O_RDONLY: 0,
O_WRONLY: 1,
O_RDWR: 2,
UV_DIRENT_UNKNOWN: 0,
UV_DIRENT_FILE: 1,
UV_DIRENT_DIR: 2,
UV_DIRENT_LINK: 3,
UV_DIRENT_FIFO: 4,
UV_DIRENT_SOCKET: 5,
UV_DIRENT_CHAR: 6,
UV_DIRENT_BLOCK: 7,
S_IFMT: 61440,
S_IFREG: 32768,
S_IFDIR: 16384,
S_IFCHR: 8192,
S_IFLNK: 40960,
O_CREAT: 256,
O_EXCL: 1024,
UV_FS_O_FILEMAP: 536870912,
O_TRUNC: 512,
O_APPEND: 8,
S_IRUSR: 256,
S_IWUSR: 128,
F_OK: 0,
R_OK: 4,
W_OK: 2,
X_OK: 1,
UV_FS_COPYFILE_EXCL: 1,
COPYFILE_EXCL: 1,
UV_FS_COPYFILE_FICLONE: 2,
COPYFILE_FICLONE: 2,
UV_FS_COPYFILE_FICLONE_FORCE: 4,
COPYFILE_FICLONE_FORCE: 4
},
这些常数主要包括5种类型:
- 文件访问权限,如R(读),W(写),X(执行),F(完全)等等
- 文件复制
- 文件打开方式,如只读、只写、读写、创建...
- 文件类型,如常规、目录、链接、Socket...
- 文件权限模式,如属主、群组、任意用户的各种操作权限
下面我们将会讨论文件操作相关的内容,为简单起见,我们都是以回调模式(Callback API)为例。如需promise或者同步的版本,详情可以查询并参考技术文档。
文件描述符(File Descriptor, FD)
文件描述符是nodejs fs模块进行文件相关操作的重要概念。笔者觉得其实是和操作系统中概念是一样的。
在POSIX系统中,对于每个进程,系统内核都会维护一个当前打开文件和资源的表。在这个表中,每个打开的文件,都有一个简单的数学标识,就是文件描述符。在操作系统层面,所有的文件系统的操作,都使用FD来标识和跟踪特定的文件。Windows系统也有类似的机制。所以nodejs能够将其抽象出来,让fs模块可以一致的进行文件操作,即文件描述符。这样,读写文件的操作就可以通过FD而非文件名进行。一个文件可以有多个文件描述符指向它,可以解决多个文件同时读写的问题。另外,FD还代表着对系统资源的占用,在文件使用完后应该关闭文件描述符以释放内核资源。
fs模块中,很多方法既可以支持使用文件名称,也可以使用文件描述符。
下面,我们将fs模块提供的方法,主要分成文件管理、文件操作和文件内容操作三个大的类型,进行讨论。
文件管理
fs提供了文件管理相关的功能,常用的有:
- realpath/realpath.natvie: 基于相对路径计算真实路径
- chmod: 可以用于修改文件的权限属性,应该同linux文件操作命令
- chown: 用于修改文件的属主信息
- stat: 检查和获取文件信息
- statfs: 检查和获取当前文件系统的信息
- exists: 检查文件的存在性,需要注意最好不要跟真实的读写操作一起使用(它们有错误处理机制)
- access: 检查是否有访问权限,或何种操作权限(读/写),注意事项同exists
- watch/unwatchFile: 监控文件和取消监控,由于实现技术的限制,此功能并非在所有平台可用
- utimes: 修改文件的时间戳
- lchmod/lchown/lutimes/lstat:
这些带"l"开头的方法,都是针对符号链接的操作,和同名称的文件操作类似,包括修改符号链接的权限、属主和操作时间等信息。
- fchmod/fchown/fstat/fdatasync/fsync/ftruncate/futimes/flchmod/flchown:
这些"f"开头的方法,都是对应操作的文件描述符版本,即使用文件描述符作为参数。
文件操作
fs提供的文件操作功能包括复制、移动、删除等等,如下:
- mkdir/rmdir: 创建文件夹,删除文件夹
- mkdtemp: 创建唯一的临时文件夹
- copyFile: 复制文件到目标位置
- cp: 更强大的复制功能,可以处理文件夹
- rename: 修改文件名称或者移动文件
- rm: 删除文件
- link: 文件链接操作(硬链接)
- unlink: 删除文件或者链接
- synlink: 文件符号链接,应该是软连接
- open: 打开文件,操作结果是一个文件描述符
- openAsBlob: 打开文件,得到一个Blob二进制数据对象
- opendir: 打开文件夹,得到一个文件夹对象
- readdir: 读取文件夹内容,得到一个目录文件和子文件夹列表
- createReadStream/creatWriteStream: 基于文件创建读取流和写入流,可用于更高效的文件传输如Web文件服务等
- close: 关闭文件描述符
文件内容操作
这部分的内容,一般才是我们在开发中,需要重点关注的内容。
- appendFile: 在文件结尾扩展内容,如常用的log操作
- truncate: 截断或扩展文件到给定长度,也可以用于快速清空文件内容
- read: 基于文件描述符读取文件内容,可以控制读取位置和长度,读取结果一般为buffer
- readv: 读取文件内容并写入一个ArrayBufferView数组中
- readFile: 基于文件名读取文件内容,要注意由于性能的考量,读取可能并非文件的全部内容,而是一部分内容,可能由操作系统和文件系统决定,可以循环读取进行处理
- readLink: 读取链接目标文件的内容
- write/writeFile: 写文件内容,基于buffer和位置,writeFile使用文件名
- writev: 使用ArrayBufferView写入数据
fs子类
除了fs模块和主类之外,fs还设计和提供了一些相关的子类,这里简单说明一下。
- FileHandle
FileHandle是fs/promises的子类型,即文件句柄对象。此对象提供了很多方便的操作方法,都可以用promise的方式使用。
- fs.Stats 文件元数据
文件相关信息。基本上复制了stat命令。它可以显示文件类型(文件、目录、链接),权限信息,所有者和属组,文件大小,时间戳(创建、访问、修改、变更),设备和块,inode编号和硬链接计数等。下面就是stat命令可以获取的信息:
shell
stat Caddyfile
文件:Caddyfile
大小:56 块:8 IO 块大小:4096 普通文件
设备:8,1 Inode: 2097155 硬链接:1
权限:(0644/-rw-r--r--) Uid: ( 1000/ yanjh) Gid: ( 1000/ yanjh)
访问时间:2023-07-18 16:13:59.185812180 +0800
修改时间:2023-07-18 16:14:57.314168158 +0800
变更时间:2023-07-18 16:14:57.314168158 +0800
创建时间:2023-07-18 16:13:59.185812180 +0800
- fs.StatFs 文件系统元数据
文件系统的相关信息。特别是一些统计信息如文件系统大小、可用磁盘空间等等。
- fs.StatWatcher 状态监控器
fs.StatWatcher是fs模块中用于监视文件状态变更的类。它实际上是一个EventEmitter,通过调用fs.watchFile()可以获得一个监控器实例。它会在文件状态发生变化时触发相关事件和回调函数。这些事件和信息包括文件名称、大小、最后访问时间等等信息。
- fs.Dir / fs.Dirent 文件夹和文件夹项目子类
这两个类用于文件夹相关的操作。通过调用如fs.opendir等方法可以返回一个Dir实例,它实际上是一个信息流,可以遍历获取文件夹中的项目。文件夹项目可以是文件夹内的文件,子文件夹或者链接等等。
下面的示例可以帮助我们理解其使用方式和它们之间的关系:
js
import { opendir } from 'node:fs/promises';
try {
const dir = await opendir('./');
for await (const dirent of dir)
console.log(dirent.name);
} catch (err) {
console.error(err);
}
- fs.WriteStream和fs.ReadStream
打开文件,创建读取流和写入流的流对象,继承自nodejs标准stream,打开流后续的处理方式就是标准的流信息处理。
应用场景和实例
前面我们讨论的内容,都是通用化的文件处理。但在实际业务中,除了通用化的处理方式之外,更多的可能是需要处理带有一些业务化属性或者具有特定格式的文件。这样,除了简单的加载文件内容之外,还需要根据这些文件的格式,进行相关的信息形式的转换,最后得到满足业务处理需求的数据和信息。
我们下面分别以一些常用的文件格式和应用场景为例展开探讨。
JS/JSON文件
.js文件和.json文件,都是js语言和nodejs原生支持的文件格式。
正常情况下,js文件不需要按照普通文件来进行处理,而是将其看待为模块来处理,就是在js文件中,可能需要export接口,然后在其他模块中,使用require或者import进行导入使用。
json文件是json格式的文本文件,里面的内容就是标准的json文本,我们可以使用require方法进行加载,加载后就是一个标准的json对象。
笔者经常使用特定的js和json文件作为配置信息使用,这样可以直接使用,不需要在作为文本形式进行解析。此外,如果使用js作为配置文件,加载时还可以进行简单的处理(比如将常数构造为常数对象),更加方便灵活。
TXT文件
fs提供的readFile方法可以非常简单的读取和加载任何类型的文件内容,读取的结果是一个buffer。如果我们预先能够确定要读取的是txt文件,并且知晓其编码方式的话,可以使用转换方法,将这个buffer转换为文本。下面就是这个操作的典型参考代码:
js
const fs = require("fs");
const doRead = (filename)=>{
try {
let buf = fs.readFileSync(filename);
console.log("Content:\n", buf);
console.log("Text:\n",buf.toString());
} catch (err) {
console.log("Error:",err.message);
}
}; doRead("./f.txt");
现实应用中,基于处理效率的考量,一般都不会将文本文件作为一个整体来处理,而是分成一个个小的部分(段)进行处理。最常见的分段就是分行,然后按照类似于流信息处理的方式,对文本行进行依次处理。
这个分行的操作,可以完全的手动处理读取流,但一般我们不会自己编写处理代码。因为nodejs还提供了一个readline模块,来满足类似的需求。readline的一个常用的使用方式,就是将输入流转化成为"行流"来进行处理,比如下面的代码:
js
const doLine = (filename)=>{
const rl = readline.createInterface({ input: fs.createReadStream(filename) });
let lines =[];
rl
.on('line', l => { // 逐行读取的回调
if (l?.trim().length>0) lines.push(l);
})
.on('close',() => { // 文件读取结束回调
console.log("Content:", lines);
});
}; doLine("./f.txt");
单纯的txt文件的读取和处理,在实际的应用开发中并不常见。笔者遇到的主要可能就是log文件的处理,或者csv文件(更底层)的处理。
CSV文件
CSV(Comma-Separated Values) 意为逗号分隔值,是最常见和常用的关系数据库数据交换格式。本质上而言,csv就是遵循某种格式规范,用于表示和存储结构化数据的TXT文件。基本上就是标准的关系型数据表的文本版本。它的格式规范是:
- 第一行通常表示字段定义(字段名称)
- 每条记录就是一行,结尾使用分行符
- 在行中,使用指定的分隔符将字段进行分隔,分隔符一般是","(CSV的名称来源),也可以选择分号或者tab
- 每行中字段定义分隔和数量是相同的,并且和第一行(定义行)中的定义应该是对应和匹配的
了解了以上的规范和定义,我们也可以方便的在txt处理的基础上,编写csv处理的程序。当然,如果是大量频繁的处理的话,也可以使用第三方程序库,它们通常比较完善和健壮,而且会提供更丰富的功能和设置。如下面的代码,就是基于csv-reader这个库,也是流读取处理的方式:
js
const
fs = require('fs'),
csvReader = require('csv-reader');
const doCSV = (filename)=>{
let creader = new csvReader({ parseNumbers: true, parseBooleans: true, trim: true });
fs
.createReadStream(filename, 'utf8')
.pipe(creader)
.on('data', row=>{
console.log('Row: ', row);
})
.on('end', function () {
console.log('Read End!');
});
}; doCSV("./f.csv");
这段代码的要点是:
- 基于一些设置,创建一个新的csvReader对象,它其实也是一个读取流,配置和选项详见技术文档
- 使用标准createReadStream方法,使用utf8编码,创建读取流
- 将读取流连接(pipe)到一个前面创建的reader对象中
- 后续就可以按照标准流方式来处理流数据对象和结束流事件了,这里就是监听data和end事件,实现其回调方法
- 这里的row就是csv记录,实际上是json array
有很多开发者提出,可以使用Excel来进行数据的交换。笔者觉得这并不是一个好的选择,csv之所以成为一个主流的结构化数据备份和交换技术,是有它的优势和原因的。首先,csv的格式简单,就是文本文件,兼容性强,在各个系统和平台上都可以很好的进行处理;csv对人的使用也比较友好,人工也可以进行处理;excel虽然好用,但它并不适合于数据交换,它的版本和设置众多,在不同的平台上很容易出现兼容的问题;而且excel本身是微软的私有技术,并不完全开放,为在更广泛的范围内交换和使用带来了风险。
DBF文件
笔者在业务应用开发中,也经常遇到需要加载和处理foxpro文件格式,后缀为dbf的数据文件。笔者没有时间和条件研究dbf文件格式,只是使用第三方软件库来进行业务处理。这里选择的是"dbffile",参考代码如下:
js
const { DBFFile } = require("dbffile");
const doDBF = async (dfile)=>{
let rows;
const fdb = await DBFFile.open(dfile, { encoding: "GBK"});
console.log("DBF, Records:", fdb.recordCount,", fields:", fdb.fields.map(v=> v.name).join(","));
console.time("DBF");
while(1) {
rows = await fdb.readRecords(10);
if (rows.length ==0) break;
// console.log("Rows:", rows);
}
console.timeEnd("DBF");
}; doDBF("./f.dbf");
这里有一些要点:
- 需要使用dbffile导出的对象DBFFile和open方法
- 注意await同步操作
- 可选读取使用的字符集,在中国有很多foxpro系统使用GBK作为默认字符集
- 打开时,可以获取如记录条目数量,字段列表等元信息
- 打开文件后,可以使用readRecords方法,并且支持批量读取
- 读取的结果是每行都会自动转化成为对象的数组,方便后续批量操作
- 可以使用循环读取,跳出条件是无新的读取数据
在笔者的开发电脑上,使用这个方法,遍历一个4个字段,记录数为514791条,文件大小为33M的dbf文件,耗时10.7秒。
Excel文件
excel文件,在版本和格式上是比较复杂的,所以一般情况下我们也是选择现有的第三方程序库,如"read-excel-file"。示例代码如下:
js
const xlsxFile = require('read-excel-file/node');
const doExcel =(filename)=>{
xlsxFile(filename).then(rows => {
console.log("Rows:\n");
console.table(rows);
});
}; doExcel("./f.xlsx");
当然,这是非常简单的情况,只是为了提供一个思路,和展示程序的基本运行方式和流程。实际上,由于excel格式的复杂性,在实际应用中要考虑内置格式、多表单、版本支持等很多的情况,要在具体实践中,具体的处理。
Web文件服务
通过Web提供文件服务,是一种常见的应用需求。比如图片服务、文件下载等都是这种应用。使用nodejs,借助内置的fs和http模块,开发者可以在不需要第三方程序库的情况下,开发基于Web技术的文件服务。实现这一功能的参考代码如下:
js
const
http = require('http'),
fs = require('fs');
http.createServer(function(req, rep) {
let filename = __dirname + "/music/song.mp3";
let stat = fileSystem.statSync( filename );
rep.writeHead(200, {
'Content-Type': 'audio/mpeg',
'Content-Length': stat.size
});
// pipe file stream to response
fileSystem.createReadStream(filePath).pipe(rep);
})
.listen(2000);
这里只是展示基本功能和流程,为了简单起见,没有在请求和响应直接进行操作,而是直接使用pipe方法,将文件的读取流发送到http响应当中。
和成熟的Web文件服务或者软件如nginx等相比,自己实现文件的传输,在业务系统中,主要是为了控制访问的权限和过程。如可以对请求进行验证,检查请求是否正常,处理请求的信息,输出文件的同时进行相关的业务操作等等。
同时,基于文件流的处理方式,对应用系统的性能和资源占用,也不会造成太大的负担,是一个方便和容易实现的可控Web文件服务的技术方案。
变更监控
基于nodejs的fs模块,可以实现文件或者文件夹的变更监控,它通常使用fs.watch方法达成,相关的参考实现代码如下:
js
const fs = require("fs");
fs.watch('./w.txt', (event, filename) => {
console.log(`事件: ${event}, 文件名:${filename}`);
});
变更监控的可能的应用场景包括:
- 监控一个文件夹,在文件夹中的内容改变时,对文件进行操作,比如自动备份,传输,编转码,日志记录等等
- nodejs应用程序对某个配置信息文件进行监控,当配置信息修改时,修改应用程序内的变量或者配置信息
- 监控某个文件夹,内容变化时实时生成和记录文件的统计信息
fs的watch,在底层应该是基于操作系统和文件系统层面提供的功能,所以可能会有一些限制或者特性,针对不同的执行环境,开发者在具体使用时需要注意。
如果不使用监视-通知的模式,定期检查文件内容的变化,可能的实现代价会比较高,而且这种技术方案也比较优雅,应当优先考虑应用。
小结
本文讨论了nodejs技术体系中,有关于文件操作或者文件系统的部分。包括fs模块和相关的支持模块,功能特性,使用方式和应用场景等等。