[Перевод] Наблюдение за изменениями файлов в Node.js

d5c9-nk8c69t5qo8ryvoqhr4kic.png


Материал, перевод которого мы сегодня публикуем, посвящён организации наблюдения за изменениями файлов в Node.js. Автор материала, Дэйв Джонсон, говорит, что необходимость в системе наблюдения за файлами появилась у него в процессе создания IoT-проекта, связанного с кормлением аквариумных рыбок. Когда кто-то из членов семьи их кормит, он нажимает одну из трёх кнопок. В частности, речь идёт о кнопке на плате расширения, подключённой к Raspberry Pi, о кнопке Amazon Dash, и о кнопке в веб-интерфейсе. Любое из этих действий приводит к записи в лог-файл строчки с указанием даты, времени и типа события. В результате, взглянув на содержимое этого файла, можно понять, пора кормить рыбок или нет. Вот его фрагмент:

2018-5-21 19:06:48|circuit board
2018-5-21 10:11:22|dash button
2018-5-20 11:46:54|web


После того, как налажено формирование файла, нужно, чтобы система, основанная на Node.js, реагировала на события изменения этого файла и предпринимала необходимые действия. Здесь будет рассмотрено и проанализировано несколько подходов к решению данной задачи.

Пакеты для организации наблюдения за файлами и встроенные возможности Node.js


Этот материал посвящён исследованию встроенных возможностей Node.js по наблюдению за файлами. На самом деле, подобные задачи можно решать исключительно средствами Node, не прибегая к использованию пакетов сторонних разработчиков. Однако если вы не против внешних зависимостей или просто хотите как можно быстрее выйти на работающее решение, не вникая в детали, можете воспользоваться соответствующими пакетами. Например — пакетами chokidar и node-watch. Это — отличные библиотеки, которые основаны на внутренних возможностях Node по наблюдению за файловой системой. Пользоваться ими несложно, возлагаемые на них задачи они решают. Поэтому, если вам надо организовать наблюдение за файлами, не особенно вдаваясь в особенности реализации тех или иных вещей в Node, данные пакеты вам в этом помогут. Если же вас, помимо получения практического результата, интересует и то, как устроены соответствующие подсистемы Node, давайте вместе их исследуем.

Первые шаги


Для того чтобы исследовать различные средства Node для организации наблюдения за файлами, сначала создадим и настроим новый проект. Ориентироваться мы будем на начинающих разработчиков Node, поэтому будем описывать всё достаточно подробно.

Итак, для того, чтобы создать проект, создадим новую папку и перейдём в неё средствами терминала. В терминале надо выполнить следующую команду:

$ npm init -y


В ответ на неё система создаст файл package.json для Node.js-проекта.
Теперь установим пакет log-timestamp из npm и сохраним его в package.json в виде зависимости:

$ npm install --save log-timestamp


Пакет log-timestamp позволяет присоединять отметку времени к сообщениям, которые выводятся в консоль с использованием команды console.log. Это позволит анализировать время возникновения событий, связанных с наблюдением за файлами. Этот пакет нужен исключительно в учебных целях, а, например, если вы будете готовить нечто подобное тому, о чём мы будем говорить, для применения в продакшне, в log-timestamp у вас необходимости не будет.

Использование fs.watchFile


Встроенный метод Node.js fs.watchFile может показаться вполне логичным выбором для организации наблюдения за состоянием нашего лог-файла. Коллбэк, передаваемый этому методу, будет вызываться при каждом изменении файла. Испытаем fs.watchFile.

const fs = require('fs');
require('log-timestamp');

const buttonPressesLogFile = './button-presses.log';

console.log(`Watching for file changes on ${buttonPressesLogFile}`);

fs.watchFile(buttonPressesLogFile, (curr, prev) => {
  console.log(`${buttonPressesLogFile} file Changed`);
});


Здесь мы запускаем наблюдение за изменениями в файле button-pressed.log. Коллбэк вызывается после того, как файл меняется.

