Когда я работаю с файлами в 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 и target2const 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 в target1const src_copied = await src.copyTo(target1);// Переместим src в target2const 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.txtC:\Users\debgger\AppData\Local\Temp\fstb-KHT0zv\target2\src\test.txtПривет, хабр!Привет, хабр!Временный каталог удален
Как видите, получается лаконичный, удобный в написании и использовании код. Большинство типовых операций пишутся в одну строчку, нет кучи joinов для формирования сложных путей, проще выстраивать последовательность операций с файлами и директориям.
Заключение
Когда я начинал писать эту библиотеку, моей целью было упростить работу с файловой системой в Node.js. Считаю, что со своей задачей я справился. Работать с файлами при помощи FSTB гораздо удобнее и приятнее. На проекте, в котором я ее обкатывал, объем кода, связанный с файловой системой, уменьшился раза в два.
Если говорить о плюсах, которые дает FSTB, можно выделить следующее:
-
Сокращается объем кода
-
Код получается более декларативный и менее запутанный
-
Снижается когнитивная нагрузка при написании кода для работы с файловой системой.
-
Библиотека хорошо типизирована, что при наличии поддержки тайпингов в вашей IDE заметно упрощает жизнь.
-
Нет внешних зависимостей, так что она не притащит за собой в ваш проект ничего лишнего
-
Поддержка Node.js начиная с 10-й версии, поэтому можно использовать даже в проектах с довольно старой кодовой базой
Основной минус, о котором стоит сказать, это, синтаксис FSPath, который может сбивать с толку, если с кодом будут работать разработчики, незнакомые с библиотекой. В таком случае имеет смысл добавить в код поясняющие комментарии.
На этом, пожалуй, все. Надеюсь, что моя разработка будет полезна вам. Буду рад любой критике, комментариям, предложениям.
Исходный код библиотеки доступен в GitHub: https://github.com/debagger/fstb
С документацией можно ознакомиться здесь: https://debagger.github.io/fstb/
Благодарю за внимание!