Когда я работаю с файлами в Node.js, меня не оставляет мысль, что я пишу очень много однотипного кода. Создание, чтение и запись, перемещение, удаление, обход файлов и подкаталогов, всё это обрастает неимоверным количеством бойлерплейта, который еще усугубляется странными названиями функций модуля fs
. Со всем этим можно жить, но меня не оставляла мысль, что можно сделать удобнее. Хотелось, чтобы такие элементарные вещи, как, например, чтение или запись текста (или json) в файл можно было написать в одну строчку.
Как итог этих размышлений, появилась библиотека FSTB, в которой я попытался улучшить способы взаимодействия с файловой системой. Решить, получилось у меня или нет вы сможете сами, прочитав эту статью и попробовав библиотеку в деле.
Предыстория
Работа с файлами в ноде проходит в несколько этапов: отрицание, гнев, торг... сначала мы получаем каким-либо образом путь к объекту файловой системы, потом проверяем его существование (при необходимости), потом работаем с ним. Работа с путями в ноде вообще вынесена в отдельный модуль. Самая классная функция для работы с путями, это path.join
. Реально крутая штука, которая, когда я стал ей пользоваться, сэкономила мне кучу нервных клеток.
Но с путями есть проблема. Путь - это строка, хотя при этом он по сути описывает местоположение объекта иерархической структуре. А раз уж мы имеем дело с объектом, почему бы для работы с ним не использовать такие же механизмы, как при работе с обычными объектами яваскрипта.
Главная проблема, это то, что объект файловой системы может иметь любое имя из разрешённых символов. Если, я сделаю у этого объекта методы для работы с ним, то получится, что, например, такой код: root.home.mydir.unlink
будет двусмысленным - а что, если у в директории mydir
есть директория unlink
? И что тогда? Я хочу удалить mydir
или обратиться к unlink
?
Однажды я экспериментировал с яваскриптовым Proxу и придумал интересную конструкцию:
const FSPath = function(path: string): FSPathType {
return new Proxy(() => path, {
get: (_, key: string) => FSPath(join(path, key)),
}) as FSPathType;
};
Здесь FSPath
– это функция, которая принимает на вход строку, содержащую в себе путь к файлу, и возвращающая новую функцию, замыкающую в себе этот путь и обернутая в Proxy
, который перехватывает все обращения к свойствам получившейся функции и возвращает новую функцию FSPath
, с присоединенным именем свойства в качестве сегмента. На первый взгляд выглядит странно, но оказалось, что на практике такая конструкция позволяет собирать пути интересным способом:
FSPath(__dirname).node_modules //работает аналогично path.join(__dirname, "node_modules")
FSPath(__dirname)["package.json"] //работает аналогично path.join(__dirname, "package.json")
FSPath(__dirname)["node_modules"]["fstb"]["package.json"] //работает аналогично path.join(__dirname, "node_modules", "fstb", "package.json")
Как результат, получаем функцию, которая при вызове возвращает сформированный путь. Например:
const package_json = FSPath(__dirname).node_modules.fstb["package.json"]
console.log(package_json()) // <путь к скрипту>/node_modules/fstb/package.json
Опять же, и что такого, обычные фокусы JS. Но тут я подумал – можно ведь возвращать не просто путь, а объект, у которого имеются все необходимые методы для работы с файлами и директориями:
Так и появилась библиотека FSTB – расшифровывается как FileSystem ToolBox.
Пробуем в деле
Установим FSTB:
npm i fstb
И подключим в проект:
const fstb = require('fstb');
Для формирования пути к файлу можно воспользоваться функцией FSPath
, либо использовать одно из сокращений: cwd
, dirname
, home
или tmp
(подробнее про них смотрите в документации). Также пути можно подтягивать из переменных окружения при помощи метода envPath
.
Чтение текста из файла:
fstb.cwd["README.md"]().asFile().read.txt().then(txt=>console.log(txt));
FSTB работает на промисах, так что можно использовать в коде async/await:
(async function() {
const package_json = await fstb.cwd["package.json"]().asFile().read.json();
console.log(package_json);
})();
Здесь мы десериализуем json из файла. На мой взгляд неплохо, мы одной строчкой объяснили, где лежит, что лежит и что с этим делать.
Если бы я писал это с помощью стандартных функций, получилось бы что-то такое:
const fs = require("fs/promises");
const path = require("path");
(async function() {
const package_json_path = path.join(process.cwd(), "package.json");
const file_content = await fs.readFile(package_json_path, "utf8");
const result = JSON.parse(file_content);
console.log(result);
})();
Это конечно не тот код, которым стоит гордиться, но на это примере видно, какой многословной получается работа с файлами при помощи стандартной библиотеки.
Другой пример. Допустим нужно прочитать текстовый файл построчно. Тут мне даже придумывать не надо, вот пример из документации Node.js:
const fs = require('fs');
const readline = require('readline');
async function processLineByLine() {
const fileStream = fs.createReadStream('input.txt');
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
// Note: we use the crlfDelay option to recognize all instances of CR LF
// ('\r\n') in input.txt as a single line break.
for await (const line of rl) {
// Each line in input.txt will be successively available here as `line`.
console.log(`Line from file: ${line}`);
}
}
processLineByLine();
Теперь попробуем сделать это при помощи FSTB:
(async function() {
await fstb.cwd['package.json']()
.asFile()
.read.lineByLine()
.forEach(line => console.log(`Line from file: ${line}`));
})();
Да, да я читер. В библиотеке есть эта функция, и под капотом работает тот самый код из документации. Но здесь интересно, что на ее выходе реализован итератор, который умеет filter
, map
, reduce
и т.д. Поэтому, если надо, например, читать csv, просто добавьте .map(line => line.split(','))
.
Запись в файл
Естественно, куда же без записи. Здесь тоже все просто. Допустим у нас есть строка и мы ее хотим записать в файл:
(async function() {
const string_to_write = 'Привет Хабр!';
await fstb.cwd['habr.txt']()
.asFile()
.write.txt(string_to_write);
})();
Можно дописать в конец файла:
await fstb.cwd['habr.txt']()
.asFile()
.write.appendFile(string_to_write, {encoding:"utf8"});
Можно сериализовать в json:
(async function() {
const object_to_write = { header: 'Привет Хабр!', question: 'В чем смысл всего этого', answer: 42 };
await fstb.cwd['habr.txt']()
.asFile()
.write.json(object_to_write);
})();
Ну и можно создать стрим для записи:
(async function() {
const file = fstb.cwd['million_of_randoms.txt']().asFile();
//Пишем в файл
const stream = file.write.createWriteStream();
stream.on('open', () => {
for (let index = 0; index < 1_000_000; index++) {
stream.write(Math.random() + '\n');
}
stream.end();
});
await stream;
//Проверяем количество записей
const lines = await file.read.lineByLine().reduce(acc => ++acc, 0);
console.log(`${lines} lines count`);
})();
Кстати, ничего странного не заметили? Я об этом:
await stream; // <= WTF?!!
Дело в том, что это не простой WriteStream
, а прокачанный до промиса. Точнее, не совсем полноценный промис, но хватает, чтобы await
корректно работал. Теперь можно начать работу со стримом и дождаться, когда он закончит работу с помощью await
.
Что еще можно делать с файлами
Итак, мы посмотрели, как можно писать и читать из файлов. Но что еще можно с ними делать при помощи FSTB? Да все тоже, что при помощи стандартных методов модуля fs.
Можно получить информацию о файле:
const stat = await file.stat()
console.log(stat);
Получим:
Stats {
dev: 1243191443,
mode: 33206,
nlink: 1,
uid: 0,
gid: 0,
rdev: 0,
blksize: 4096,
ino: 26740122787869450,
size: 19269750,
blocks: 37640,
atimeMs: 1618579566188.5884,
mtimeMs: 1618579566033.8242,
ctimeMs: 1618579566033.8242,
birthtimeMs: 1618579561341.9297,
atime: 2021-04-16T13:26:06.189Z,
mtime: 2021-04-16T13:26:06.034Z,
ctime: 2021-04-16T13:26:06.034Z,
birthtime: 2021-04-16T13:26:01.342Z
}
Можно посчитать хэш-сумму:
const fileHash = await file.hash.md5();
console.log("File md5 hash:", fileHash);
// File md5 hash: 5a0a221c0d24154b850635606e9a5da3
Переименовывать:
const renamedFile = await file.rename(`${fileHash}.txt`);
Копировать:
//Получаем путь к директории, в которой находится наш файл и
// создаем в ней директорию "temp" если она не существует
const targetDir = renamedFile.fsdir.fspath.temp().asDir()
if(!(await targetDir.isExists())) await targetDir.mkdir()
//Копируем файл
const fileCopy = await renamedFile.copyTo(targetDir)
const fileCopyHash = await fileCopy.hash.md5();
console.log("File copy md5 hash:", fileCopyHash);
// File md5 hash: 5a0a221c0d24154b850635606e9a5da3
И удалять:
await renamedFile.unlink();
Также можно проверить, существует ли файл, доступен ли он на чтение и запись:
console.log({
isExists: await file.isExists(),
isReadable: await file.isReadable(),
isWritable: await file.isWritable() });
Итак, весь джентельменский набор для работы с файлами в наличии, теперь посмотрим, что можно делать с директориями.
Директории: вишенка на торте и куча изюма
На мой взгляд, самая вкусная часть проекта – это работа с директориями. Когда я ее реализовал и попробовал в деле, мне самому жутко понравился результат. Давайте посмотрим, что может делать FSTB с директориями. Для работы с каталогами используется объект FSDir
, а получить его можно таким вот образом:
//Создем объект FSDir для node_modules:
const node_modules = fstb.cwd.node_modules().asDir();
Что можно с этим делать? Ну во-первых, мы можем итерировать подкаталоги и файлы в директории:
// Выводим в консоль все имена подкаталогов
await node_modules.subdirs().forEach(async dir => console.log(dir.name));
Здесь доступны методы filter, map, reduce, forEach, toArray. Можно, для примера посчитать объем подкаталогов, названия которых начинаются с символа «@» и отсортировать их по убыванию.
const ileSizes = await node_modules
.subdirs()
.filter(async dir => dir.name.startsWith('@'))
.map(async dir => ({ name: dir.name, size: await dir.totalSize() })).toArray();
fileSizes.sort((a,b)=>b.size-a.size);
console.table(fileSizes);
Получим что-то в этом роде:
----------T----------------------T---------¬
¦ (index) ¦ name ¦ size ¦
+---------+----------------------+---------+
¦ 0 ¦ '@babel' ¦ 6616759 ¦
¦ 1 ¦ '@typescript-eslint' ¦ 2546010 ¦
¦ 2 ¦ '@jest' ¦ 1299423 ¦
¦ 3 ¦ '@types' ¦ 1289380 ¦
¦ 4 ¦ '@webassemblyjs' ¦ 710238 ¦
¦ 5 ¦ '@nodelib' ¦ 512000 ¦
¦ 6 ¦ '@rollup' ¦ 496226 ¦
¦ 7 ¦ '@bcoe' ¦ 276877 ¦
¦ 8 ¦ '@xtuc' ¦ 198883 ¦
¦ 9 ¦ '@istanbuljs' ¦ 70704 ¦
¦ 10 ¦ '@sinonjs' ¦ 37264 ¦
¦ 11 ¦ '@cnakazawa' ¦ 25057 ¦
¦ 12 ¦ '@size-limit' ¦ 14831 ¦
¦ 13 ¦ '@polka' ¦ 6953 ¦
L---------+----------------------+----------
Бабель, конечно же, на первом месте ))
Усложним задачу. Допустим нам надо посмотреть, в каких модулях при разработке используется typescript и вывести версии. Это немного посложнее, но тоже получится довольно компактно:
const ts_versions = await node_modules
.subdirs()
.map(async dir => ({
dir,
package_json: dir.fspath['package.json']().asFile(),
}))
//Проверяем наличие package.json в подкаталоге
.filter(async ({ package_json }) => await package_json.isExists())
// Читаем package.json
.map(async ({ dir, package_json }) => ({
dir,
content: await package_json.read.json(),
}))
//Проверяем наличие devDependencies.typescript в package.json
.filter(async ({ content }) => content.devDependencies?.typescript)
// Отображаем имя директории и версию typescript
.map(async ({ dir, content }) => ({
name: dir.name,
ts_version: content.devDependencies.typescript,
}))
.toArray();
console.table(ts_versions);
И получим:
----------T-----------------------------T-----------------------¬
¦ (index) ¦ name ¦ ts_version ¦
+---------+-----------------------------+-----------------------+
¦ 0 ¦ 'ajv' ¦ '^3.9.5' ¦
¦ 1 ¦ 'ast-types' ¦ '3.9.7' ¦
¦ 2 ¦ 'axe-core' ¦ '^3.5.3' ¦
¦ 3 ¦ 'bs-logger' ¦ '3.x' ¦
¦ 4 ¦ 'chalk' ¦ '^2.5.3' ¦
¦ 5 ¦ 'chrome-trace-event' ¦ '^2.8.1' ¦
¦ 6 ¦ 'commander' ¦ '^3.6.3' ¦
¦ 7 ¦ 'constantinople' ¦ '^2.7.1' ¦
¦ 8 ¦ 'css-what' ¦ '^4.0.2' ¦
¦ 9 ¦ 'deepmerge' ¦ '=2.2.2' ¦
¦ 10 ¦ 'enquirer' ¦ '^3.1.6' ¦
...
Что же еще можно делать с директориями?
Можно обратиться к любому файлу или поддиректории. Для этого служит свойство fspath:
//Создаем объект FSDir для node_modules:
const node_modules = fstb.cwd.node_modules().asDir();
//Получаем объект для работы с файлом "package.json" в подкаталоге "fstb"
const package_json = node_modules.fspath.fstb["package.json"]().asFile()
Для того, чтобы не засорять временными файлами рабочую директорию иногда имеет смысл использовать каталог для временных файлов в директории temp операционной системы. Для этих целей в FSTB есть метод mkdtemp
.
Создание директории производится с помощью метода mkdir
. Для копирования и перемещения директории есть методы copyTo
и moveTo
. Для удаления - rmdir
(для пустых директорий) и rimraf
(если надо удалить директорию со всем содержимым).
Давайте посмотрим на примере:
// Создадим временную директорию
const temp_dir = await fstb.mkdtemp("fstb-");
if(await temp_dir.isExists()) console.log("Временный каталог создан")
// В ней создадим три директории: src, target1 и target2
const src = await temp_dir.fspath.src().asDir().mkdir();
const target1 = await temp_dir.fspath.target1().asDir().mkdir();
const target2 = await temp_dir.fspath.target2().asDir().mkdir();
//В директории src создадим текстовый файл:
const test_txt = src.fspath["test.txt"]().asFile();
await test_txt.write.txt("Привет, Хабр!");
// Скопируем src в target1
const src_copied = await src.copyTo(target1);
// Переместим src в target2
const src_movied = await src.moveTo(target2);
// Выведем получившуюся структуру
// subdirs(true) – для рекурсивного обхода подкаталогов
await temp_dir.subdirs(true).forEach(async dir=>{
await dir.files().forEach(async file=>console.log(file.path))
})
// Выведем содержимое файлов, они должны быть одинаковы
console.log(await src_copied.fspath["test.txt"]().asFile().read.txt())
console.log(await src_movied.fspath["test.txt"]().asFile().read.txt())
// Удалим временную директорию со всем содержимым
await temp_dir.rimraf()
if(!(await temp_dir.isExists())) console.log("Временный каталог удален")
Получим следующий вывод в консоли:
Временный каталог создан
C:\Users\debgger\AppData\Local\Temp\fstb-KHT0zv\target1\src\test.txt
C:\Users\debgger\AppData\Local\Temp\fstb-KHT0zv\target2\src\test.txt
Привет, Хабр!
Привет, Хабр!
Временный каталог удален
Как видите, получается лаконичный, удобный в написании и использовании код. Большинство типовых операций пишутся в одну строчку, нет кучи join’ов для формирования сложных путей, проще выстраивать последовательность операций с файлами и директориям.
Заключение
Когда я начинал писать эту библиотеку, моей целью было упростить работу с файловой системой в Node.js. Считаю, что со своей задачей я справился. Работать с файлами при помощи FSTB гораздо удобнее и приятнее. На проекте, в котором я ее обкатывал, объем кода, связанный с файловой системой, уменьшился раза в два.
Если говорить о плюсах, которые дает FSTB, можно выделить следующее:
Сокращается объем кода
Код получается более декларативный и менее запутанный
Снижается когнитивная нагрузка при написании кода для работы с файловой системой.
Библиотека хорошо типизирована, что при наличии поддержки тайпингов в вашей IDE заметно упрощает жизнь.
Нет внешних зависимостей, так что она не притащит за собой в ваш проект ничего лишнего
Поддержка Node.js начиная с 10-й версии, поэтому можно использовать даже в проектах с довольно старой кодовой базой
Основной минус, о котором стоит сказать, это, синтаксис FSPath, который может сбивать с толку, если с кодом будут работать разработчики, незнакомые с библиотекой. В таком случае имеет смысл добавить в код поясняющие комментарии.
На этом, пожалуй, все. Надеюсь, что моя разработка будет полезна вам. Буду рад любой критике, комментариям, предложениям.
> Исходный код библиотеки доступен в GitHub
> С документацией можно ознакомиться здесь
Благодарю за внимание!
marliotto
У меня тоже, стандартная библиотека для работы с файлами вызывает каждый раз неприятные ощущения.
Не понял преимуществ в задании пути как свойств объекта. По мне так, вариант
выглядит лучше и понятнее, чем ваш вариант
К тому же в первом случае IDE может подсказать путь и результат типизирован.
Вызов функции для получения объекта пути, создает дополнительную когнитивную нагрузку и подвержен ошибкам. В примере выше многие, при чтении кода, будут спотыкаться о вызов метода unlink(), так как, ожидается, что происходит удаление.
Так же, странно видеть атрибуты read и write, лучше либо переименовать на более очевидное, либо сделать их методами. Почему бы сразу не сделать readLineByLine() и writeText()?