Чат на Spring Boot и WebSocket

В этой статье мы напишем простой чат с использованием Spring Boot и Websocket.

Это вольный перевод статьи Rajeev Kumar Singh https://www.callicoder.com/spring-boot-websocket-chat-example/, исходный код приложения можно скачать на GitHub.

Готовый чат можно попробовать тут https://spring-ws-chat.herokuapp.com/. Если там никого нет, то просто откройте приложение на двух вкладках браузера, войдите под разными именами и пишите.

Как выглядит чат

Что такое Websocket

Websocket – протокол, который позволяет устанавливать двустороннюю связь между клиентом и сервером.

Переключение на Websocket происходит после специального http-запроса от клиента. Чтобы переключиться на вебсокет,  клиент высылает в качестве одного из заголовков http-запроса:

Upgrade: websocket

Если сервер поддерживает вебсокеты, то он отвечает “да”, и дальше общение идет по протоколу Websocket. Он не имеет ничего общего с протоколом http, хотя они оба – протоколы самого верхнего уровня Application в системе протоколов OSI, но просто они разные.

Большинство браузеров поддерживают протокол Websocket.

Итак, перейдем к примеру.

Создание приложения

Сначала создадим шаблон проекта с помощью сайта-инициализатора:

  1. Зайдите на https://start.spring.io/.
  2. Выберите Spring Boot 2.
  3. Введите имя группы и артифакта для своего проекта.
  4. Включите зависимость Websocket.
  5. Сгенерируйте проект.

Далее разархивируем и импортируем проект в редактор как проект Maven:

Тут на картинке у нас уже созданы дополнительно три пакета – config, controller и model, они нам понадобятся.

Настройка Websocket

Итак, первым делом надо настроить конечную точку и брокер сообщений.  Все это сделано в пакете конфигурации config, его надо предварительно  создать.

Класс конфигурации:

package com.example.websocketdemo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
        registry.enableSimpleBroker("/topic");
    }
}

Аннотация @Configuration необходима, как известно, в любом классе конфигурации Spring.

Аннотация @EnableWebSocketMessageBroker включает Websocket сервер. Обратите внимание, мы реализуем интерфейс WebSocketMessageBrokerConfigurer и переопределяем два из его default-методов.

В первом методе registerStompEndpoints() мы регистрируем конечную точку, которую клиенты будут использовать, чтобы подключиться к нашему Websocket-серверу. SockJS – для браузеров, которые не поддерживают Websocket.

Обратите внимание на Stomp в названии метода.  STOMP – это Simple Text Oriented Messaging Protocol. Это протокол обмена сообщениями, задающий формат и правила обмена.

Зачем нужен Stomp? Дело в том, что сам по себе WebSocket не дает таких вещей (более высокого уровня), как отправка сообщений пользователям, подписанным на тему, или отправка сообщений конкретному пользователю.

Во втором методе configureMessageBroker() мы настраиваем брокер сообщений, который будет использоваться для направления сообщений от одного клиента к другому.

В первой строке мы говорим, что сообщения, чей адрес (куда отправлены) начинается с  “/app“, должны быть направлены в методы, занимающиеся обработкой сообщений.

Во второй строке мы говорим, что  сообщения, чей адрес начинается с  “/topic“, должны быть направлены в брокер сообщений. Брокер перенаправляет сообщения всем клиентам, подписанным на тему.

В примере у нас используется встроенный брокер, но можно использовать и полноценный брокер, такой как RabbitMQ или ActiveMQ.

Модель сообщения

Далее надо создать пакет model, а в нем класс ChatMessage:

public class ChatMessage {
    private MessageType type;
    private String content;
    private String sender;

    public enum MessageType {
        CHAT,
        JOIN,
        LEAVE
    }

    public MessageType getType() {
        return type;
    }

    public void setType(MessageType type) {
        this.type = type;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public String getSender() {
        return sender;
    }

    public void setSender(String sender) {
        this.sender = sender;
    }
}

Создание контроллера для отправки и получения сообщений

Теперь создадим пакет controller и класс ChatController. В классе ChatController есть методы, отвечающие за получение сообщения от одного клиента и трансляцию его всем остальным. Добавление пользователя и его сообщения транслируются всем, кто подключен к чату:

@Controller
public class ChatController {

    @MessageMapping("/chat.sendMessage")
    @SendTo("/topic/public")
    public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
        return chatMessage;
    }

    @MessageMapping("/chat.addUser")
    @SendTo("/topic/public")
    public ChatMessage addUser(@Payload ChatMessage chatMessage, 
                               SimpMessageHeaderAccessor headerAccessor) {
        // Add username in web socket session
        headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
        return chatMessage;
    }

}

