Pull to refresh

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

Reading time 9 min
Views 88K
Добрый день, Хабражитель!

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

Работая над автоматизацией концертного агентства, мне на каком-то этапе разработки понадобилась система уведомлений. Доступ к автоматизации происходит через написанное мною 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.

Спасибо за внимание. Всех еще раз с праздником!
Tags:
Hubs:
+30
Comments 29
Comments Comments 29

Articles