3 способа мониторинга изменений лог-файлов в Java

Изучая механизм правил (англ. Rule Engine), следует учитывать следующее обстоятельство: в случае хранения правил в формате файлов необходимо контролировать конкретную директорию или файл, чтобы проверять их на предмет изменений и затем загружать. 

Похожие принципы действуют и в других бизнес-сценариях, таких как динамическая загрузка конфигурационных файлов, мониторинг лог-файлов и файлов FTP (англ. File Transfer Protocol, протокол передачи файлов). 

В статье мы рассмотрим 3 способа решения этой задачи. 

Способ 1: запланированная задача + File.lastModified

Это самое простое и понятное решение, которое приходит на ум. Выполняя хронометрированные задачи, мы регистрируем время последнего изменения файла запроса и сравниваем его с предыдущим. 

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

Рассмотрим пример кода: 

public class FileReadVersionDemo {
public static int version = 0;

public static void main(String[] args) throws IOException, InterruptedException {
String fileName = "/var/tmp/1.txt";
String versionName = "/var/tmp/version.txt";
// создание файла
createFile(fileName);
createFile(versionName);

for (int i = 1; i < 10; i++) {
// Запись данных в файл
writeToFile(fileName);
// Одновременная запись версий
writeToFile(versionName, i);
// Слушатель для чтения версии файла
int fileVersion = Integer.parseInt(readOneLineFromFile(versionName));

if (version == fileVersion) {
System.out.println("The version has not changed");
} else {
System.out.println("The version has changed, and business processing is performed");
}

Thread.sleep(100);
}
}

public static void createFile(String fileName) throws IOException {
File file = new File(fileName);
if (!file.exists()) {
boolean result = file.createNewFile();
System.out.println("create file:" + result);
}
}

public static void writeToFile(String fileName) throws IOException {
writeToFile(fileName, new Random(1000).nextInt());
}

public static void writeToFile(String fileName, int version) throws IOException {
FileWriter fileWriter = new FileWriter(fileName);
fileWriter.write(version +"");
fileWriter.close();
}

public static String readOneLineFromFile(String fileName) {
File file = new File(fileName);
String tempString = null;

try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
// Считывание одной строки за раз, считывание null означает конец файла
tempString = reader.readLine();
} catch (IOException e) {
e.printStackTrace();
}

return tempString;
}
}

Такое решение прекрасно подходит для сценариев с редкими изменениями файлов: оно легко реализуется и соответствует всем требованиям. Однако при работе с версиями Java 8 и Java 9 может возникнуть ошибка File#lastModified, так что будьте внимательны. 

Но если вы задействуете данный способ для мониторинга изменений директории файлов, то обнаружите ряд очевидных недостатков: частое выполнение операции, снижение производительности при обходе, сохранении и сравнении состояния, а также ограниченное использование функций ОС. 

Способ 2: WatchService

В Java 7 был добавлен java.nio.file.WatchService, позволяющий контролировать изменения файлов. 

WatchService —  это монитор файловой системы на основе ОС. Он отслеживает изменения всех файлов в системе без обхода или сравнения. Такой мониторинг работает по принципу отправления и получения сигнала, демонстрируя высокую степень производительности. 