Как вы помните, в конфигурации мы указали, что все сообщения от клиентов, направленные по адресу, начинающемуся с /app, будут перенаправлены в соответствующие методы. Имелись в виду как раз методы, аннотированные @MessageMapping.

Например, сообщение, направленное по адресу /app/chat.sendMessage будет перенаправлено в метод sendMessage(). А например, сообщение, направленное по адресу/app/chat.addUser будет перенаправлено в метод addUser().

Создание WebSocket Event listeners

Здесь мы слушаем события соединения с сервером и отсоединения. Это нужно для того, чтобы логировать эти события и передавать в чат на всеобщее обозрение. Так все видят, когда кто-то заходит в чат и выходит из него.

@Component
public class WebSocketEventListener {

    private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class);

    @Autowired
    private SimpMessageSendingOperations messagingTemplate;

    @EventListener
    public void handleWebSocketConnectListener(SessionConnectedEvent event) {
        logger.info("Received a new web socket connection");
    }

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

        String username = (String) headerAccessor.getSessionAttributes().get("username");
        if(username != null) {
            logger.info("User Disconnected : " + username);

            ChatMessage chatMessage = new ChatMessage();
            chatMessage.setType(ChatMessage.MessageType.LEAVE);
            chatMessage.setSender(username);

            messagingTemplate.convertAndSend("/topic/public", chatMessage);
        }
    }
}

Кстати, вход в чат мы уже обрабатываем в методе addUser() в ChatController, так что в SessionConnectedEvent ничего делать не нужно.

А в SessionDisconnectEvent мы извлекаем имя пользователя из сессии и транслируем его всем.

Создание Front-End

Теперь надо создать статику, и хотя это уже не Java, без нее приложение не заработает.

Для начала создадим папку static в src/main/resources. Впрочем, чтобы не расписывать все по кусочкам, вот вам структура папок файлов целиком:

static
  └── css
       └── main.css
  └── js
       └── main.js
  └── index.html     

Создание HTML – файл index.html

Обратите внимание, что в файл включены библиотеки sockjs и stomp.

Весь код:

<!DOCTYPE html>
<html>
  <head>
      <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
      <title>Spring Boot WebSocket Chat Application</title>
      <link rel="stylesheet" href="/css/main.css" />
  </head>
  <body>
    <noscript>
      <h2>Sorry! Your browser doesn't support Javascript</h2>
    </noscript>

    <div id="username-page">
        <div class="username-page-container">
            <h1 class="title">Type your username</h1>
            <form id="usernameForm" name="usernameForm">
                <div class="form-group">
                    <input type="text" id="name" placeholder="Username" autocomplete="off" class="form-control" />
                </div>
                <div class="form-group">
                    <button type="submit" class="accent username-submit">Start Chatting</button>
                </div>
            </form>
        </div>
    </div>

    <div id="chat-page" class="hidden">
        <div class="chat-container">
            <div class="chat-header">
                <h2>Spring WebSocket Chat Demo</h2>
            </div>
            <div class="connecting">
                Connecting...
            </div>
            <ul id="messageArea">

            </ul>
            <form id="messageForm" name="messageForm">
                <div class="form-group">
                    <div class="input-group clearfix">
                        <input type="text" id="message" placeholder="Type a message..." autocomplete="off" class="form-control"/>
                        <button type="submit" class="primary">Send</button>
                    </div>
                </div>
            </form>
        </div>
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.4/sockjs.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
    <script src="/js/main.js"></script>
  </body>
</html>

 JavaScript – main.js

Теперь давайте добавим Javascript-код. Он необходим для соединения с конечной точкой и отправки/получения сообщений.

Для начала его скопируйте в файл main.js, а ниже мы рассмотрим, что он делает.

'use strict';

var usernamePage = document.querySelector('#username-page');
var chatPage = document.querySelector('#chat-page');
var usernameForm = document.querySelector('#usernameForm');
var messageForm = document.querySelector('#messageForm');
var messageInput = document.querySelector('#message');
var messageArea = document.querySelector('#messageArea');
var connectingElement = document.querySelector('.connecting');

var stompClient = null;
var username = null;

var colors = [
    '#2196F3', '#32c787', '#00BCD4', '#ff5652',
    '#ffc107', '#ff85af', '#FF9800', '#39bbb0'
];