Функции обратного вызова передаются два аргумента типа fs.stats. Это — объект с данными о текущем состоянии файла (curr), и объект с данными о его предыдущем состоянии (prev). Это позволяет, например, узнать время предыдущей модификации файла, воспользовавшись конструкцией prev.mtime.

Если, после запуска вышеописанного кода, открыть файл button-pressed.log и внести в него изменения, программа на это отреагирует, в консоли появится соответствующая запись.

$ node file-watcher.js
[2018-05-21T00:54:55.885Z] Watching for file changes on ./button-presses.log
[2018-05-21T00:55:04.731Z] ./button-presses.log file Changed


Экспериментируя, можно заметить задержку между моментом внесения изменения в файл и моментом появления сообщения об этом в консоли. Почему? Всё дело в том, что метод fs.watchFile, по умолчанию, опрашивает файлы на предмет изменений каждые 5.007 секунды. Это время можно поменять, передав методу fs.watchFile объект с параметрами, содержащий свойство interval:

fs.watchFile(buttonPressesLogFile, { interval: 1000 }, (curr, prev) => {
  console.log(`${buttonPressesLogFile} file Changed`);
});


Здесь мы установили интервал опроса, равный 1000 миллисекунд, тем самым указав, что хотим, чтобы система опрашивала наш лог-файл каждую секунду.

Обратите внимание на то, что документация по fs.watchFile указывает на то, что функция обратного вызова в обработчике будет вызываться всякий раз, когда к файлу получают доступ. Я, готовя этот материал, работал в Node v9.8.0, и в моём случае система вела себя не так. Вызов коллбэка происходил лишь тогда, когда в наблюдаемый файл вносились изменения.

Использование fs.watch


Гораздо лучший способ для организации наблюдения за файлами представляет метод fs.watch. В то время как fs.watchFile тратит системные ресурсы на проведение опроса файлов, fs.watch полагается на операционную систему, на системные уведомления об изменениях файловой системы. В документации сказано, что Node использует механизм inotify в ОС семейства Linux, FSEvents в MacOS, и ReadDirectoryChangesW в Windows для получения асинхронных уведомлений при изменении файлов (сравните это с синхронным опросом файлов). Выигрыш в производительности, получаемый от использования fs.watch вместо fs.watchFile оказывается ещё более существенным, когда, например, надо следить за всеми файлами, находящимися в некоей директории, так как в качестве первого аргумента для fs.watch можно передать либо путь к конкретному файлу, либо — к папке. Испытаем fs.watch.

const fs = require('fs');
require('log-timestamp');

const buttonPressesLogFile = './button-presses.log';

console.log(`Watching for file changes on ${buttonPressesLogFile}`);

fs.watch(buttonPressesLogFile, (event, filename) => {
  if (filename) {
    console.log(`${filename} file Changed`);
  }
});


Здесь мы наблюдаем за тем, что происходит с лог-файлом, и, обнаружив изменения, выводим в консоль соответствующее сообщение.

Изменим лог-файл и посмотрим, что произойдёт. То, что будет описано ниже, происходит при запуске примера на Raspberry Pi (Raspbian), поэтому то, что увидите вы, запустив его на своей системе, может выглядеть иначе. Итак, вот что вывелось после того, как в файл были внесены изменения.

$ node file-watcher.js
[2018-05-21T00:55:52.588Z] Watching for file changes on ./button-presses.log
[2018-05-21T00:56:00.773Z] button-presses.log file Changed
[2018-05-21T00:56:00.793Z] button-presses.log file Changed
[2018-05-21T00:56:00.802Z] button-presses.log file Changed
[2018-05-21T00:56:00.813Z] button-presses.log file Changed


Интересно получается: в файл внесено одно изменение, а обработчик, реагирующий на изменение файла, был вызван четыре раза. Количество этих событий зависит от платформы. Возможно, то, что одно изменение вызывает несколько событий, связано с тем, что операция записи файла на диск длится некий отрезок времени X, и система обнаруживает несколько изменений файла на этом отрезке времени. Для того чтобы избавиться от таких вот «ложных срабатываний», нам надо модифицировать наше решение, сделать его менее чувствительным.

