Pull to refresh

Визуализируем геоинформацию из логов на web-карте в реальном времени

Reading time 9 min
Views 17K


Дабы не было двусмысленностей, обозначу суть. При приёме на новую работу мне дали тестовое задание, которое кратко можно описать так: «Написать аналог Glow для геовизуализации событий входа пользователей в кастомерку интернет-магазина». Проще говоря, необходимо мониторить лог системы на предмет возникновения определенных событий и в случае оных выполнять (в данном случае) отображение точки на карте, которая будет определяться IP-адресом пользователя. Цель реализации: создать приятную на вид «игрушку» для презентационных целей, способную погрузить смотрящего в нирвану гармонии и эстетического наслаждения. Основным условием было использование в процессе разработки стека Java-технологий, чем обусловлено принятие многих решений. Кроме этого, было решено реализовать это в виде одностраничного сайта. А поскольку с Java и web я был знаком крайне поверхностно (писал в основном на C/C++), пришлось многому научиться. Что ж, будем разбираться вместе.
Статья рассчитана на интересующихся и начинающих, однако не «разжевывает» простые вещи, с которыми можно ознакомиться с помощью документации или специализированных статей. Наиболее полезные ресурсы, ссылка на исходники (распространяются по лицензии BSD) и ссылка на рабочую версию приведены в конце статьи.


И вообще, почему бы не использовать исходники вышеупомянутого Glow? Во-первых, они достаточно специфичны для тех объемов данных, которыми орудовала Mozilla — вспомните количество установок Firefox в день запуска, а также то, что система логирования у них децентрализована. В нашем случае, в единственный файл лога в пике пишется около 100 записей в секунду, из которых только часть необходимо визуализировать. Во-вторых, карта в Glow не самая приятная на вид. Ну и в-третьих, это же тестовое задание :)

Беглый взгляд


Что требуется от нашей мини-системы?
  • Следить за обновлениями в файле лога (как, например, tail -f). Кроме этого, следует учесть, что раз в сутки файл лога закрывается и бережно архивируется, а его место занимает новый файл, то есть необходимо отслеживать эти действия и переключаться на актуальный лог.
  • Определять тип события, соответствующей каждой новой записи в логе, и в случае, если его необходимо отображать на карте в виде точки, разрешать (резолвить) координаты точки по IP-адресу, содержащемуся в записи.
  • Данные о событиях необходимо в реальном времени передавать клиентам (в данном случае, скрипту в браузере клиента).
  • Клиентский скрипт должен заниматься выводом информации в виде опрятной карты с точками на ней, которые раскрашиваются в зависимости от типа соответствующего события.

Проведя небольшое исследование по каждому пункту, было решено следующее. Следить за логом, парсить записи, резолвить IP будет небольшой java-демон (звучит смешно, я понимаю, ну да ничего), который будет отсылать данные серверу посредством HTTP POST. Это позволит впоследствии легко менять отдельные части системы без головной боли. Сервер же будет по совместительству контейнером сервлетов, для которого мы и напишем соответствующий сервлет. В качестве клиентской стороны должен выступить какой-то картографический виджет (рендер карты), который будет общаться с сервером асинхронно. Тут есть несколько основных способов (подробнее в статье [1] и обзоре [2]):
  1. Comet. Клиент подключается к серверу, а сервер не разрывает соединение, а держит его открытым, что позволяет при поступлении новых данных сразу отсылать их клиенту (push). Как вариант — использование технологии WebSocket.
  2. Частые опросы (polling). Клиент с заданной частотой опрашивает сервер на наличие новых данных.
  3. «Длинные» опросы (long polling). Что-то среднее между предыдущими двумя способами. Клиент запрашивает новые данные с сервера, и если на сервере этих данных ещё нет, сервер не закрывает соединение. При поступлении данных они отсылаются клиенту, а тот в свою очередь снова отправляет запрос на новые данные.

Выбор пал на long polling, поскольку WebSocket поддерживается не всеми браузерами, а частые опросы попросту отъедают трафик впустую, эксплуатируя при этом ресурсы сервера. Кроме того, web-сервер (по совместительству сервлет-контейнер) Jetty дает возможность воспользоваться техникой continuations для обработки long polling запросов (см. [1]). Но позвольте, скажете вы, где ж тут реалтайм? Мы пишем не встраиваемую систему для самолетов, а аккуратную презентационную карту, поэтому задержки между действием пользователя и выводом точки на карте наблюдателя в 1-2 секунды не столь критичны, не правда ли?
Среди картографических движков был выбран Leaflet как один из наиболее приятных на вид и имеющих простой, дружественный API. Кроме того, обратите внимание на хорошую поддержку браузеров Leaflet'ом.
Что ж, приступим к реализации, а проблемы будем решать по месту поступления.

Получаем данные из лога