public class WatchServiceDemo {
public static void main(String[] args) throws IOException {
// Здесь слушателем должна быть директория
Path path = Paths.get("/xxx/xxx/temp/");
// Создание WatchService, который является инкапсуляцией монитора файлов ОС.
// По сравнению с предыдущим вариантом,
// не требует обхода директории файлов,
// и значительно повышает производительность
WatchService watcher = FileSystems.getDefault().newWatchService();
// StandardWatchEventKinds.ENTRY_MODIFY представляет собой событие изменения для контролируемого файла
path.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY);

// Создание потока, ожидающего изменения файла в директории
try {
while (true) {
// Получение изменений директории:
// take() - метод блокировки, ожидающий сигнала от монитора перед возвращением результата.
// Можно задействовать неблокирующий метод watcher.poll(), который немедленно возвращает результат
// вне зависимости от наличия сигнала в watcher на тот момент.
// Возвращаемый результат, WatchKey, является однократно используемым объектом,
// который аналогичен экземпляру, возвращаемому предыдущим методом register
WatchKey key = watcher.take();
// Обработка событий изменения файла:
// key.pollEvents() применяется для получения события изменения файла,
// которые можно получить только один раз,
// аналогично принципу очереди.
for (WatchEvent<?> event : key.pollEvents()) {
// event.kind():тип события
if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
//Событие может быть потеряно или отменено
continue;
}
// Возвращает путь (относительный путь) к файлу или директории, вызвавших событие
Path fileName = (Path) event.context();
System.out.println("file changed: " + fileName);
}
// Данный метод необходимо сбрасывать каждый раз при вызове методов WatchService: take() или poll()
if (!key.reset()) {
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

Данный пример иллюстрирует основной принцип применения WatchService, а комментарии поясняют конкретную роль каждого API. 

Отметим значительное разнообразие типов событий мониторинга файлов, отслеживаемых WatchService.

  • ENTRY_CREATE  —  создание записи. 
  • ENTRY_DELETE  —  удаление записи. 
  • ENTRY_MODIFY  —  изменение записи. 
  • OVERFLOW  —  специальное событие, указывающее на то, что событие было отменено или потеряно. 

Если посмотрим на исходный код класса PollingWatchService реализации WatchService, то увидим, что он фактически запускает независимый поток для мониторинга изменений файлов. 

PollingWatchService() {
// TBD: Необходимо сделать количество потоков настраиваемым
scheduledExecutor = Executors
.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(null, r, "FileSystemWatcher", 0, false);
t.setDaemon(true);
return t;
}});
}

Таким образом, благодаря своей внутренней логике WatchService также делает за нас и ту часть, которую мы должны реализовать вручную. 

Если вы напишите демо при проверке, то заметите, что изменения файлов мониторинга WatchService происходят не в режиме реального времени. Иногда для отслеживания этих изменений требуется несколько секунд. 

В качестве примера рассмотрим класс реализации PollingWatchService. Обратимся к исходному коду: 

void enable(Set<? extends Kind<?>> var1, long var2) {
synchronized(this) {
this.events = var1;
Runnable var5 = new Runnable() {
public void run() {
PollingWatchKey.this.poll();
}
};
this.poller = PollingWatchService.this.scheduledExecutor.scheduleAtFixedRate(var5, var2, var2, TimeUnit.SECONDS);
}
}

Слушатель управляется планировщиком в соответствии с установленным временным интервалом, который определен в классе SensitivityWatchEventModifier:

public enum SensitivityWatchEventModifier implements Modifier {
HIGH(2),
MEDIUM(10),
LOW(30);
// ...
}

Данный класс предоставляет 3 значения временных интервалов: 2, 10 и 30 секунд. Значением по умолчанию является 10 секунд. 

Этот интервал можно передать в path#register:

path.register(watcher, new WatchEvent.Kind[]{StandardWatchEventKinds.ENTRY_MODIFY},
SensitivityWatchEventModifier.HIGH);

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

Но не обошлось и без недостатков. Дело в том, что мониторингу могут подвергаться только файлы и каталоги текущей директории. А вот контролировать подкаталоги уже не получится.

Кроме того, мониторинг можно рассматривать только как квазиреальное время, а его интервалы принимают лишь 3 значения, предоставляемые API по умолчанию.

На Stack Overflow также поднимался вопрос о задержках в работе API Java 7 на системах Mac OS, Windows и Linux.

Другие системы я не проверял. Если вы сталкиваетесь с похожими проблемами, просто самостоятельно поищите способы их решения. 

Способ 3: Apache Commons-IO

Данный способ предусматривает использование фреймворка с открытым исходным кодом. Речь идет о библиотеке классов commons-io, которая представлена почти в любом проекте. 

Добавляем соответствующие зависимости: 

<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.7</version>
</dependency>

Обратите внимание, что разные версии нуждаются в разной поддержке JDK. Версия 2.7 требует Java 8 и выше. 

