
Дабы не было двусмысленностей, обозначу суть. При приёме на новую работу мне дали тестовое задание, которое кратко можно описать так: «Написать аналог Glow для геовизуализации событий входа пользователей в кастомерку интернет-магазина». Проще говоря, необходимо мониторить лог системы на предмет возникновения определенных событий и в случае оных выполнять (в данном случае) отображение точки на карте, которая будет определяться IP-адресом пользователя. Цель реализации: создать приятную на вид «игрушку» для презентационных целей, способную погрузить смотрящего в нирвану гармонии и эстетического наслаждения. Основным условием было использование в процессе разработки стека Java-технологий, чем обусловлено принятие многих решений. Кроме этого, было решено реализовать это в виде одностраничного сайта. А поскольку с Java и web я был знаком крайне поверхностно (писал в основном на C/C++), пришлось многому научиться. Что ж, будем разбираться вместе.
Статья рассчитана на интересующихся и начинающих, однако не «разжевывает» простые вещи, с которыми можно ознакомиться с помощью документации или специализированных статей. Наиболее полезные ресурсы, ссылка на исходники (распространяются по лицензии BSD) и ссылка на рабочую версию приведены в конце статьи.
И вообще, почему бы не использовать исходники вышеупомянутого Glow? Во-первых, они достаточно специфичны для тех объемов данных, которыми орудовала Mozilla — вспомните количество установок Firefox в день запуска, а также то, что система логирования у них децентрализована. В нашем случае, в единственный файл лога в пике пишется около 100 записей в секунду, из которых только часть необходимо визуализировать. Во-вторых, карта в Glow не самая приятная на вид. Ну и в-третьих, это же тестовое задание :)
Беглый взгляд
Что требуется от нашей мини-системы?
- Следить за обновлениями в файле лога (как, например,
tail -f). Кроме этого, следует учесть, что раз в сутки файл лога закрывается и бережно архивируется, а его место занимает новый файл, то есть необходимо отслеживать эти действия и переключаться на актуальный лог. - Определять тип события, соответствующей каждой новой записи в логе, и в случае, если его необходимо отображать на карте в виде точки, разрешать (резолвить) координаты точки по IP-адресу, содержащемуся в записи.
- Данные о событиях необходимо в реальном времени передавать клиентам (в данном случае, скрипту в браузере клиента).
- Клиентский скрипт должен заниматься выводом информации в виде опрятной карты с точками на ней, которые раскрашиваются в зависимости от типа соответствующего события.
Проведя небольшое исследование по каждому пункту, было решено следующее. Следить за логом, парсить записи, резолвить IP будет небольшой java-демон (звучит смешно, я понимаю, ну да ничего), который будет отсылать данные серверу посредством HTTP POST. Это позволит впоследствии легко менять отдельные части системы без головной боли. Сервер же будет по совместительству контейнером сервлетов, для которого мы и напишем соответствующий сервлет. В качестве клиентской стороны должен выступить какой-то картографический виджет (рендер карты), который будет общаться с сервером асинхронно. Тут есть несколько основных способов (подробнее в статье [1] и обзоре [2]):
- Comet. Клиент подключается к серверу, а сервер не разрывает соединение, а держит его открытым, что позволяет при поступлении новых данных сразу отсылать их клиенту (push). Как вариант — использование технологии WebSocket.
- Частые опросы (polling). Клиент с заданной частотой опрашивает сервер на наличие новых данных.
- «Длинные» опросы (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, нельзя добиться приятной картинки с анимацией точек. Тут есть два пути:
- Анимация посредством SVG. На хабре недавно проскакивала отличная статья на эту тему. Плюсы: для Leaflet уже есть отличный плагин (обратите внимание на демо внизу страницы), использующий превосходную библиотеку Raphaël, причем эта библиотека позволяет рисовать SVG даже на IE6 (точнее VML). Минусы: в связи со спецификой SVG, анимация на нём — достаточночно ресурсоемкая операция (представьте себя на месте браузера: вам придется большую часть времени парсить XML и рендерить графику в соответствии с изменениями в нем).
- HTML5's
. У всех на слуху, масса статей, туториалов и библиотек, упрощающих работу (особенно рекомендую посмотреть на www.html5canvastutorials.com и KineticJS). Плюсы: то, что надо для анимации в браузере. Минусы: не всеми браузерами поддерживается.
