WebSocket: Реализация web-приложения с использованием Jetty Web Socket. Часть 1

Добрый день, Хабражитель!

Поздравляю всех и каждого с великим Днем Программиста! Желаю рабочего кода, уверенных сокетов и самых продвинутых пользователей!

Работая над автоматизацией концертного агентства, мне на каком-то этапе разработки понадобилась система уведомлений. Доступ к автоматизации происходит через написанное мною web-приложение. И, соответственно, моментальные уведомления должны приходить в браузер пользователя.

Для реализации такой задачи есть три решения:
  • «бесконечный iframe»,
  • используя XMLHttpRequest (a.k.a. Ajax),
  • используя WebSocket.

Первое решение я сразу «отметаю» (причины объяснять не буду, web-разработчики меня поймут).

Второе решение нравится гораздо больше, но у него есть свои минусы:
  • браузер отправляет запрос каждую секунду создавая лишнюю нагрузку на:
    • сервер;
    • ОС, на которой работает браузер;
    • и еще раз на сервер, так как сервер постоянно выполняет запрос БД на выборку последних уведомлений.
  • тяжело отследить онлайн-статус пользователя (то есть нужно, например, хранить сессии в БД и постоянно мониторить каждую на timeout).

Третье решение — как раз то, что доктор прописал.

Итак, WebSocket.

Среди минусов WebSocket важен только тот, что его пока поддерживают только браузеры webkit (они же Google Chrome и Apple Safari).

Давайте попробуем реализовать простой чат, как web-приложение c базовой возможностью полнодуплексного обмена сообщениями между клиентом и сервером.

Реализация клиентской части

Реализация WebSocket-ов на JavaScript на клиентской стороне простая, на неё не будем тратить много времени. Смотрим листинг.

var socket = new WebSocket("ws://myserver.com:8081/");
socket.onopen = function () {
  console.log("Соединение открылось");
};
socket.onclose = function () {
  console.log ("Соединение закрылось");
};
socket.onmessage = function (event) {
  console.log ("Пришло сообщение с содержанием:", event.data);
};


Отправлять сообщения на сервер можно методом send():
socket.send(messageString);

Реализация серверной части

Реализация решения на сервере выглядит заметно сложнее. В сети можно найти несколько вариантов реализации, из них наиболее ближе ко мне были:

Из них JWebSocket выделяется тем, что это большой фреймворк для работы с WebSocket, одновременно являющийся еще и надежным standalone-сервером. JWebSocket требует отдельного поста. А сейчас мы остановимся на самом простом варианте решения на платформе J2EE: Jetty.

Что мы имеем? Давайте взглянем на схему.

схема взаимодействия браузера, glassfish и jetty

В моем случае мы имеем контейнер сервлетов GlassFish на порту 8080. Браузер отправляет запрос на GlassFish [1], который передает браузеру страничку чата [2], которая по желанию пользователя соединяется с сервером через порт 8081 по протоколу WebSocket [3]. Далее происходит полнодуплексный обмен данными между браузером и сервером [4].

На момент написания поста последняя версия Jetty 8.0.1.v20110908. Скачиваем, распаковываем (извиняюсь перед всеми разработчиками, использующих Maven), из дистрибутива нас интересуют 6 библиотек:
  • jetty-continuation-8.0.1.v20110908.jar,
  • jetty-http-8.0.1.v20110908.jar,
  • jetty-io-8.0.1.v20110908.jar,
  • jetty-server-8.0.1.v20110908.jar,
  • jetty-util-8.0.1.v20110908.jar,
  • jetty-websocket-8.0.1.v20110908.jar.

Добавляем эти библиотеки в проект.

Возвращаясь к клиентской части, скачиваем ее отсюда (не хочу засорять пост листингом HTML-кода). Добавляем файл chat.html в Web Pages проекта. В дескрипторе развёртывания (web.xml) указываем chat.html как «welcome-file».

Теперь немного о Jetty.

Embedded-сервер Jetty находится в классе org.eclipse.jetty.server.Server. И конструктор может содержать либо адрес сервера, либо, как в нашем случае номер порта:

Server jetty = new Server(8081);

Далее нам нужно добавить в jetty нужные handlerы и запустить его. Запускается и останавливается jetty методами без параметров start() и stop() соответственно. А вот handler нам надо будет написать свой, создаем новый класс. Чтобы у нас был handler для обработки соединений по протоколу WebSocket, мы должны его наследовать от org.eclipse.jetty.websocket.WebSocketHandler:

public class ChatWebSocketHandler extends WebSocketHandler {

}

У класса WebSocketHandler есть один абстрактный метод doWebSocketConnect(). Он, собственно, и вызывается, когда браузер открывает новое соединение с jetty, и возвращает объект WebSocketа.

Класс WebSocket нам тоже следует определить свой, и наследовать мы его будем от интерфейса org.eclipse.jetty.websocket.WebSocket.OnTextMessage — этот интерфейс работает с данными, проходящими через протокол WebSocket, как с текстовыми данными. Интерфейс org.eclipse.jetty.websocket.WebSocket.OnTextMessage содержит три метода:
  • onOpen() – вызывается после открытия сокета;
  • onClose() – вызывается перед закрытием сокета;
  • onMessage() – вызывается когда приходит сообщение от клиента.

Вроде бы, все просто! Давайте посмотрим на листинг ChatWebSocketHandler.

import java.io.IOException;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletRequest;
import org.eclipse.jetty.websocket.WebSocket;
import org.eclipse.jetty.websocket.WebSocketHandler;

public class ChatWebSocketHandler extends WebSocketHandler {

    /**
     * Набор открытых сокетов
     */
    private final Set<ChatWebSocket> webSockets = new CopyOnWriteArraySet<ChatWebSocket>();

    /**
     * Выполняется когда пытается открыться новое соединение
     * @param request
     * @param protocol протокол (бывает двух видов ws и wss)
     * @return 
     */
    @Override
    public WebSocket doWebSocketConnect(HttpServletRequest request,
            String protocol) {

        // У нас есть два варианта
        // Либо мы не пускаем клиента и вызываем исключение
        //    throw new Exception();
        // Либо возвращаем объект, который будет соединять сервер с клиентом
        //   и обрабатывать запросы от клиента 
        return new ChatWebSocket();
    }

    private class ChatWebSocket implements WebSocket.OnTextMessage {

        /**
         * Хранилище соединения
         */
        private Connection connection;
        
        /**
         * Ник пользователя
         */
        private String userName = null;
        
        /**
         * Шаблон входящей команды авторизации
         */
        private final Pattern authCmdPattern = Pattern.compile("^\\/auth ([\\S]+).*");
        
        /**
         * Шаблон входящей команды получения списка пользователей
         */
        private final Pattern getUsersCmdPattern = Pattern.compile("^\\/getUsers.*");
        
        /**
         * Шаблон входящей команды получения помощи
         */
        private final Pattern helpCmdPattern = Pattern.compile("^\\/help.*");

        /**
         * Выполняется когда открыто новое соединение
         * @param connection 
         */
        @Override
        public void onOpen(Connection connection) {

            // Сохраняем соединение в свойство ChatWebSocket::connection
            this.connection = connection;

            // Добавляем себя в глобальный набор ChatWebSocketHandler::webSockets
            webSockets.add(this);
        }