Реализация мониторинга файлов commons-io находится в пакете org.apache.commons.io.monitor. Рассмотрим основной порядок действий.

  • Настройка класса слушателя файлов FileListener и наследование FileAlterationListenerAdaptor для обработки операций создания, изменения и удаления файлов/директорий. 
  • Создание наблюдателя FileAlterationObserver пользовательским классом мониторинга файлов путем указания директории.
  • Добавление в монитор наблюдателя файловой системы и слушателя файлов. 
  • Вызов и выполнение. 

Шаг 1. Создаем слушателя FileListener. По необходимости реализуем соответствующую обработку бизнес-логики в разных методах.  

public class FileListener extends FileAlterationListenerAdaptor {
@Override
public void onStart(FileAlterationObserver observer) {
super.onStart(observer);
System.out.println("onStart");
}

@Override
public void onDirectoryCreate(File directory) {
System.out.println("create:" + directory.getAbsolutePath());
}

@Override
public void onDirectoryChange(File directory) {
System.out.println("modify:" + directory.getAbsolutePath());
}

@Override
public void onDirectoryDelete(File directory) {
System.out.println("delete:" + directory.getAbsolutePath());
}

@Override
public void onFileCreate(File file) {
String compressedPath = file.getAbsolutePath();
System.out.println("create:" + compressedPath);
if (file.canRead()) {
// TODO: Чтение или перезагрузка содержимого файлов
System.out.println("file changes, processing");
}
}

@Override
public void onFileChange(File file) {
String compressedPath = file.getAbsolutePath();
System.out.println("modify:" + compressedPath);
}

@Override
public void onFileDelete(File file) {
System.out.println("delete:" + file.getAbsolutePath());
}

@Override
public void onStop(FileAlterationObserver observer) {
super.onStop(observer);
System.out.println("onStop");
}
}

Шаг 2. Инкапсулируем служебный класс для мониторинга файлов. 

Суть в том, чтобы создать наблюдателя FileAlterationObserver, инкапсулировать путь к файлу Path и слушателя FileAlterationListener, после чего передать его в FileAlterationMonitor.

public class FileMonitor {
private FileAlterationMonitor monitor;

public FileMonitor(long interval) {
monitor = new FileAlterationMonitor(interval);
}

/**
* Добавление слушателя в файл.
*
* @param path file path
* @param listener file listener
*/
public void monitor(String path, FileAlterationListener listener) {
FileAlterationObserver observer = new FileAlterationObserver(new File(path));
monitor.addObserver(observer);
observer.addListener(listener);
}

public void stop() throws Exception {
monitor.stop();
}

public void start() throws Exception {
monitor.start();
}
}

Шаг 3. Вызываем и выполняем.

public class FileRunner {
public static void main(String[] args) throws Exception {
FileMonitor fileMonitor = new FileMonitor(1000);
fileMonitor.monitor("/xxx/xxx/temp/", new FileListener());
fileMonitor.start();
}
}

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

onStart
modify:/xxx/xxx/temp/1.txt
onStop
onStart
onStop

При создании FileMonitor можно изменить соответствующий временной интервал прослушивания.

Согласно этому способу сам слушатель запускает поток для обработки времени. При каждом запуске сначала вызывается метод onStart класса обработки слушателя событий, затем выполняется проверка на наличие изменений и вызывается метод соответствующего события. 

Например, меняется содержимое файла onChange. Мы проводим проверку, после чего вызываем метод onStop для освобождения ресурсов CPU, задействованных текущим потоком, и ожидаем следующего временного интервала для запуска и очередного выполнения. 

Слушатель использует директорию файлов как корневую. Вы можете настроить фильтры для мониторинга соответствующих изменений файлов. 

Настройки фильтров можно посмотреть в конструкторе FileAlterationObserver:

public FileAlterationObserver(String directoryName, FileFilter fileFilter, IOCase caseSensitivity) {
this(new File(directoryName), fileFilter, caseSensitivity);
}

Вот мы и рассмотрели 3 способа мониторинга изменений на основе Java.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Dwen: 3 Methods for Monitoring Log File Changes in Java

Предыдущая статьяКак уменьшить размер компонента React: 3 профессиональных приема
Следующая статьяКак создать бессерверную форму для бессерверного сайта