Как стать автором
Обновить

FSTB – работа с файлами в Node.js без боли

Время на прочтение 10 мин
Количество просмотров 5.5K

Когда я работаю с файлами в 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

С документацией можно ознакомиться здесь

Благодарю за внимание!

Теги:
Хабы:
+15
Комментарии 2
Комментарии Комментарии 2

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн