Содержание статьи
Реализация таких возможностей потребовала пересмотра архитектуры веб‑приложений. Запросы должны выполняться практически в реальном времени, приложение — легко расширяться и масштабироваться. Существующая модель, в которой запускается один тяжеловесный сервер приложений, распределяющий запросы по разным потокам, не справляется с этими задачами эффективно. Создание потоков требует ресурсов памяти, переключение между потоками — процессорных ресурсов, поддержка синхронизации — времени и нервов программиста, масштабирование — использования сторонних библиотек и программ.
Поэтому была предложена новая модель: для обслуживания запросов запускается много мелких однопоточных серверов, которые общаются между собой сообщениями. Ставку на такой подход сделали разработчики недавно появившегося фреймворка Vert.x. О том, чем он может привлечь программиста и осчастливить конечного пользователя, и пойдет речь дальше.
Основные черты Vert.x
Первая версия Vert.x была выпущена в 2012 году. Как утверждает его создатель, Тим Фокс, его вдохновила простота и легкость Node.js, и он решил сделать что‑то похожее, но лучше и на JVM. В отличие от Node.js Тим решил не ограничивать программистов в выборе языка программирования и сразу внес в проект поддержку мультиязычности. Проект изначально даже назывался Node.x, где x намекал на его полиглотную натуру.
На данный момент Vert.x поддерживает Java, JavaScript, Groovy, Ruby и Python. Активно идет разработка поддержки Scala, Clojure и PHP. Поддержка языков реализована в виде модулей, которые легко подключаются и отключаются. Использование модульной архитектуры также позволяет писать разные компоненты проекта на разных языках или подключать к своему Java-проекту библиотечный модуль, написанный, к примеру, на руби.
Разработчики Vert.x приняли во внимание и то, что логика современных веб‑приложений все больше реализуется на JavaScript и выполняется в браузере. Так что фреймворк не ставит своей задачей отображение HTML-страниц или обработку данных из формы. Его задача — просто вывести статичную страницу с джаваскриптом и работать уже с соединением от JS-клиента через веб‑сокеты, предоставляя необходимые данные. Помимо WebSockets, Vert.x поддерживает также SockJS, аналогичную JS-библиотеку. Оба этих протокола требуют от сервера постоянно держать открытым соединение с клиентом. В обычном веб‑приложении для каждого такого соединения выделяется отдельный поток. С каждым новым клиентом количество потоков в системе увеличивается, многие из них простаивают без дела, растрачивая напрасно ресурсы системы на их содержание и переключение между ними. Поэтому в Vert.x используется другой подход. Все соединения вешаются на один поток, который занимается только тем, что принимает и отправляет данные. Обработка принятых данных в этом потоке не происходит.
Поэтому соединения не блокируют друг друга.
Vert.x является полностью асинхронным. Все действия представляются в форме событий и по очереди обрабатываются в потоке цикла событий (event loop). Таких потоков может быть несколько, но их количество не превышает количества процессорных ядер, потому что они активны почти все время и могут использовать вычислительные мощности ядра на 100%.
Архитектурно Vert.x можно разделить на две части: сервисы ядра и модули. Сервисы ядра включают в себя реализацию клиентов и серверов TCP/SSL, HTTP, SockJS, веб‑сокеты. Также есть сервисы для доступа к шине событий, к файловой системе, таймеры, буферы, сервисы настройки прав доступа, развертывания и другие. Предполагается, что сервисы ядра будут статичны, а вся функциональность, которая изменяется, вынесена в модули. Модуль — это zip-файл, в котором содержатся исполняемые файлы, ресурсы, библиотеки, используемые в приложении, и файл описания модуля mod.
. Модули можно загружать в репозиторий Мавена или Bintray. Также модули можно помещать в реестр Vert.x-модулей, чтобы другие разработчики могли им пользоваться.
Архитектура Vert.x
Разработчики Vert.x ввели несколько новых понятий, которые понадобятся при разработке приложений. Единица развертывания в Vert.x называется вертикл (verticle). Каждый вертикл содержит метод main
, который выполняется при запуске. Приложение может состоять только из одного вертикла или из нескольких, общающихся между собой через шину событий. Каждый вертикл запускается в своем загрузчике классов (classloader), чтобы изолировать вертиклы друг от друга и избежать ситуации, когда один вертикл меняет статичные переменные другого. В одной виртуальной машине может быть запущен только один экземпляр Vert.x. Но на одной машине можно запустить несколько JVM с Vert.x внутри.
Экземпляр Vert.x гарантирует, что каждый экземпляр вертикла всегда будет выполняться в одном и том же потоке. Так что программисту не приходится переживать о синхронизации, дедлоках и прочих проблемах многопоточности.
Конечно, есть задачи, время выполнения которых сложно рассчитать, например обращение к базе данных или сложные вычисления. Выполнение такой задачи в потоке цикла событий привело бы к проблемам производительности. Поэтому ее можно пометить как worker verticle, чтобы она выполнялась в отдельном потоке из специально выделенного пула потоков и не препятствовала работе основного потока цикла событий. Нужно помнить, что все задачи, помеченные как «рабочий вертикл», выполняются последовательно, так что лучше уменьшать их количество до минимума и использовать, только когда они действительно необходимы.
Вертиклы общаются между собой, передавая сообщения в шину событий (event bus). Экземпляр Vert.x держит специальный поток для обработки таких сообщений (event loop). Когда поступает новое сообщение, поток просыпается, выполняет его, передает результат слушателю сообщения и снова засыпает. Также Vert.x предоставляет общую карту (map) и набор (set) для передачи данных между вертиклами, запущенными в рамках одного Vert.x-экземпляра. Чтобы избежать проблем с синхронизацией данных, передавать можно только неизменяемые данные (immutable).
Передать сообщение через шину событий можно двумя способами: используя метод publish или метод send. Метод publish передает сообщение по указанному адресу. Каждый подписчик с таким адресом получит сообщение. Адрес — это просто строка. Адрес может быть каким угодно, но по правилам хорошего тона принято указывать в качестве префикса полное имя класса, который опубликовал сообщение.
Метод send отправляет сообщение одному конкретному адресату. Если на данный адрес подписано несколько вертиклов, то получатель определяется по алгоритму циклического распределения нагрузки (round-robin). Преимущества использования этого алгоритма в легком масштабировании. Если для выполнения какого‑то действия не хватает ресурсов, то можно просто запустить еще несколько экземпляров одного вертикла, и нагрузка будет равномерно распределена между ними.
Пример приложения
В преддверии 8 марта напишем простенький сервис виртуальных поздравлений (сохраняйте спокойствие, ведь автор — женщина! — Прим. ред.). Пользователь заходит на сайт и оставляет координаты человека, которого он хотел бы поздравить, и свои пожелания к поздравлению: шуточное, романтическое, официальное... Заказ на поздравление попадает к одному из агентов, тот его выполняет и ставит соответствующий статус, который сразу передается пользователю.
Чтобы добиться интерактивности, воспользуемся веб‑сокетами. Для начала займемся сервером. Нам понадобится последний релиз Vert.x (берем с сайта) и обычный Java-проект в твоей любимой среде программирования. К проекту нужно добавить библиотеки vertx-core
, vertx-platform
, netty-all
для работы сервера и клиента и библиотеки jackson-annotation
, jackson-core
, jackson-databind
для работы с данными в формате JSON.
Создаем класс сервера, наследованный от Verticle. Метод start
является входной точкой приложения. Выполняем инициализацию HTTP-сервера, он будет отдавать страницу client.
по дефолтному пути и файлы ресурсов, которые соответствуют маске. Остальные запросы будут игнорироваться. Запускаем сервер на 8080-м порту. Handler
— это базовый класс для всех обработчиков, используется везде, где надо что‑то обработать асинхронно. HttpRequestServer
— это класс с информацией о HTTP-запросе от клиента.
public class Server extends Verticle { public void start() { RouteMatcher httpRouteMatcher = new RouteMatcher() .get("/", new Handler<HttpServerRequest>() { @Override public void handle(HttpServerRequest request) { request.response().sendFile("webroot/client.html"); } }) .get(".*\\.(css|js)$", new Handler<HttpServerRequest>() { @Override public void handle(HttpServerRequest request) { request.response().sendFile("webroot/" + new File(request.path())); } }); vertx.createHttpServer().requestHandler(httpRouteMatcher).listen(8080); }}
Все ресурсы приложения хранятся в папке webroot
, которую следует поместить в папке, откуда запускается приложение. Если положить туда простой HTML-файл с названием client.
, то уже на этом этапе можно убедиться, что сервер работает. Запустить сервер можно из IntelliJ IDEA (или другой среды программирования) либо прямо из командной строки даже без предварительной компиляции.
vertx run Server.java
На 8090-м порту будем принимать соединения от веб‑сокетов. Сервер будет принимать только два адреса: "/
" для соединений от клиента, "/
" для соединений от агентов, остальные соединения отклоняются.
final String clientUrl = "/client";final String agentUrl ="/agent";vertx.createHttpServer().websocketHandler(new Handler<ServerWebSocket>() { @Override public void handle(ServerWebSocket socket) { logger.info("WebSocket " + socket.path()); if (socket.path().startsWith(agentUrl)) { processAgent(socket); } else if (socket.path().startsWith(clientUrl)) { // Заберем имя пользователя processClient(socket, socket.query().split("=")[1]); } else { socket.reject(); } }}).listen(8090);
Фронтенд для клиента реализуем с помощью HTTP и JavaScript. Пользователь указывает свое имя‑фамилию и логинится в систему.
В это время создаем веб‑сокетное соединение к серверу и регистрируем обработчик входящих сообщений.
<script> var serviceLocation = "ws://localhost:8090/client/"; var wsocket; function connectToServer() { fullName = $userName.val() + ' ' + $userSurname.val(); wsocket = new WebSocket(serviceLocation + '?username=' + fullName); wsocket.onmessage = onMessageReceived; }</script>
Предлагаем пользователю ввести имя и телефон «жертвы» и свои особые пожелания к поздравлению. Всю эту информацию складываем в JSON и отправляем на сервер.
function sendMessage() { var msg = '{"recipientName":"' + $recipientName.val() + '", "recipientPhone":"' + $recipientPhone.val() + '", "message":"' + $message.val() + '", "sender":"' + $userName.val() + '"}'; wsocket.send(msg); }
На сервере регистрируем обработчик данных для клиента. При получении нового сообщения проставляем ему статус «Принято» и отправляем заказ агенту и обратно клиенту, чтобы показать, что заказ в обработке. Для хранения идентификатора сокета клиента воспользуемся шаренным сетом, где ключом будет имя пользователя. Идентификатор понадобится нам, чтобы перенаправлять сообщения от агента нужному пользователю.
// Получаем объект шины событий, чтобы обмениваться сообщениями между клиентом и агентомfinal EventBus eventBus = vertx.eventBus();void processClient(ServerWebSocket socket, final String userName) { final String id = socket.textHandlerID(); // Сохраняем идентификатор сокета, чтобы в дальнейшем пересылать сообщения от агента getVertx().sharedData().getSet(userName).add(id); socket.dataHandler(new Handler<Buffer>() { @Override public void handle(Buffer buffer) { JsonObject root = new JsonObject(buffer.toString()); root.putString("status", "Принято"); // Шлем ответ клиенту eventBus.send(id, root.toString()); //send to the agent eventBus.send(agentUrl, root.toString()); } }); socket.closeHandler(new Handler<Void>() { @Override public void handle(final Void event) { // Когда пользователь отсоединяется, удаляем идентификатор сокета, чтобы не слать сообщения в никуда vertx.sharedData().getSet(userName).remove(id); } });}
Когда клиент получает сообщение от сервера с новым статусом, отображаем его в таблице.
function onMessageReceived(evt) { var msg = JSON.parse(evt.data); // native API var $messageLine = $('<tr><td class="status">' + msg.status + '</td><td>' + msg.recipientName + '</td><td>' + msg.recipientPhone + '</td><td>' + msg.message + '</td></tr>'); $chatWindow.append($messageLine); }
Для агентов напишем приложение на JavaFX, чтобы сразу было видно, что они работают, а не в инете шарятся. Для этого добавим новый модуль в проект и создадим класс FelicitationAgent
, наследованный от Application
. Набросаем легкий интерфейс: табличку, в которую будут приходить новые заказы, и форму для редактирования статуса заказа.
Чтобы загружать данные с сервера, открываем веб‑сокет и назначаем обработчик на входящие сообщения. Сообщения из JSON-формата преобразуем в класс и добавляем в таблицу.
private void loadData() { Vertx vertx = VertxFactory.newVertx(); vertx.createHttpClient().setHost("localhost").setPort(8090).connectWebsocket("/agent", new Handler<WebSocket>() { @Override public void handle(WebSocket websocket) { websocket.dataHandler(new Handler<Buffer>() { @Override public void handle(Buffer data) { JsonObject object = new JsonObject(data.toString()); FelicitationRequest request = new FelicitationRequest( object.getString("recipientName"), object.getString("recipientPhone"), object.getString("message"), object.getString("status"), object.getString("sender") ); // Добавляем новый запрос в таблицу updateData(request); } }); } });}
Осталось дописать обработку соединения от агента на сервере. Регистрируем обработчик для входящих сообщений от клиента, который просто перенаправляет их через веб‑сокет в JavaFX-приложение для агента. Второй обработчик принимает сообщения от агента и отправляет их нужному клиенту, отыскав идентификатор сокета по имени клиента из сообщения.
void processAgent(final ServerWebSocket socket) { eventBus.registerHandler(agentUrl, new Handler<Message>() { @Override public void handle(Message message) { Buffer buffer = new Buffer(message.body().toString()); socket.write(buffer); } }); socket.dataHandler(new Handler<Buffer>() { @Override public void handle(Buffer buffer) { JsonObject root = new JsonObject(buffer.toString()); Set<Object> set = getVertx().sharedData().getSet(root.getString("sender")); for (Object client : set) { eventBus.send((String) client, root.toString()); } } });}
Вот и все. Пользователь регистрируется на сайте и оставляет заявку. Сервер направляет ее первому свободному агенту. Тот звонит с поздравлением и меняет статус заявки. После чего она отправляется обратно клиенту и отображается в таблице заявок с новым статусом.
К сожалению, в данной реализации есть один существенный недостаток. Если один из агентов отсоединится от сервера, не отменив подписку на события, Vert.x будет считать, что он в онлайне, и продолжать отправлять ему сообщения. Чтобы избежать такой ситуации, можно либо использовать метод sendWithTimeout
, либо создать отдельный таймер, который будет проверять, обрабатывает ли кто‑то из агентов сообщение, или стоит его передать другому агенту.
Выводы
В целом Vert.x оставляет приятное впечатление. Асинхронная модель позволяет не думать о синхронизации и блокировании доступа. Использование модулей со слабой связанностью делает приложение гибче и уменьшает затраты времени на разработку и поддержку. Уменьшение использования потоков значительно экономит ресурсы системы. Приложение на Vert.x легко расширяется и масштабируется. Чтобы запустить, скажем, десять вертиклов для распределения нагрузки, достаточно прописать в командной строке -instance
. Все это делает использование Vert.x привлекательным для разработки традиционных веб‑приложений, популярных в последнее время приложений реального времени, игр и даже для back end’a к мобильным приложениям.
Основные методы получения событий
Polling
Самый простой, но самый неэффективный метод. Клиент раз в несколько секунд опрашивает сервер на наличие событий.
Плюсы:
- простота.
Минусы:
- очень много лишних запросов;
- события всегда приходят с опозданием;
- серверу приходится хранить события, пока клиент не заберет их или пока они не устареют.
Long Polling
Улучшенный вариант предыдущего метода. Клиент отправляет запрос на сервер, сервер держит открытым соединение, пока не придут какие‑нибудь данные или клиент не отключится самостоятельно. Как только данные пришли, отправляется ответ, соединение закрывается и открывается следующее и так далее.
Плюсы по сравнению с Polling:
- минимальное количество запросов;
- высокая временная точность событий;
- сервер хранит события только на время реконнекта.
Минусы по сравнению с Polling:
- более сложная схема.
WebSockets
Плюсы по сравнению с Long Polling:
поднимается одно соединение;
предельно высокая временная точность событий;
управление сетевыми сбоями контролирует браузер.
Минусы по сравнению с Long Polling:
- HTTP не совместимый протокол, нужен свой сервер, усложняется отладка.
Это бинарный дуплексный протокол, позволяющий клиенту и серверу общаться на равных. Этот протокол можно применять для игр, чатов и всех тех приложений, где нужны предельно точные события, близкие к реальному времени.