Вот одна техническая особенность fs.watch. Этот метод позволяет реагировать на события, которые возникают либо при переименовании файла (это — события rename), либо при изменении его содержимого (change). Если нам нужна точность и мы хотим наблюдать лишь за изменениями содержимого файла, код надо привести к следующему состоянию:

const fs = require('fs');
require('log-timestamp');

const buttonPressesLogFile = './button-presses.log';

console.log(`Watching for file changes on ${buttonPressesLogFile}`);

fs.watch(buttonPressesLogFile, (event, filename) => {
  if (filename && event ==='change') {
    console.log(`${filename} file Changed`);
  }
});


В нашем случае подобная модификация кода ничего принципиально не изменит, но, возможно, если вы будете строить собственную систему для наблюдения за состоянием файлов, этот приём вам пригодится. Кроме того, надо отметить, что, при экспериментах с этим кодом, событие rename удалось обнаружить при запуске Node под Windows, но не под Raspbian.

Попытка улучшения №1: сравнение моментов модификации файла


Нам нужно, чтобы обработчик вызывался лишь тогда, когда в лог-файл вносятся реальные изменения. Поэтому попытаемся улучшить код fs.watch за счёт наблюдения за моментом модификации файла, что позволит нам выявить реальные изменения и избежать ложных срабатываний обработчика.

const fs = require('fs');
require('log-timestamp');

const buttonPressesLogFile = './button-presses.log';

console.log(`Watching for file changes on ${buttonPressesLogFile}`);

let previousMTime = new Date(0);

fs.watch(buttonPressesLogFile, (event, filename) => {
  if (filename) {
    const stats = fs.statSync(filename);
    if (stats.mtime.valueOf() === previousMTime.valueOf()) {
      return;
    }
    previousMTime = stats.mtime;
    console.log(`${filename} file Changed`);
  }
});


Здесь мы записываем в переменную previousMTime значение предыдущего момента модификации файла и вызываем console.log только в тех случаях, когда время модификации файла меняется. Похоже, идея это хорошая и теперь всё должно работать так, как нам нужно. Проверим это.

$ node file-watcher.js
[2018-05-21T00:56:50.167Z] Watching for file changes on ./button-presses.log
[2018-05-21T00:56:55.611Z] button-presses.log file Changed
[2018-05-21T00:56:55.629Z] button-presses.log file Changed
[2018-05-21T00:56:55.645Z] button-presses.log file Changed


Результат, к сожалению, выглядит не намного лучше того, что мы видели в прошлый раз. Очевидно, система (Raspbian в данном случае) генерирует множество событий в процессе сохранения файла, и нам, для того, чтобы не видеть ненужных сообщений, придётся найти другой способ улучшения кода.

Попытка улучшения №2: сравнение контрольных сумм MD5


Создадим MD5-хэш (контрольную сумму) содержимого файла в начале работы, а затем, при каждом событии изменения файла, на которое реагирует fs.watch, посчитаем контрольную сумму ещё раз. Возможно, нам удастся избавиться от ненужных сообщений об изменении файла, если мы будем принимать во внимание состояние содержимого файла.

Для этого нам, сначала, понадобится установить пакет md5.

$ npm install --save md5


Теперь воспользуемся этим пакетом и напишем код, призванный выявить настоящие изменения файла с использованием контрольной суммы.

const fs = require('fs');
const md5 = require('md5');
require('log-timestamp');

const buttonPressesLogFile = './button-presses.log';

console.log(`Watching for file changes on ${buttonPressesLogFile}`);

let md5Previous = null;

fs.watch(buttonPressesLogFile, (event, filename) => {
  if (filename) {
    const md5Current = md5(fs.readFileSync(buttonPressesLogFile));
    if (md5Current === md5Previous) {
      return;
    }
    md5Previous = md5Current;
    console.log(`${filename} file Changed`);
  }
});


В этом коде мы используем подход, напоминающий тот, что мы применяли, сравнивая время модификации файла, но здесь мы анализируем изменения содержимого файла, используя его контрольную сумму. Посмотрим, как этот код поведёт себя на практике.

