Обработка файлов журналов с помощью D (часть I)

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

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

Возьмем простой формат журналов (в дальнейшем, для обозначения файла журнала, будем использовать английский термин «лог»), который будет выглядеть примерно так:

[число/месяц/год час:минута:секунда] Фамилия_Имя -> Действие

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

А для того, чтобы обработать данные, нужно знать что они из себя представляют или хотя бы иметь их образец.

Вот выдержка из файла журнала (значительно сокращена):

[10/09/2001 03:34:52] Dmitriyi Valeriyanov -> Create documents
[10/03/2000 15:01:35] Kuznetsov Alexander -> Create documents
[27/02/2016 04:25:28] Vladimir Putin -> Git push
[10/03/2021 06:47:23] Vladimir Putin -> Exit from system
[06/03/2017 04:11:36] Kuznetsov Alexander -> Create documents

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

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

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

Некоторые действия мы будем считать подозрительными, и это те действия, которые отличаются от действий приведенных ниже:

Enter in system
Create documents
Exit from system
Copy documents
Remove documents

Допустим, что требуется на каждого уникального пользователя создать свой текстовой файл краткой сводки и свой файл со статистикой (ну и не забываем, про общий список подозрительных действий). Тогда, в файл статистики мы выведем следующую информацию: условный идентификационный номер пользователя (ID присваиваются по мере нахождения скриптом новых пользователей), общее количество действий, количество подозрительных действий и время последнего действия, выведенное в удобном формате. Также, ради удобства, неплохо было бы иметь фамилию и имя пользователя в файле, но можно поступить несколько иначе: дав файлу название, которое совпадает с фамилией-именем пользователя с точностью до префикса, который бы служил указателем того, что это файл статистики. Такой способ именования выбран из-за того, что он позволяет, исходя из внешнего вида названия файла, сделать некоторые выводы, а также облегчает работу утилитам командной строки.

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

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

На этом описание формата логов и извлекаемой из них информации окончено.

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

Самое замечательное в генераторах примеров – это то, что реальные данные не нужны, а сам генератор может быть до предела простым и коротким.

К примеру, создание произвольного количества логов может быть выполнено вот таким вот «скриптом»:

import std.conv;
import std.random;
import std.stdio;

string[] users = [
    "Kuznetsov Alexander",
    "Pertsev Dmitriyi",
    "Vasiliyi Shandybin",
    "Vladimir Putin",
    "Dmitriyi Valeriyanov",
    "Amarant Vasikowich",
    "Ashot Asmuhatdinov",
    "Donald Trump"
];

string[] actions = [
    "Enter in system",
    "Destroy PC",
    "Create documents",
    "Leave building",
    "Exit from system",
    "Copy document",
    "Exploites vulnerability",
    "Remove documents",
    "Git push",
    "Catch the fire",
    "Exploides yourself",
    "Kick your ass",
    "Write data",
    " "
];

void main()
{
   auto generator = Random(unpredictableSeed);
   auto numberOfFiles = uniform(0, 1000, generator);
   auto numberOfLines = uniform(0, 1000, generator);

   for (int i = 0; i < numberOfFiles; i++)
   {
        File file;
        file.open(`D:\Logs\log_00` ~ to!string(i) ~ `.txt`, `w`);
        
        for (int j = 0; j < numberOfLines; j++) { file.writefln( "[%0.2d/%0.2d/%0.2d %0.2d:%0.2d:%0.2d] %s -> %s",
                uniform(1,29, generator),
                uniform(1,12, generator),
                uniform(2000,2022, generator),
                uniform(0,23, generator),
                uniform(0,59, generator),
                uniform(0,59, generator),
                users[uniform(0,7, generator)],
                actions[uniform(0,10, generator)],
            );
        }
        
        file.close;
   }
}

Кроме того, файл содержащий этот код можно действительно выполнить как скрипт, воспользовавшись для этого rdmd:

rdmd generator

или добавив такую строку в Linux-системах в файл (должна быть самой первой строкой в файле!):

#!/usr/bin/env rdmd