        /**
         * Выполняется когда пришло новое сообщение
         * @param data 
         */
        @Override
        public void onMessage(String data) {
            
            // На всякий случай удаляем теговые дескрипторы
            data = data.replaceAll("<", "<").replaceAll(">", ">");
            
            // Если пришла команда авторизации
            if (authCmdPattern.matcher(data).matches()) {
                Matcher matcher = authCmdPattern.matcher(data);
                matcher.find();
                
                // Устанавливаем новый ник пользователю
                userName = matcher.group(1);

                try {
                    // Цикл шарит по набору сокетов ChatWebSocketHandler::webSockets
                    for (ChatWebSocket webSocket : webSockets) {

                        // и отправляет сообщение, что подключился новый пользователь
                        webSocket.connection.sendMessage("inf|"
                                + (webSocket.equals(this)
                                ? "Вы успешно авторизировались"
                                : ("В чат подключился <b>" + userName + "</b>")));
                    }
                } catch (IOException x) {

                    // Все ошибки будут приводить к разъединению клиента от сервера
                    connection.disconnect();
                }

            // Если пришла команда получения списка пользователей
            } else if (getUsersCmdPattern.matcher(data).matches()) {

                String userList = "";

                // Цикл шарит по набору сокетов ChatWebSocketHandler::webSockets
                for (ChatWebSocket webSocket : webSockets) {
                    userList += webSocket.userName + ", ";
                }
                userList = userList.substring(0, userList.length() - 2);

                try {

                    // Отсылаем список активных пользователей
                    connection.sendMessage("inf|Список активных пользователей: " + userList);

                } catch (IOException x) {

                    // Все ошибки будут приводить к разъединению клиента от сервера
                    connection.disconnect();
                }

            // Если пришла команда получения помощи
            } else if (helpCmdPattern.matcher(data).matches()) {
                
                String helpMessage = "Отправлять сообщения можно просто написав "
                        + "их в поле для ввода и нажать Enter.<br />"
                        + "Чат поддерживает три команды:<br />"
                        + "<ul><li><b>/help</b> - для распечатки этого сообщения</li>"
                        + "<li><b>/getUsers</b> - для получения списка пользователей</li>"
                        + "<li><b>/auth <i>ник</i></b> - для авторизации</li></ul>";

                try {
                    // Отсылаем инструкцию
                    connection.sendMessage("inf|" + helpMessage);

                } catch (IOException x) {

                    // Все ошибки будут приводить к разъединению клиента от сервера
                    connection.disconnect();
                }
                
            // Если пришла не команда а сообщение
            } else {


                try {

                    // Если пользователь не авторизирован
                    if (userName == null) {
                        connection.sendMessage("err|Вы не авторизированны<br />"
                                + "Используйте команду <b>/help</b> для помощи");
                        return;
                    }
                    // Цикл шарит по набору сокетов ChatWebSocketHandler::webSockets
                    for (ChatWebSocket webSocket : webSockets) {

                        // и каждому рассылает сообщение с флагом in для всех
                        // кроме автора, автору - флаг out
                        webSocket.connection.sendMessage((webSocket.equals(this) ? "out|" : ("in|" + userName + "|")) + data);
                    }
                } catch (IOException x) {

                    // Все ошибки будут приводить к разъединению клиента от сервера
                    connection.disconnect();
                }
            }

        }

        /**
         * Выполняется когда клиент разъединяется от сервера
         * @param closeCode
         * @param message 
         */
        @Override
        public void onClose(int closeCode, String message) {

            // Удаляем себя из глобального набора ChatWebSocketHandler::webSockets
            webSockets.remove(this);
        }
    }
}


И так, что мы видим в ChatWebSocketHandler:
  • 1 свойство – набор сокетов;
  • 1 метод, который нам создает и возвращает новый сокет;
  • 1 приватный класс, который реализует этот WebSocket.

В классе ChatWebSocket нас интересует метод onMessage(). В нем мы будем реализовывать протокол обмена данными клиентской и серверной части. По комментариям видно, принцип его работы.

Что из себя представляет протокол обмена?

Сервер принимает от клиента все текстовые сообщения, из них может выделить три команды:
  • /auth ник //Для авторизации
  • /getUsers //Для получения списка пользователей
  • /help //Для получения помощи

Клиент принимает от сервера сообщения с такими шаблонами:
  • inf|информация // Пришла информация
  • in|ник|сообщение // Входящее сообщение от пользователя ник
  • out|сообщение // Мое сообщение отправлено
  • err|ошибка // Пришла информация об ошибке

Остальное не буду расписывать повторно, — все описано в комментариях.

Теперь перейдем к самому ответственному моменту — запуск сервера jetty. Значит, у нас задача: при старте контейнера сервлетов запустить jetty; перед остановкой контейнера сервлетов — остановить jetty. Самый простой способ это реализовать в GlassFish — написать свой ContextListener. Давайте посмотрим почему? Создаем новый класс и наследуем его от интерфейса javax.servlet.ServletContextListener. Смотрим листинг.

import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.DefaultHandler;

public class ChatServerServletContextListener implements ServletContextListener {
    
    /**
     * Хранилище сервера Jetty
     */
    private Server server = null;
    
    /**
     * Метод вызывается когда контейнер сервлетов запускается
     * @param event 
     */
    @Override
    public void contextInitialized(ServletContextEvent event) {
        
        try {
            // Создание сервера Jetty на 8081 порту
            this.server = new Server(8081);
            
            // Регистрируем ChatWebSocketHandler в сервере Jetty
            ChatWebSocketHandler chatWebSocketHandler = new ChatWebSocketHandler();
            // Это вариант хэндлера для WebSocketHandlerContainer
            chatWebSocketHandler.setHandler(new DefaultHandler());
            
            // Вставляем наш хэндлер слушаться jetty
            server.setHandler(chatWebSocketHandler);
            
            // Запускаем Jetty
            server.start();
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }

    /**
     * Метод вызывается когда контейнер сервлетов останавливается
     * @param event 
     */
    @Override
    public void contextDestroyed(ServletContextEvent event) {

        // Если сервер jetty когда-нибудь запустился
        if (server != null) {
            try {
                // останавливаем Jetty
                server.stop();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}


Интерфейс javax.servlet.ServletContextListener имеет два метода с говорящими самими за себя именами: contextInitialized(), contextDestroyed().

Теперь нам остается только подключить наш ContextListener в дескриптор развёртывания (web.xml). Приведу его листинг:

<?xml version="1.0" encoding="UTF-8"?>

<web-app xmlns="http://java.sun.com/xml/ns/javaee"
	 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	 xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
	 version="3.0">
    <session-config>
        <session-timeout>
            30
        </session-timeout>
    </session-config>
    <welcome-file-list>
        <welcome-file>chat.html</welcome-file>
    </welcome-file-list>
    <listener>
        <listener-class>ChatServerServletContextListener</listener-class>
    </listener>
</web-app>


Ну теперь можно и запустить проект. В браузере открываем сразу два окна и балуемся. Прошу обратить внимание на время, сообщения доходят практически моментально. Такую скорость работы на ajax получить практически невозможно.

image

image

Заключение

Весь проект в сборе можно скачать по ссылке
Проект полностью совместим с GlassFish 2.x, 3.x и Tomcat не ниже 5.0. Проект создавал в Netbeans 7.0.1. Внутри проекта можно найти ant-deploy.xml для развертки на Ant. Еще раз дико извиняюсь перед разработчиками, использующих Maven.

Во второй части статьи я подробно опишу проблемы, которые возникают при работе с WebSocket, и их решения.
В третьей части я опишу способы борьбы с ddos-атаками на серверы Jetty.

Спасибо за внимание. Всех еще раз с праздником!
Share post

Comments 29

    0
    Не только вебкит-браузеры работают с вебсокетами. Опера их поддерживает, просто по умолчанию они выключены, включаются тут
    opera:config#UserPrefs|EnableWebSockets
    Firefox тоже поддерживает, и тоже отключено по умолчанию, хотя вроде как говорили что в шестой версии они будут включены по умолчанию, тут пользователи фаерфокса подскажут точнее.
      0
      да да, в курсе, что FF и Opera отключили WS по-умолчанию, но пользователям не объяснишь…

      В моем случае, автоматизацией пользоваться будут американцы, подавляющим большинством они пользуются браузером Safari.

      На сегодняшний момент вижу большой потенциал использования вебсокетов в Google Apps
        0
        >В моем случае, автоматизацией пользоваться будут американцы, подавляющим большинством они пользуются браузером Safari.

        статистика с вами не согласна
        gs.statcounter.com/#browser-US-monthly-201008-201108

        если вы только не про ограниченный круг американских заказчиков :)
          0
          Извиняюсь за двусмысленное понятие предолжения… Заказчик в США, он, а также люди в его агентстве используют маки
          • UFO just landed and posted this here
              0
              Совсем не обязательно, конечно! Вы работали за маком в Safari? — Мне кажется, очень приятный минималистичный браузер. Все прокрутки работают плавно, анимация не тормозит. Safari на маке очень не похож на Safari для Windows, для серфинга очень даже ничего.

              Я сколнен предположить, что большинству пользователей мака не нужны другие браузеры, одного Safari достаточно.
              • UFO just landed and posted this here
                  +1
                  Ну Сафари для маков — единственный браузер, который работает также как и весь Мак.
                  Все остальные браузеры работают на маке как-то «по-виндозовски».
                    0
                    Мне кажется, пользователи мака отказываются от Safari только из-за ненависти к IE
                    (это не относится к web-разработчикам, которым нужен firebug и пр.)
          0
          >да да, в курсе, что FF и Opera отключили WS по-умолчанию, но пользователям не объяснишь…

          не только пользователям, www.webmonkey.com/2010/12/security-flaws-force-firefox-opera-to-turn-off-websockets/

          Firefox and Opera have both disabled support for HTML5 WebSockets in the latest builds of their respective browsers. The move comes on the heels of a protocol vulnerability that could leave thousands of sites harboring malicious code.
        +1
        Зачем Jetty использовать, если вы все равно Glassfish используете?
        В нем же есть все необходимое для работы с WebSocket…
          +1
          Я очень хочу написать пост про JWebSocket… Как закончу серию этих постов, обязательно, напишу про JWebSocket! Какой смысл использовать JWebSocket, который основан на Jetty WebSocket, который в свою очередь основан на Netty? Не легче использовать сразу Netty, программируя базовые уровни обмена данными через UDP?
            +2
            Вы извините. Может переборщил. Пьяный сегодня. День Программиста. Что я хотел сказать — каждый более высокий уровень программирования почти всегда сложнее чем более низкий.
              0
              Я сам комментарий не туда запостил… С празником!
            0
            Это вы уже серьезно заговорились:
            >> программируя базовые уровни обмена данными через UDP…

            Netty вы используете косвенно, поэтому по вашей логике не понятно зачем Jetty WebSockets?

            Ответ простой… Данные обертки существуют не просто так, а чтобы упростить реализацию, которая в вашем случае, необоснована избыточна за счет спуска на другой уровень абстракции. А если посмотреть исходники оберток, то можно и обнаружить невероятные заплатки в некоторых местах, которые уберегают от лишних проблем.
              +2
              Не готов к холивару… Писал об этом выше. Извини. Давайте продолжим, когда будем трезвыми) Сегодня Ваш день! Я надеюсь, вы достойно прокомментируете вторую часть статьи. Спасибо за ваш комментарий. Вы не представляете, как я Вам рад сегодня!
              +1
              > браузер отправляет запрос каждую секунду создавая лишнюю нагрузку на:

              Просто чтобы Вы знали (вдруг куда-нибудь программистом устраиваться пойдёте, мало ли) — есть такая волшебная штука как long polling. Которая а) практически никого не нагружает и б) совместима чуть ли не с IE6.

              Будьте здоровы и не пейте много.
                0
                Сколько бы я не выпил уже…

                long polling для меня — это «бесконечный iframe» версии 2.0

                Может быть, это мое субъективное мнение… Но зачем использовать XMLHttpRequest для отложенного ответа в IE6, если бесконечный iframe поддерживается даже в IE3 на Windows 95.

                Кстати, Windows 95 даже IPv4 без бубна не тянет)))
                  0
                  Это са-авсем не бесконечный ифрейм. Ну то есть и не близко даже. Спокойной ночи!:)
                +3
                А чем плох «бесконечный iframe» и long-polling? И тем более, чтобы веб разработчики поняли?:)
                Нам у себя в проекте тоже понадобилось использовать возможности push-нотификаций с сервера. И тоже на java. Использование только web socket'ов — пока рановато. В итоге пришли к использованию Atmosphere. Поддерживает все концепции: бесконечный iframe, long-polling, web-socket, при этом старается «по умному» определить нужную. И, кроме того, абстрагирует код от конкретного сервера приложений. Перенести проект на новый сервер приложений можно просто заменив библиотеку в зависимостях.
                  0
                  Согласен. Все зависит от задачи. WebSocket нельзя использовать как единственный способ связи на сайтах. Но если речь идет об автоматизации деятельности предприятия, где разработчик диктует системные требования?

                  Более того, WebSocket прекрасно поддерживает Chrome. В виду последних событий, развитие Google Apps в сомнения не вводит!

                  Также технология очень популярна в развитии web-приложений для Android и iOS. iOS и Android используют миллионы, многие ресурсы www делают отдельный клиент для этих платформ, так как, скорее всего такие пользователи — именно наши целевые клиенты, — клиенты, которые готовы платить $5/мес. за достойный сервис.

                  А рано или поздно все равно iframe и long-polling уйдет в прошлое. Главное тут то, чтобы во-время просветить себя в новых технологиях, тем более, что они предоставляют гораздо более лаконичный и простой доступ к данным без каких либо костылей. Не согласны?
                    +2
                    Хмм, я конечно сварщик не настоящий в Jetty, но чем вам jetty continuation не угодил? www.ibm.com/developerworks/ru/library/j-jettydwr/
                      0
                      Про Jetty можно написать десятки постов. Он достоин отдельного блога. Вот так вышло, что написал про WebSocket, а не про continuation
                        +1
                        Вы в посте утверждаете что для решения поставленной задачи есть три варианта. Почему вы не рассматривали вариант jetty continuation? Почему данный вариант вам не подошел?
                          0
                          jetty continuation использует XMLHttpRequest, посему он не четвертый вариант, а второй)
                          Информации по XMLHttpRequest существует достаточно много. Я же хотел рассказать про WebSocket. А в моем проекте, который я уже сдал, я не использовал Jetty напрямую. Я реализовал WebSocket с использованием JWebSocket, у которого есть поддержка соединения через FlashBridge. Про JWebSocket хочу написать отдельную серию постов.
                            +1
                            Хорошо, но если jetty continuation второй вариант, то как быть с недостатком — «браузер отправляет запрос каждую секунду»? Это ведь неправильно в данном случае, соединение устанавливается и клиент ждет пока сервер найдет для него данные.

                            Да и второй недостаток под вопросом — «тяжело отследить онлайн-статус пользователя». Вы же храните в памяти набор открытых сокетов, точно также можно continuation хранить активные.

                            Конечно у jetty continuation есть недостатки (низкий уровень абстракции, открытие нового соединения для следующего запроса). Но в принципе они преодолимы и не так страшны, да и работают практически везде где есть xmlhttprequest. К тому же не стоит забывать что jetty continuation используются как библиотека для построения websocket.
                              0
                              Спасибо за замечания. Но я ставил цель показать самый простой способ использования WebSocket в своих проектах. Тем более это наиболее удобный вариант для реализации web-клиентов для iPhone и Android-смартфонов, а также Google Apps. — что без сомнения делает это решение востребованным в наши дни.
                        +1
                        Ну это уже немножко другое, но можно использовать для аналогичных задач.
                        В этом случае нужно вспомнить очень не плохой engine на базе Jetty: Cometd
                        0
                        Запускать Jetty внутри уже существующего контейнера — то еще извращение!
                        Быстрый поиск вывел на эту статью java.dzone.com/articles/tomcat-websockets-html5
                        Вообще, я думаю, стоит подождать, пока контейнеры не начнут поддерживать JSR-340 по-умолчанию, а пока использовать проверенные способы типа long pooling.

                        Only users with full accounts can post comments. Log in, please.