$ node file-watcher.js
[2018-05-21T00:56:50.167Z] Watching for file changes on ./button-presses.log
[2018-05-21T00:59:00.924Z] button-presses.log file Changed
[2018-05-21T00:59:00.936Z] button-presses.log file Changed


К сожалению, тут снова получилось не то, что нам нужно. Система, вероятно, выдаёт события об изменении файла в процессе сохранения файла.

Рекомендованный способ использования fs.watch


Мы рассмотрели различные варианты использования fs.watch, но так и не достигли того, чего хотели. Однако, не всё так плохо, ведь, в поиске решения, мы узнали много полезного. Предпримем ещё одну попытку достичь желаемого. На этот раз используем технологию устранения «дребезга» событий, введя в наш код небольшую задержку, которая позволит не реагировать на события об изменениях файла в пределах заданного временного окна.

const fs = require('fs');
require('log-timestamp');

const buttonPressesLogFile = './button-presses.log';

console.log(`Watching for file changes on ${buttonPressesLogFile}`);

let fsWait = false;
fs.watch(buttonPressesLogFile, (event, filename) => {
  if (filename) {
    if (fsWait) return;
    fsWait = setTimeout(() => {
      fsWait = false;
    }, 100);
    console.log(`${filename} file Changed`);
  }
});


Функция для подавления «дребезга» создана благодаря некоторой помощи пользователей StackOverflow. Как оказалось, задержки в 100 миллисекунд достаточно для выдачи всего одного сообщения при единственном изменении файла. При этом наше решение подходит и для случаев, когда файл подвергается достаточно частым изменениям. Вот как теперь выглядит вывод программы.

$ node file-watcher.js
[2018-05-21T00:56:50.167Z] Watching for file changes on ./button-presses.log
[2018-05-21T01:00:22.904Z] button-presses.log file Changed


Как видно, всё это отлично работает. Мы нашли волшебную формулу для построения системы наблюдения за файлами. Если вы поинтересуетесь кодом npm-пакетов для Node, которые направлены на наблюдение за изменениями файлов, вы обнаружите, что многие из них реализуют функции для фильтрации «дребезга». Мы же воспользовались похожим подходом, построив решение на базе стандартных механизмов Node, что позволило не только решить задачу, но и научиться чему-то новому.

В итоге хочется отметить, что функцию для подавления «дребезга» можно скомбинировать с проверкой контрольных сумм MD5 для выдачи сообщений только в том случае, если файл по-настоящему изменился, и не выводить сообщения в ситуациях, когда в файл, при сохранении, не было внесено никаких реальных изменений.

const fs = require('fs');
const md5 = require('md5');
require('log-timestamp');

const buttonPressesLogFile = './button-presses.log';

console.log(`Watching for file changes on ${buttonPressesLogFile}`);

let md5Previous = null;
let fsWait = false;
fs.watch(buttonPressesLogFile, (event, filename) => {
  if (filename) {
    if (fsWait) return;
    fsWait = setTimeout(() => {
      fsWait = false;
    }, 100);
    const md5Current = md5(fs.readFileSync(buttonPressesLogFile));
    if (md5Current === md5Previous) {
      return;
    }
    md5Previous = md5Current;
    console.log(`${filename} file Changed`);
  }
});


Пожалуй, всё это выглядит немного сложно, и в 99% случаев в подобном нет необходимости, но, в любом случае, полагаю, это даёт некоторую пищу для ума.

Итоги


В Node.js можно наблюдать за изменениями файлов и выполнять некий код в ответ на эти изменения. В применении к аквариумному IoT-проекту это даёт возможность наблюдать за состоянием лог-файла, в который попадают записи о кормлении рыбок.

Существует множество ситуаций, в которых наблюдение за файлами может быть полезным. Надо отметить, что использовать для наблюдения за файлами fs.watchFile не рекомендуется, так как эта команда, для обнаружения событий изменений файлов, выполняет регулярные запросы к системе. Вместо этого стоит обратить внимание на fs.watch с функцией для подавления «дребезга» событий.

Уважаемые читатели! Используете ли вы механизмы наблюдения за изменением файлов в своих Node.js-проектах?

© Habrahabr.ru