Как следить за обновлениями лога, учитывая его периодическое архивирование-создание? Можно воспользоваться, например, классом Tailer из известной библиотеки Apache Commons, но мы пойдем своим, отчасти аналогичным путем. Наш класс TailReader инициализируется каталогом, в котором располагается лог, регуляркой, описывающей имя файла лога (поскольку оно может меняться), и периодом обновления — временем, через которое мы периодически будем проверять появление новых записей в логе. Интерфейс класса напоминает работу со стандартными потоками ввода-вывода (streams), однако при этом блокирует процесс выполнения при вызове nextRecord(), если в логе не появилось новых записей. Для проверки наличия новых записей (без блокировки) можно воспользоваться методом hasNext(). Поскольку слежение за логом осуществляется в отдельном потоке (не путать с вводом-выводом, thread), существуют методы start() и stop() для управления работой потока. В случае, если файловый поток окажется закрытым (лог отправили на архивацию), через заданное количество попыток чтения объект класса решит, что пора открывать новый лог. Лог ищется по правилам, заданным в getLogFile():
    /**
     * вернуть используемый в данный момент лог-файл
     * @return лог-файл или null в случае отсутствия
     */
    private File getLogFile() {
        File logCatalog = new File(logFileCatalog);
        File[] files = logCatalog.listFiles(new FileFilter() {
            @Override
            public boolean accept(File pathname) {
                return pathname.canRead()
                        && pathname.isFile()
                        && pathname.getName().matches(logFileNamePattern);
            }
        });

        if (0 == files.length)
            return null;

        if (files.length > 1)
            Arrays.sort(files, new Comparator<File>() {
                @Override
                public int compare(File o1, File o2) {
                    return (int) (o1.lastModified() - o2.lastModified());
                }
            });

        return files[files.length - 1];
    }

После того, как мы научились следить за обновлениями лога, необходимо что-то с этими обновлениями делать. Для начала, необходимо определить тип этого события, и если его необходимо отображать на карте, выдергивать IP клиента и резолвить его в геокоординаты.
Класс RecordParser, как не трудно догадаться, анализирует строчки лог-файла с помощью регулярных выражений. Метод LogEvent parse(String record) возвращает простенький объект, инкапсулирующий тип события и IP-адрес, или null, если данная запись лога нас не интересует (это, к слову, далеко не самая лучшая практика в мире Java-разработки — лучше воспользоваться паттерном Null Object). При этом записи также фильтруются от запросов поисковых роботов (они же не совсем пользователи магазина, правда?).
Наконец, класс IpToLocationConverter занимается разрешением IP-адресов в соответствующие им геокоординаты, используя сервисы Maxmind (Java API к нему) и IpGeoBase (доступ к нему осуществляется посредством XML API, логика работы с которым инкапсулирована в пакете com.ecwid.geowid.daemon.resolvers). Maxmind достаточно паршиво резолвит российские адреса, поэтому воспользуемся дополнительно IpGeoBase'ом. API Maxmind тривиально, резолвинг осуществляется посредством файла базы данных, расположенного локально. Для IpGeoBase был написан резолвер, кеширующий обращения к сервису по очевидным причинам.
Чтобы не нагружать сервер, будем отсылать ему данные пачками по несколько штук так, чтобы записи в одной пачке разнились по времени незначительно. Для этого накопленные для визуализации объекты точек на карте (класс Point) хранятся в буфере — объекте класса PointsBuffer и «сбрасываются» при его заполнении на сервер в формате JSON (сериализуем объекты с помощью Gson).
Вся логика работы демона находится в классе GeowidDaemon. Настройки демона хранятся в XML (пошлость с моей стороны, можно было бы и properies-файлами обойтись или YAML взять, но так хотелось попробовать XML to Object mapping). Обратите внимание на
    <events>
        <event>
            <type>def</type>
            <pattern>\b((?:\d{1,3}\.){3}\d{1,3})\b\s+script\.js</pattern>
        </event>
        <event>
            <type>mob</type>
            <pattern>\b((?:\d{1,3}\.){3}\d{1,3})\b\s+mobile:</pattern>
        </event>
        <event>
            <type>api</type>
            <pattern>\b((?:\d{1,3}\.){3}\d{1,3})\b\s+api:</pattern>
        </event>
    </events>

Типы событий: def — открытие «обычной» кастомерки, mob — открытие мобильной кастомерки, api — вызов API сервиса. Тип определяется по нахождению в записи лога подстроки, соответствующей конкретной регулярке, в которой IP выделен в группу.
Для запуска демона на просторах сети был найден замечательный скрипт.

Раздаем данные клиентам