function connect(event) {
    username = document.querySelector('#name').value.trim();

    if(username) {
        usernamePage.classList.add('hidden');
        chatPage.classList.remove('hidden');

        var socket = new SockJS('/ws');
        stompClient = Stomp.over(socket);

        stompClient.connect({}, onConnected, onError);
    }
    event.preventDefault();
}


function onConnected() {
    // Subscribe to the Public Topic
    stompClient.subscribe('/topic/public', onMessageReceived);

    // Tell your username to the server
    stompClient.send("/app/chat.addUser",
        {},
        JSON.stringify({sender: username, type: 'JOIN'})
    )

    connectingElement.classList.add('hidden');
}


function onError(error) {
    connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!';
    connectingElement.style.color = 'red';
}


function sendMessage(event) {
    var messageContent = messageInput.value.trim();
    if(messageContent && stompClient) {
        var chatMessage = {
            sender: username,
            content: messageInput.value,
            type: 'CHAT'
        };
        stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage));
        messageInput.value = '';
    }
    event.preventDefault();
}


function onMessageReceived(payload) {
    var message = JSON.parse(payload.body);

    var messageElement = document.createElement('li');

    if(message.type === 'JOIN') {
        messageElement.classList.add('event-message');
        message.content = message.sender + ' joined!';
    } else if (message.type === 'LEAVE') {
        messageElement.classList.add('event-message');
        message.content = message.sender + ' left!';
    } else {
        messageElement.classList.add('chat-message');

        var avatarElement = document.createElement('i');
        var avatarText = document.createTextNode(message.sender[0]);
        avatarElement.appendChild(avatarText);
        avatarElement.style['background-color'] = getAvatarColor(message.sender);

        messageElement.appendChild(avatarElement);

        var usernameElement = document.createElement('span');
        var usernameText = document.createTextNode(message.sender);
        usernameElement.appendChild(usernameText);
        messageElement.appendChild(usernameElement);
    }

    var textElement = document.createElement('p');
    var messageText = document.createTextNode(message.content);
    textElement.appendChild(messageText);

    messageElement.appendChild(textElement);

    messageArea.appendChild(messageElement);
    messageArea.scrollTop = messageArea.scrollHeight;
}


function getAvatarColor(messageSender) {
    var hash = 0;
    for (var i = 0; i < messageSender.length; i++) {
        hash = 31 * hash + messageSender.charCodeAt(i);
    }
    var index = Math.abs(hash % colors.length);
    return colors[index];
}

usernameForm.addEventListener('submit', connect, true)
messageForm.addEventListener('submit', sendMessage, true)

Функция connect() использует SockJS  и клиент Stomp, чтобы подключиться к конечной точке /ws, которую мы настроили выше в Spring Boot.

После успешного подключения, клиент подписывается на адрес /topic/public и сообщает серверу имя пользователя по адресу /app/chat.addUser.

Функция stompClient.subscribe() принимает аргументом callback-функцию, которая вызывается каждый раз, когда в тему приходит сообщение.

Добавим стили CSS – main.css

Наконец, внешний вид приложения задается тут:

* {
    -webkit-box-sizing: border-box;
    -moz-box-sizing: border-box;
    box-sizing: border-box;
}

html,body {
    height: 100%;
    overflow: hidden;
}

body {
    margin: 0;
    padding: 0;
    font-weight: 400;
    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
    font-size: 1rem;
    line-height: 1.58;
    color: #333;
    background-color: #f4f4f4;
    height: 100%;
}

body:before {
    height: 50%;
    width: 100%;
    position: absolute;
    top: 0;
    left: 0;
    background: #128ff2;
    content: "";
    z-index: 0;
}

.clearfix:after {
    display: block;
    content: "";
    clear: both;
}

.hidden {
    display: none;
}

.form-control {
    width: 100%;
    min-height: 38px;
    font-size: 15px;
    border: 1px solid #c8c8c8;
}

.form-group {
    margin-bottom: 15px;
}

input {
    padding-left: 10px;
    outline: none;
}

h1, h2, h3, h4, h5, h6 {
    margin-top: 20px;
    margin-bottom: 20px;
}

h1 {
    font-size: 1.7em;
}

a {
    color: #128ff2;
}

button {
    box-shadow: none;
    border: 1px solid transparent;
    font-size: 14px;
    outline: none;
    line-height: 100%;
    white-space: nowrap;
    vertical-align: middle;
    padding: 0.6rem 1rem;
    border-radius: 2px;
    transition: all 0.2s ease-in-out;
    cursor: pointer;
    min-height: 38px;
}

button.default {
    background-color: #e8e8e8;
    color: #333;
    box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
}

button.primary {
    background-color: #128ff2;
    box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
    color: #fff;
}

