Когда я работаю с файлами в 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);
Получим что-то в этом роде:
┌─────────┬──────────────────────┬─────────┐
│ (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 │
└─────────┴──────────────────────┴─────────┘
Бабель, конечно же, на первом месте ))
Усложним задачу. Допустим нам надо посмотреть, в каких модулях при разработке используется 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);
И получим:
┌─────────┬─────────────────────────────┬───────────────────────┐
│ (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
→ С документацией можно ознакомиться здесь
Благодарю за внимание!