Дабы не было двусмысленностей, обозначу суть. При приёме на новую работу мне дали тестовое задание, которое кратко можно описать так: «Написать аналог 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
<canvas>. У всех на слуху, масса статей, туториалов и библиотек, упрощающих работу (особенно рекомендую посмотреть на www.html5canvastutorials.com и KineticJS). Плюсы: то, что надо для анимации в браузере. Минусы: не всеми браузерами поддерживается.
Казалось бы, выбор очевиден. Однако склепанные на скорую руку демки для прощупывания обеих технологий показали следующее. При использовании SVG ядро процессора нагружается на 70-100% (!), а память течет со скоростью 3-5Mb в минуту, что ограничивает возможность держать страничку с картой долго открытой. Кроме того, иногда возникают неприятные артефакты, когда пользователь оставил вкладку открытой в фоне, а через некоторое время вернулся на неё. Связано это, вероятно, с оптимизациями в браузере, когда он не считает нужным перерисовывать фоновую вкладку, а когда она открывается, вываливает всё, что происходило на ней за это время. Если же использовать <canvas>, эту проблему можно избежать с помощью requestAnimationFrame. Кроме того, анимация вообще не будет мучать CPU, но! память протекает ужасными темпами в 50-70Mb за минуту. Возможно, причина всему вышеперечисленному — проблемы в использованных библиотеках или способах их применения. В общем, решено было использовать SVG.
Вернемся к реализации. Вся логика сосредоточена в скриптах geowid-src.js и rlayer-src.js (последний пришлось немного переписать, поэтому он отличается от оригинала). Вряд ли в них можно найти что-то интересное, однако на следующее стоит обратить внимание:
(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. Для этого можно написать простенький Ant build-файл, чтобы автоматизировать сборку артефактов нашей мини-системы.
Результаты
Время собирать камни. В процессе разработки использовались инструменты:
- IntelliJ IDEA Community Edition, отличная IDE для продуктивной разработки.
- Apache Ant и Ant-Contrib Tasks к нему для сборки проекта.
- TestNG для модульного тестирования (Где же тесты? — спросите вы. Они были в самой первой версии, когда система имела несколько иную реализацию. Потом, скажу честно, мне стало лениво актуализировать их, и я их просто удалил 🙁 ).
- git и GitHub к нему для версионирования.
- Chrome Developer Tools для отладки JS-кода.
- и ещё что-то по мелочам (например, Gimp для графики и Linux Mint для удобства).
Результат можно попробовать здесь. Вот и проверим Jetty’s continuations 🙂 Исходники, уже неоднократно упоминаемые, здесь.
Ресурсы
Часть ресурсов, упоминаемых в статье, не представлена в списке ниже. И наоборот, список содержит некоторые полезные ресурсы, до этого не упоминаемые.
- Ajax для Java-разработчиков: Создание масштабируемых Comet-приложений с использованием Jetty и Direct Web Remoting.
- Учебник по AJAX и COMET.
- Jetty’s Continuations.
- Compatibility tables for support of HTML5, CSS3, SVG and more in desktop and mobile browsers.
- An Open-Source JavaScript Library for Mobile-Friendly Interactive Maps by CloudMade.
- Apache Commons.
- Determine the country, region, city, latitude, and longitude associated with IP addresses worldwide.
- Поиск географического местонахождения IP-адреса.
- Raphaël — JavaScript Library.
- Тестирование в Java. TestNG.
- Log4j 2.
- The Java Tutorials.
- javascript.ru
- htmlbook.ru
- Git Cheatsheet.
- Make the Web Faster.
- stackoverflow.com
- Кей Хорстманн, Гари Корнелл. Java 2. Библиотека профессионала. Том 1. Основы.
- Кей Хорстманн, Гари Корнелл. Java 2. Библиотека профессионала. Том 2. Тонкости программирования.
- Джошуа Блох. Java. Эффективное программирование.
ссылка на оригинал статьи http://habrahabr.ru/post/158333/
Добавить комментарий