- PVSM.RU - https://www.pvsm.ru -
Дабы не было двусмысленностей, обозначу суть. При приёме на новую работу мне дали тестовое задание, которое кратко можно описать так: «Написать аналог Glow [1] для геовизуализации событий входа пользователей в кастомерку интернет-магазина». Проще говоря, необходимо мониторить лог системы на предмет возникновения определенных событий и в случае оных выполнять (в данном случае) отображение точки на карте, которая будет определяться IP-адресом пользователя. Цель реализации: создать приятную на вид «игрушку» для презентационных целей, способную погрузить смотрящего в нирвану гармонии и эстетического наслаждения. Основным условием было использование в процессе разработки стека Java-технологий, чем обусловлено принятие многих решений. Кроме этого, было решено реализовать это в виде одностраничного сайта. А поскольку с Java и web я был знаком крайне поверхностно (писал в основном на C/C++), пришлось многому научиться. Что ж, будем разбираться вместе.
Статья рассчитана на интересующихся и начинающих, однако не «разжевывает» простые вещи, с которыми можно ознакомиться с помощью документации или специализированных статей. Наиболее полезные ресурсы и ссылка на исходники (распространяются по лицензии BSD [2]) приведены в конце статьи.
И вообще, почему бы не использовать исходники вышеупомянутого Glow? Во-первых, они достаточно специфичны для тех объемов данных, которыми орудовала Mozilla — вспомните количество установок Firefox в день запуска, а также то, что система логирования у них децентрализована. В нашем случае, в единственный файл лога в пике пишется около 100 записей в секунду, из которых только часть необходимо визуализировать. Во-вторых, карта в Glow не самая приятная на вид. Ну и в-третьих, это же тестовое задание :)
Что требуется от нашей мини-системы?
tail -f
[3]). Кроме этого, следует учесть, что раз в сутки файл лога закрывается и бережно архивируется, а его место занимает новый файл, то есть необходимо отслеживать эти действия и переключаться на актуальный лог.Проведя небольшое исследование по каждому пункту, было решено следующее. Следить за логом, парсить записи, резолвить IP будет небольшой java-демон (звучит смешно, я понимаю, ну да ничего), который будет отсылать данные серверу посредством HTTP POST. Это позволит впоследствии легко менять отдельные части системы без головной боли. Сервер же будет по совместительству контейнером сервлетов, для которого мы и напишем соответствующий сервлет. В качестве клиентской стороны должен выступить какой-то картографический виджет (рендер карты), который будет общаться с сервером асинхронно. Тут есть несколько основных способов (подробнее в статье [1] [4] и обзоре [2] [5]):
Выбор пал на long polling, поскольку WebSocket поддерживается не всеми браузерами [7], а частые опросы попросту отъедают трафик впустую, эксплуатируя при этом ресурсы сервера. Кроме того, web-сервер (по совместительству сервлет-контейнер) Jetty дает возможность воспользоваться техникой continuations [8] для обработки long polling запросов (см. [1] [4]). Но позвольте, скажете вы, где ж тут реалтайм? Мы пишем не встраиваемую систему для самолетов, а аккуратную презентационную карту, поэтому задержки между действием пользователя и выводом точки на карте наблюдателя в 1-2 секунды не столь критичны, не правда ли?
Среди картографических движков был выбран Leaflet [9] как один из наиболее приятных на вид и имеющих простой, дружественный API. Кроме того, обратите внимание на хорошую поддержку браузеров Leaflet'ом.
Что ж, приступим к реализации, а проблемы будем решать по месту поступления.
Как следить за обновлениями лога, учитывая его периодическое архивирование-создание? Можно воспользоваться, например, классом Tailer
[10] из известной библиотеки Apache Commons [11], но мы пойдем своим, отчасти аналогичным путем. Наш класс TailReader
[12] инициализируется каталогом, в котором располагается лог, регуляркой [13], описывающей имя файла лога (поскольку оно может меняться), и периодом обновления — временем, через которое мы периодически будем проверять появление новых записей в логе. Интерфейс класса напоминает работу со стандартными потоками ввода-вывода (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
[14], как не трудно догадаться, анализирует строчки лог-файла с помощью регулярных выражений. Метод LogEvent parse(String record)
возвращает простенький объект, инкапсулирующий тип события и IP-адрес, или null
, если данная запись лога нас не интересует (это, к слову, далеко не самая лучшая практика в мире Java-разработки — лучше воспользоваться паттерном Null Object [15]). При этом записи также фильтруются от запросов поисковых роботов (они же не совсем пользователи магазина, правда?).
Наконец, класс IpToLocationConverter
[16] занимается разрешением IP-адресов в соответствующие им геокоординаты, используя сервисы Maxmind [17] (Java API к нему [18]) и IpGeoBase [19] (доступ к нему осуществляется посредством XML API [20], логика работы с которым инкапсулирована в пакете com.ecwid.geowid.daemon.resolvers
[21]). Maxmind достаточно паршиво резолвит российские адреса, поэтому воспользуемся дополнительно IpGeoBase'ом. API Maxmind тривиально, резолвинг осуществляется посредством файла базы данных, расположенного локально. Для IpGeoBase был написан резолвер [22], кеширующий обращения к сервису по очевидным причинам.
Чтобы не нагружать сервер, будем отсылать ему данные пачками по несколько штук так, чтобы записи в одной пачке разнились по времени незначительно. Для этого накопленные для визуализации объекты точек на карте (класс Point
[23]) хранятся в буфере — объекте класса PointsBuffer
[24] и «сбрасываются» при его заполнении на сервер в формате JSON (сериализуем объекты с помощью Gson [25]).
Вся логика работы демона находится в классе GeowidDaemon
[26]. Настройки демона хранятся в XML (пошлость с моей стороны, можно было бы и properies [27]-файлами обойтись или YAML [28] взять, но так хотелось попробовать XML to Object mapping [29]). Обратите внимание на
<events>
<event>
<type>def</type>
<pattern>b((?:d{1,3}.){3}d{1,3})bs+script.js</pattern>
</event>
<event>
<type>mob</type>
<pattern>b((?:d{1,3}.){3}d{1,3})bs+mobile:</pattern>
</event>
<event>
<type>api</type>
<pattern>b((?:d{1,3}.){3}d{1,3})bs+api:</pattern>
</event>
</events>
Типы событий: def
— открытие «обычной» кастомерки, mob
— открытие мобильной кастомерки, api
— вызов API сервиса. Тип определяется по нахождению в записи лога подстроки, соответствующей конкретной регулярке, в которой IP выделен в группу.
Для запуска демона на просторах сети был найден замечательный скрипт [30].
Let's rock, что там с хвалёными continuations в API Jetty (договоримся использовать 7ую версию сервера)? Об этом превосходно написано в документации [3] [31], включая примеры кода. Ими и воспользуемся. Наш сервлет GeowidServlet
[32] минималистичен: умеет принимать данные от демона и отдавать их клиентам. Наиболее интересен в этом отношении следующий код:
@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()
здесь [33]), если ID отсутствует — «отшиваем» клиента. Это нужно для того, чтобы правильно идентифицировать continuation, связанное с конкретным клиентом. Далее проверяем, установлен ли для данного запроса атрибут, содержащий необходимые данные. Естественно, если клиент пришёл к нам в первый раз, ни о каких данных речи быть не может. Поэтому создаём для него continuation с заданным таймаутом, саспендим (suspend) его и помещаем на хранение в хэш-таблицу. Однако бывают такие ситуации, когда таймаут continuation истек, а данных как не было, так и нет. В этом случае нам помогает проверка условия if (continuation.isExpired())
, при прохождении которой сервлет отдает клиенту пустой массив в JSON, убирая при этом соответствующее данному клиенту continuation из таблицы за ненадобностью.
Если же атрибут с данными установлен, мы просто возвращаем эти данные клиенту. Откуда берутся эти данные? В обработчике POST-запросов, конечно. Как только демон прислал данные, сервлет пробегается по таблице «подвешенных» continuations, устанавливая у каждого атрибут с данными и возобновляя каждого же (resume), после чего очищая таблицу. Именно в этот момент происходит повторный вход в метод doGet()
для каждого continuation, но уже с нужными пользователю данными.
Можно, например, замерить таинственную силу этих самых continuations с помощью профилировщика под нагрузкой. Для этого автор воспользовался VisualVM [34] и Siege [35]. Из автора тестировщик посредственный, поэтому тест выглядел крайне искусственно. JVM «прогревалась» около часу, устаканившись на 15Mb heap space. После чего с помощью Siege нагружаем сервер параллельными 3000 запросами в секунду (не хотелось ковыряться в системе для поднятия лимитов на открытые файлы и прочее) в течение 5 минут. JVM отъела ~250Mb heap space, нагружая ядро процессора на ~10-15%. Думаю, неплохой результат для начинающих.
Сразу оговорюсь: возможно, мой JavaScript-код покажется вам «неканоничным» с точки зрения профессионального frontend-разработчика. Судить тем, кто разберётся в моём коде :)
Итак, используем Leaflet. Как будем выводить точки на карту? Стандартные маркеры выглядят неподобающе. Используя png или, упаси W3C, gif, нельзя добиться приятной картинки с анимацией точек. Тут есть два пути:
<canvas>
. У всех на слуху, масса статей, туториалов и библиотек, упрощающих работу (особенно рекомендую посмотреть на www.html5canvastutorials.com [40] и KineticJS [41]). Плюсы: то, что надо для анимации в браузере. Минусы: не всеми браузерами поддерживается.
Казалось бы, выбор очевиден. Однако склепанные на скорую руку демки для прощупывания обеих технологий показали следующее. При использовании SVG ядро процессора нагружается на 70-100% (!), а память течет со скоростью 3-5Mb в минуту, что ограничивает возможность держать страничку с картой долго открытой. Кроме того, иногда возникают неприятные артефакты, когда пользователь оставил вкладку открытой в фоне, а через некоторое время вернулся на неё. Связано это, вероятно, с оптимизациями в браузере, когда он не считает нужным перерисовывать фоновую вкладку, а когда она открывается, вываливает всё, что происходило на ней за это время. Если же использовать <canvas>
, эту проблему можно избежать с помощью requestAnimationFrame
[42]. Кроме того, анимация вообще не будет мучать CPU, но! память протекает ужасными темпами в 50-70Mb за минуту. Возможно, причина всему вышеперечисленному — проблемы в использованных библиотеках или способах их применения. В общем, решено было использовать SVG.
Вернемся к реализации. Вся логика сосредоточена в скриптах geowid-src.js [33] и rlayer-src.js [43] (последний пришлось немного переписать, поэтому он отличается от оригинала). Вряд ли в них можно найти что-то интересное, однако на следующее стоит обратить внимание:
(function poll() {
$.ajax({
url: url,
success: function(points) {
flush = true;
flushNum = pointsQueue.length;
pointsQueue = pointsQueue.concat(points);
if (pointsQueue.length >= chunkSize) {
noDataCounter = 0;
$('#wait').fadeOut('slow');
}
},
dataType: 'json',
complete: poll,
timeout: ajaxTimeOut,
cache: false
});
})();
Так мы организуем long polling опросы сервера: при успешном AJAX-запросе выполняется добавление точек в очередь для отображения, после чего запрос повторяется (свойству complete
присваиваем имя функции, вызываемой по окончании).
Наши скрипты для пущей важности упаковываем с помощью Google Closure Compiler [44]. Для этого можно написать простенький Ant build-файл [45], чтобы автоматизировать сборку артефактов нашей мини-системы.
Время собирать камни. В процессе разработки использовались инструменты:
Результат можно попробовать здесь [52]. Вот и проверим Jetty's continuations :) Исходники, уже неоднократно упоминаемые, здесь [53].
Часть ресурсов, упоминаемых в статье, не представлена в списке ниже. И наоборот, список содержит некоторые полезные ресурсы, до этого не упоминаемые.
Автор: injecto
Источник [67]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/20036
Ссылки в тексте:
[1] Glow: http://blog.mozilla.org/website-archive/2011/06/14/glow-1-0/
[2] BSD: http://opensource.org/licenses/BSD-3-Clause
[3] tail -f
: http://www.linuxmanpages.com/man1/tail.1.php
[4] [1]: #r1
[5] [2]: #r2
[6] WebSocket: http://www.websocket.org/
[7] не всеми браузерами: http://caniuse.com/#feat=websockets
[8] continuations: http://en.wikipedia.org/wiki/Continuation
[9] Leaflet: http://leafletjs.com/
[10] Tailer
: http://commons.apache.org/io/apidocs/org/apache/commons/io/input/Tailer.html
[11] Apache Commons: http://commons.apache.org/
[12] TailReader
: https://github.com/injecto/geowid/blob/master/geowidd/src/com/ecwid/geowid/daemon/TailReader.java
[13] регуляркой: http://www.regular-expressions.info/
[14] RecordParser
: https://github.com/injecto/geowid/blob/master/geowidd/src/com/ecwid/geowid/daemon/RecordParser.java
[15] Null Object: http://en.wikipedia.org/wiki/Null_Object_pattern
[16] IpToLocationConverter
: https://github.com/injecto/geowid/blob/master/geowidd/src/com/ecwid/geowid/daemon/IpToLocationConverter.java
[17] Maxmind: http://www.maxmind.com/en/home
[18] Java API к нему: https://github.com/maxmind/geoip-api-java
[19] IpGeoBase: http://ipgeobase.ru/
[20] XML API: http://blog.ipgeobase.ru/?p=76
[21] com.ecwid.geowid.daemon.resolvers
: https://github.com/injecto/geowid/tree/master/geowidd/src/com/ecwid/geowid/daemon/resolvers
[22] резолвер: https://github.com/injecto/geowid/blob/master/geowidd/src/com/ecwid/geowid/daemon/resolvers/RuIpResolver.java
[23] Point
: https://github.com/injecto/geowid/blob/master/geowidd/src/com/ecwid/geowid/daemon/Point.java
[24] PointsBuffer
: https://github.com/injecto/geowid/blob/master/geowidd/src/com/ecwid/geowid/daemon/PointsBuffer.java
[25] Gson: http://code.google.com/p/google-gson/
[26] GeowidDaemon
: https://github.com/injecto/geowid/blob/master/geowidd/src/com/ecwid/geowid/daemon/GeowidDaemon.java
[27] properies: http://en.wikipedia.org/wiki/.properties
[28] YAML: http://www.yaml.org/
[29] XML to Object mapping: http://www.oracle.com/technetwork/articles/javase/index-140168.html
[30] скрипт: http://shrubbery.mynetgear.net/c/display/W/Java+Daemon+Startup+Script
[31] [3]: #r3
[32] GeowidServlet
: https://github.com/injecto/geowid/blob/master/geowidsrv/src/com/ecwid/geowid/server/GeowidServlet.java
[33] здесь: https://github.com/injecto/geowid/blob/master/geowidsrv/web/src/geowid-src.js
[34] VisualVM: http://docs.oracle.com/javase/6/docs/technotes/guides/visualvm/index.html
[35] Siege: http://www.joedog.org/siege-home/
[36] статья: http://habrahabr.ru/post/157087/
[37] плагин: http://dynmeth.github.com/RaphaelLayer/
[38] Raphaël: http://raphaeljs.com/
[39] VML: http://www.w3.org/TR/NOTE-VML
[40] www.html5canvastutorials.com: http://www.html5canvastutorials.com
[41] KineticJS: http://kineticjs.com/
[42] requestAnimationFrame
: http://habrahabr.ru/post/114358/
[43] rlayer-src.js: https://github.com/injecto/geowid/blob/master/geowidsrv/web/src/rlayer-src.js
[44] Google Closure Compiler: https://developers.google.com/closure/compiler/
[45] build-файл: https://github.com/injecto/geowid/blob/master/geowidsrv/jscompile.xml
[46] IntelliJ IDEA Community Edition: http://www.jetbrains.com/idea/
[47] Apache Ant: http://ant.apache.org/
[48] Ant-Contrib Tasks: http://ant-contrib.sourceforge.net/
[49] git: http://git-scm.com/
[50] GitHub: https://github.com/
[51] Chrome Developer Tools: https://developers.google.com/chrome-developer-tools/
[52] здесь: http://graph.ecwid.com:8080/
[53] здесь: https://github.com/injecto/geowid
[54] Ajax для Java-разработчиков: Создание масштабируемых Comet-приложений с использованием Jetty и Direct Web Remoting: http://www.ibm.com/developerworks/ru/library/j-jettydwr/index.html
[55] Учебник по AJAX и COMET: http://javascript.ru/ajax/comet
[56] Jetty's Continuations: http://wiki.eclipse.org/Jetty/Feature/Continuations
[57] Compatibility tables for support of HTML5, CSS3, SVG and more in desktop and mobile browsers: http://caniuse.com/
[58] Determine the country, region, city, latitude, and longitude associated with IP addresses worldwide: http://www.maxmind.com/en/city
[59] Тестирование в Java. TestNG: http://habrahabr.ru/post/121234/
[60] Log4j 2: http://logging.apache.org/log4j/2.x/
[61] The Java Tutorials: http://docs.oracle.com/javase/tutorial/
[62] javascript.ru: http://javascript.ru
[63] htmlbook.ru: http://htmlbook.ru
[64] Git Cheatsheet: http://ndpsoftware.com/git-cheatsheet.html
[65] Make the Web Faster: https://developers.google.com/speed/tools
[66] stackoverflow.com: http://stackoverflow.com
[67] Источник: http://habrahabr.ru/post/158333/
Нажмите здесь для печати.