(в Windows эту строчку замените на строку #!rdmd)

Работает это следующим образом: в начале файла задаем массив строк users (список всех пользователей, имена и фамилии вымышлены), массив строк actions (список всех действий, без разницы, каких именно, главное, чтобы были и подозрительные и обычные), после чего запускается генератор псевдослучайных чисел со случайным зерном (выражение Random(unpredictableSeed)) и случайным образом, получается количество файлов и количество строк в них (случайными в диапазоне от 0 до 1000). Дальше, происходит цикл по количеству файлов, в котором происходит открытие на запись и внесение случайных данных в нужном формате (см. начало статьи), которая формируется на ходу.

Небольшое пояснение относительно кода генератора: нам было откровенно лень встраивать проверку на количество дней в разных месяцах, поэтому мы ограничились 29 днями в каждом месяце и не учли «високосность» года. Также, решено ограничиться годами в интервале от 2000 до 2022 (этому нет адекватных причин), выбран 24-часовой формат времени (непризнанный стандарт). Все остальное предельно тривиально: фамилия-имя пользователя и действие выбираются на основе случайного индекса. По идее, стоило бы учесть еще некоторые детали, а также использовать разные объекты генераторов псевдослучайных чисел, но в нашем случае, написанного скрипта достаточно для примера.

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

Особо это касается экранирования данных.

Теперь заменяем путь к будущей папке с логами в file.open(`D:\Logs\log_00` ~ to!string(i) ~ `.txt`, `w`) на свой (шаблон имени файла log_00XYZ.txt, где X, Y, Z – некоторые числа, лучше оставить), запускаем и получаем набор для экспериментов по извлечению информации.

Мы получили этим скриптом 443 файла (средний объем одного файла — 11 Кб) общим объемом в 5,2 Мб. После успешного создания целого ряда примеров, можно взяться за их обработку.

Для начала, опишем структуры данных, которые будут представлять нужную нам информацию:

// Информация о действии
struct Action
{
    DateTime time;
    string description;
    bool suspicioned; // флаг подозрительности действия
}

// Информация по текущему пользователю
struct UserInfo
{
    int id;
    string name;
    Action[] actions;
}

// Информация по конкретным записям из файла
struct UserEntry
{
    string name;
    Action action;
}

Теперь, опишем структуры, которые помогут создать статистику и сводку, а заодно перегрузим у них метод toString, чтобы получить удобный вывод:

// Короткая сводка на юзера
struct Summary
{
    string username;
    int numberOfSuspicious; // 
    DateTime timeOfLastAction;

    string toString()
    {
        return format("Name : %s\nNumber of suspicious: %s\nTime of last action : %s",
            username,
            numberOfSuspicious,
            timeOfLastAction
        );
    }
}

// Статистика по пользователю
struct Statistic
{
    int id;
    int numberOfActions;
    int numberOfSuspicious;
    DateTime timeOfLastAction;

    string toString()
    {
        return format("Identifier : %s\nNumber of action: %s\nNumber of suspicious: %s\nTime of last action : %s",
            id,
            numberOfActions,
            numberOfSuspicious,
            timeOfLastAction
        );
    }
}

Дальше попробуем извлечь информацию из обычной строки и упаковать ее в описанные структуры. Сначала, извлечем данные о дате и времени, используя описание формата строки лога и определенный в std.datetime тип DateTime (именно в него, будем помещать извлеченную информацию):

// Извлечь дату из строки
final DateTime parseDateFrom(string line)
{
    auto dateTimeBlocks = line[1..$-1].split;

    auto date = dateTimeBlocks[0].split("/");
    
    auto day = to!int(date[0]);
    auto month = to!int(date[1]);
    auto year = to!int(date[2]);

    auto time = dateTimeBlocks[1].split(":");
    auto hour = to!int(time[0]);
    auto minute = to!int(time[1]);
    auto second = to!int(time[2]);

    return DateTime(year, month, day, hour, minute, second);
}

Что тут происходит понять нетрудно: сначала избавляем строку от начальной и конечной скобки, а затем разбиваем ее по пробелам (таким образом, в массиве элементов разбиения всего 2 элемента – дата и время). После разбиения работаем с каждой из полученных строк отдельно: разбиваем каждую по своему разделителю (дату по разделителю «/», а время по разделителю «:»), приводим их по отдельности к целому числу и помещаем, наконец, в структуру DateTime.

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

// Извлечь текущую информацию
final UserEntry parseLine(string line)
{
    UserEntry userEntry;

    auto lineBlocks = line.split(" "); // значащие единицы строки
    auto date = parseDateFrom(lineBlocks[0] ~ " " ~ lineBlocks[1]); // дата и время 
    userEntry.name = lineBlocks[2] ~ " " ~ lineBlocks[3]; // имя пользователя
    auto description = to!string(lineBlocks[5..$].join(" ")); // описание действия

    // определение степени легальности действия
    switch (description)
    {
        case "Enter in system", 
             "Create documents",
             "Exit from system",
             "Copy documents",
             "Remove documents":
             userEntry.action = Action(date, description, false);
             break;
        default:
            userEntry.action = Action(date, description, true);
            break;
    }
    return userEntry;
}

Здесь все тривиально, и на основе этой процедуры можно строить разбор самого лога:

// Обработка одного файла
final void processFile(string fileName, ref UserInfo[string] usersBase, ref int counter)
{
    final void addInDictionary(UserEntry userEntry, ref UserInfo[string] usersBase, ref int counter)
    {
        if (userEntry.name in usersBase) // если пользователь есть в базе, 
                                        // то обновить информацию, иначе создать новую запись в базе
        {
            UserInfo userInfo;

            userInfo = usersBase[userEntry.name];
            userInfo.actions ~= userEntry.action;

            usersBase[userEntry.name] = userInfo;
        }
        else
        {
            UserInfo userInfo;
            
            userInfo.id = counter;
            userInfo.name = userEntry.name;
            userInfo.actions ~= userEntry.action;
            
            usersBase[userEntry.name] = userInfo;

            counter++;
        }
    }

    // создать базу на основе записей из файла
    (cast(string) std.file.read(fileName))
                                .splitLines
                                .map!(a => parseLine(a))
                                .each!(a => addInDictionary(a, usersBase, counter));
}

Функция processFile принимает три аргумента: имя файла с данными, базу пользователей (представлена ассоциативным массивом UserEntry с ключом в виде string, отражающим имя пользователя) и счетчик записей. Стоит обратить внимание на то, что два последних аргумента передаются по ссылке, а значит, требуют наличия уже готовых переменных (об этом чуть позже).

Также потребуется вспомогательная процедура (которая реализована прямо внутри processFile), которая поместит в ассоциативный массив типа UserInfo[string] запись о пользователе типа UserEntry. Эта функция в случае, если имя пользователя уже присутствует среди ключей массива (т.е. пользователь уже есть в базе) обновляет информацию о пользователя внутри базы, и добавляет пользователя в случае отсутствия его имени среди ключей. Помимо этого, с помощью переменной counter происходит присвоение уникального номера, который увеличивает каждый раз при добавлении в базу нового пользователя. Функция processFile считывает файл в строку, превращает её в массив строк (на основе разбиения по разделителю «символ новой строки»), каждая строка которого превращается в удобную структуру-запись и затем подается на функцию addInDictionary, которая приводит записи в нужный вид и помещает их в базу.

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

Но об этом в следующий части

Добавить комментарий