Добрый день, Хабражитель!
Поздравляю всех и каждого с великим Днем Программиста! Желаю рабочего кода, уверенных сокетов и самых продвинутых пользователей!
Работая над автоматизацией концертного агентства, мне на каком-то этапе разработки понадобилась система уведомлений. Доступ к автоматизации происходит через написанное мною web-приложение. И, соответственно, моментальные уведомления должны приходить в браузер пользователя.
Для реализации такой задачи есть три решения:
Первое решение я сразу «отметаю» (причины объяснять не буду, web-разработчики меня поймут).
Второе решение нравится гораздо больше, но у него есть свои минусы:
Третье решение — как раз то, что доктор прописал.
Итак, WebSocket.
Среди минусов WebSocket важен только тот, что его пока поддерживают только браузеры webkit (они же Google Chrome и Apple Safari).
Давайте попробуем реализовать простой чат, как web-приложение c базовой возможностью полнодуплексного обмена сообщениями между клиентом и сервером.
Реализация WebSocket-ов на JavaScript на клиентской стороне простая, на неё не будем тратить много времени. Смотрим листинг.
Отправлять сообщения на сервер можно методом send():
Реализация решения на сервере выглядит заметно сложнее. В сети можно найти несколько вариантов реализации, из них наиболее ближе ко мне были:
Из них JWebSocket выделяется тем, что это большой фреймворк для работы с WebSocket, одновременно являющийся еще и надежным standalone-сервером. JWebSocket требует отдельного поста. А сейчас мы остановимся на самом простом варианте решения на платформе J2EE: Jetty.
Что мы имеем? Давайте взглянем на схему.
В моем случае мы имеем контейнер сервлетов GlassFish на порту 8080. Браузер отправляет запрос на GlassFish [1], который передает браузеру страничку чата [2], которая по желанию пользователя соединяется с сервером через порт 8081 по протоколу WebSocket [3]. Далее происходит полнодуплексный обмен данными между браузером и сервером [4].
На момент написания поста последняя версия Jetty 8.0.1.v20110908. Скачиваем, распаковываем (извиняюсь перед всеми разработчиками, использующих Maven), из дистрибутива нас интересуют 6 библиотек:
Добавляем эти библиотеки в проект.
Возвращаясь к клиентской части, скачиваем ее отсюда (не хочу засорять пост листингом HTML-кода). Добавляем файл chat.html в Web Pages проекта. В дескрипторе развёртывания (web.xml) указываем chat.html как «welcome-file».
Теперь немного о Jetty.
Embedded-сервер Jetty находится в классе org.eclipse.jetty.server.Server. И конструктор может содержать либо адрес сервера, либо, как в нашем случае номер порта:
Далее нам нужно добавить в jetty нужные handlerы и запустить его. Запускается и останавливается jetty методами без параметров start() и stop() соответственно. А вот handler нам надо будет написать свой, создаем новый класс. Чтобы у нас был handler для обработки соединений по протоколу WebSocket, мы должны его наследовать от org.eclipse.jetty.websocket.WebSocketHandler:
У класса WebSocketHandler есть один абстрактный метод doWebSocketConnect(). Он, собственно, и вызывается, когда браузер открывает новое соединение с jetty, и возвращает объект WebSocketа.
Класс WebSocket нам тоже следует определить свой, и наследовать мы его будем от интерфейса org.eclipse.jetty.websocket.WebSocket.OnTextMessage — этот интерфейс работает с данными, проходящими через протокол WebSocket, как с текстовыми данными. Интерфейс org.eclipse.jetty.websocket.WebSocket.OnTextMessage содержит три метода:
Вроде бы, все просто! Давайте посмотрим на листинг ChatWebSocketHandler.
И так, что мы видим в ChatWebSocketHandler:
В классе ChatWebSocket нас интересует метод onMessage(). В нем мы будем реализовывать протокол обмена данными клиентской и серверной части. По комментариям видно, принцип его работы.
Что из себя представляет протокол обмена?
Сервер принимает от клиента все текстовые сообщения, из них может выделить три команды:
Клиент принимает от сервера сообщения с такими шаблонами:
Остальное не буду расписывать повторно, — все описано в комментариях.
Теперь перейдем к самому ответственному моменту — запуск сервера jetty. Значит, у нас задача: при старте контейнера сервлетов запустить jetty; перед остановкой контейнера сервлетов — остановить jetty. Самый простой способ это реализовать в GlassFish — написать свой ContextListener. Давайте посмотрим почему? Создаем новый класс и наследуем его от интерфейса javax.servlet.ServletContextListener. Смотрим листинг.
Интерфейс javax.servlet.ServletContextListener имеет два метода с говорящими самими за себя именами: contextInitialized(), contextDestroyed().
Теперь нам остается только подключить наш ContextListener в дескриптор развёртывания (web.xml). Приведу его листинг:
Ну теперь можно и запустить проект. В браузере открываем сразу два окна и балуемся. Прошу обратить внимание на время, сообщения доходят практически моментально. Такую скорость работы на ajax получить практически невозможно.
Весь проект в сборе можно скачать по ссылке
Проект полностью совместим с GlassFish 2.x, 3.x и Tomcat не ниже 5.0. Проект создавал в Netbeans 7.0.1. Внутри проекта можно найти ant-deploy.xml для развертки на Ant. Еще раз дико извиняюсь перед разработчиками, использующих Maven.
Во второй части статьи я подробно опишу проблемы, которые возникают при работе с WebSocket, и их решения.
В третьей части я опишу способы борьбы с ddos-атаками на серверы Jetty.
Спасибо за внимание. Всех еще раз с праздником!
Поздравляю всех и каждого с великим Днем Программиста! Желаю рабочего кода, уверенных сокетов и самых продвинутых пользователей!
Работая над автоматизацией концертного агентства, мне на каком-то этапе разработки понадобилась система уведомлений. Доступ к автоматизации происходит через написанное мною 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,
- Jetty WebSocket,
- node.js WebSocket.
Из них JWebSocket выделяется тем, что это большой фреймворк для работы с WebSocket, одновременно являющийся еще и надежным standalone-сервером. JWebSocket требует отдельного поста. А сейчас мы остановимся на самом простом варианте решения на платформе J2EE: 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 получить практически невозможно.
Заключение
Весь проект в сборе можно скачать по ссылке
Проект полностью совместим с GlassFish 2.x, 3.x и Tomcat не ниже 5.0. Проект создавал в Netbeans 7.0.1. Внутри проекта можно найти ant-deploy.xml для развертки на Ant. Еще раз дико извиняюсь перед разработчиками, использующих Maven.
Во второй части статьи я подробно опишу проблемы, которые возникают при работе с WebSocket, и их решения.
В третьей части я опишу способы борьбы с ddos-атаками на серверы Jetty.
Спасибо за внимание. Всех еще раз с праздником!