Let's rock, что там с хвалёными continuations в API Jetty (договоримся использовать 7ую версию сервера)? Об этом превосходно написано в документации [3], включая примеры кода. Ими и воспользуемся. Наш сервлет GeowidServlet минималистичен: умеет принимать данные от демона и отдавать их клиентам. Наиболее интересен в этом отношении следующий код:
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        synchronized (continuations) {
            for (Continuation continuation : continuations.values()) {
                continuation.setAttribute(resultAttribute, req.getParameter(requestKey));
                try {
                    continuation.resume();
                } catch (IllegalStateException e) {
                    // ok
                }
            }
            continuations.clear();
            resp.setStatus(HttpServletResponse.SC_OK);
        }
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String reqId = req.getParameter(idParameterName);

        if (null == reqId) {
            resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Request ID needed");
            logger.info("Request without ID rejected [{}]", req.getRequestURI());
            return;
        }

        Object result = req.getAttribute(resultAttribute);

        if (null == result) {
            Continuation continuation = ContinuationSupport.getContinuation(req);
            synchronized (continuations) {
                if (!continuations.containsKey(reqId)) {
                    continuation.setTimeout(timeOut);
                    try {
                        continuation.suspend();
                        continuations.put(reqId, continuation);
                    } catch (IllegalStateException e) {
                        logger.warn("Continuation with reqID={} can't be suspended", reqId);
                        resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                    }
                } else
                if (continuation.isExpired()) {
                    synchronized (continuations) {
                        continuations.remove(reqId);
                    }
                    resp.setContentType(contentType);
                    resp.getWriter().println(emptyResult);
                } else {
                    resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Request ID conflict");
                }
            }
        } else {
            resp.setContentType(contentType);
            resp.getWriter().println((String) result);
        }
    }

Что здесь происходит?
Когда клиент приходит за новыми данными, мы проверяем наличие в параметрах GET-запроса его уникального идентификатора (который, по правде говоря, псевдоуникален, см. реализацию клиентской части, функция getPseudoGUID() здесь), если ID отсутствует — «отшиваем» клиента. Это нужно для того, чтобы правильно идентифицировать continuation, связанное с конкретным клиентом. Далее проверяем, установлен ли для данного запроса атрибут, содержащий необходимые данные. Естественно, если клиент пришёл к нам в первый раз, ни о каких данных речи быть не может. Поэтому создаём для него continuation с заданным таймаутом, саспендим (suspend) его и помещаем на хранение в хэш-таблицу. Однако бывают такие ситуации, когда таймаут continuation истек, а данных как не было, так и нет. В этом случае нам помогает проверка условия if (continuation.isExpired()), при прохождении которой сервлет отдает клиенту пустой массив в JSON, убирая при этом соответствующее данному клиенту continuation из таблицы за ненадобностью.
Если же атрибут с данными установлен, мы просто возвращаем эти данные клиенту. Откуда берутся эти данные? В обработчике POST-запросов, конечно. Как только демон прислал данные, сервлет пробегается по таблице «подвешенных» continuations, устанавливая у каждого атрибут с данными и возобновляя каждого же (resume), после чего очищая таблицу. Именно в этот момент происходит повторный вход в метод doGet() для каждого continuation, но уже с нужными пользователю данными.
Можно, например, замерить таинственную силу этих самых continuations с помощью профилировщика под нагрузкой. Для этого автор воспользовался VisualVM и Siege. Из автора тестировщик посредственный, поэтому тест выглядел крайне искусственно. JVM «прогревалась» около часу, устаканившись на 15Mb heap space. После чего с помощью Siege нагружаем сервер параллельными 3000 запросами в секунду (не хотелось ковыряться в системе для поднятия лимитов на открытые файлы и прочее) в течение 5 минут. JVM отъела ~250Mb heap space, нагружая ядро процессора на ~10-15%. Думаю, неплохой результат для начинающих.


Визуализация, сэр


Сразу оговорюсь: возможно, мой JavaScript-код покажется вам «неканоничным» с точки зрения профессионального frontend-разработчика. Судить тем, кто разберётся в моём коде :)

Итак, используем Leaflet. Как будем выводить точки на карту? Стандартные маркеры выглядят неподобающе. Используя png или, упаси W3C, gif, нельзя добиться приятной картинки с анимацией точек. Тут есть два пути:
  1. Анимация посредством SVG. На хабре недавно проскакивала отличная статья на эту тему. Плюсы: для Leaflet уже есть отличный плагин (обратите внимание на демо внизу страницы), использующий превосходную библиотеку Raphaël, причем эта библиотека позволяет рисовать SVG даже на IE6 (точнее VML). Минусы: в связи со спецификой SVG, анимация на нём — достаточночно ресурсоемкая операция (представьте себя на месте браузера: вам придется большую часть времени парсить XML и рендерить графику в соответствии с изменениями в нем).
  2. HTML5's . У всех на слуху, масса статей, туториалов и библиотек, упрощающих работу (особенно рекомендую посмотреть на www.html5canvastutorials.com и KineticJS). Плюсы: то, что надо для анимации в браузере. Минусы: не всеми браузерами поддерживается.
Tags:
Hubs:
+35
Comments 24
Comments Comments 24

Articles