button.accent {
    background-color: #ff4743;
    box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
    color: #fff;
}

#username-page {
    text-align: center;
}

.username-page-container {
    background: #fff;
    box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27);
    border-radius: 2px;
    width: 100%;
    max-width: 500px;
    display: inline-block;
    margin-top: 42px;
    vertical-align: middle;
    position: relative;
    padding: 35px 55px 35px;
    min-height: 250px;
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    margin: 0 auto;
    margin-top: -160px;
}

.username-page-container .username-submit {
    margin-top: 10px;
}


#chat-page {
    position: relative;
    height: 100%;
}

.chat-container {
    max-width: 700px;
    margin-left: auto;
    margin-right: auto;
    background-color: #fff;
    box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27);
    margin-top: 30px;
    height: calc(100% - 60px);
    max-height: 600px;
    position: relative;
}

#chat-page ul {
    list-style-type: none;
    background-color: #FFF;
    margin: 0;
    overflow: auto;
    overflow-y: scroll;
    padding: 0 20px 0px 20px;
    height: calc(100% - 150px);
}

#chat-page #messageForm {
    padding: 20px;
}

#chat-page ul li {
    line-height: 1.5rem;
    padding: 10px 20px;
    margin: 0;
    border-bottom: 1px solid #f4f4f4;
}

#chat-page ul li p {
    margin: 0;
}

#chat-page .event-message {
    width: 100%;
    text-align: center;
    clear: both;
}

#chat-page .event-message p {
    color: #777;
    font-size: 14px;
    word-wrap: break-word;
}

#chat-page .chat-message {
    padding-left: 68px;
    position: relative;
}

#chat-page .chat-message i {
    position: absolute;
    width: 42px;
    height: 42px;
    overflow: hidden;
    left: 10px;
    display: inline-block;
    vertical-align: middle;
    font-size: 18px;
    line-height: 42px;
    color: #fff;
    text-align: center;
    border-radius: 50%;
    font-style: normal;
    text-transform: uppercase;
}

#chat-page .chat-message span {
    color: #333;
    font-weight: 600;
}

#chat-page .chat-message p {
    color: #43464b;
}

#messageForm .input-group input {
    float: left;
    width: calc(100% - 85px);
}

#messageForm .input-group button {
    float: left;
    width: 80px;
    height: 38px;
    margin-left: 5px;
}

.chat-header {
    text-align: center;
    padding: 15px;
    border-bottom: 1px solid #ececec;
}

.chat-header h2 {
    margin: 0;
    font-weight: 500;
}

.connecting {
    padding-top: 5px;
    text-align: center;
    color: #777;
    position: absolute;
    top: 65px;
    width: 100%;
}


@media screen and (max-width: 730px) {

    .chat-container {
        margin-left: 10px;
        margin-right: 10px;
        margin-top: 10px;
    }
}

@media screen and (max-width: 480px) {
    .chat-container {
        height: calc(100% - 30px);
    }

    .username-page-container {
        width: auto;
        margin-left: 15px;
        margin-right: 15px;
        padding: 25px;
    }

    #chat-page ul {
        height: calc(100% - 120px);
    }

    #messageForm .input-group button {
        width: 65px;
    }

    #messageForm .input-group input {
        width: calc(100% - 70px);
    }

    .chat-header {
        padding: 10px;
    }

    .connecting {
        top: 60px;
    }

    .chat-header h2 {
        font-size: 1.1em;
    }
}

Запуск приложения

Все готово, теперь приложение можно запускать – запускаем сгенерированный Spring Boot-ом файл с функцией main в корне иерархии папок.

Запустится веб-сервер, и можно переходить по адресу http://localhost:8080 и наслаждаться работающим чатом.

Использование брокера RabbitMQ

Если вы не хотите пользоваться встроенным брокером, а хотите подключить полноценный, то в зависимости Maven надо добавить:

<!-- RabbitMQ Starter Dependency -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

<!-- Following additional dependency is required for Full Featured STOMP Broker Relay -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-reactor-netty</artifactId>
</dependency>

Далее надо включить RabbitMQ в классе конфигурации WebSocketConfig.java таким образом:

public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.setApplicationDestinationPrefixes("/app");

    // Use this for enabling a Full featured broker like RabbitMQ
    registry.enableStompBrokerRelay("/topic")
            .setRelayHost("localhost")
            .setRelayPort(61613)
            .setClientLogin("guest")
            .setClientPasscode("guest");
}

Итог

Мы написали с нуля чат.

Еще раз, весь код находится на GitHub.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *