Дабы не было двусмысленностей, обозначу суть. При приёме на новую работу мне дали тестовое задание, которое кратко можно описать так: «Написать аналог 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). Плюсы: то, что надо для анимации в браузере. Минусы: не всеми браузерами поддерживается.