Сервер для Чата на Spring Websocket - Часть 1 Базовый сервер websocket

Сервер для Чата на Spring Websocket - Часть 1 Базовый сервер websocket
От Доходяги до Богатыря(сначала серия переводов)  и дальше к звёздам - "Бесконечность не предел!"

"Через терни к звездам или как написать чат веб приложение" на Java и походу познакомиться с разными технологиями из Spring экосистемы.

Source code: https://github.com/isatimur/serna-chat/tree/feature/websocket-intro

Часть 1 - оригинал на английском https://daddyprogrammer.org/post/4077/spring-websocket-chatting/

Итак в первой части нашего гайда мы собираемся написать простой чат сервер и использовать Spring и Websocket протокол.

В отличии от серверов, которые взаимодействуют с обычным http, чат серверу нужно взаимодействовать с сокетами. Обычно HTTP-связь представляет собой одностороннюю связь, при которой сервер отвечает и разрывает соединение только при наличии запроса от клиента. Поэтому часто используют сервисы со структурой,  в которой клиент подключается к серверу, запрашивaет контент и, получает  и потребляет результат. С другой стороны, связь через сокеты - это метод, при котором сервер и клиент постоянно поддерживают соединение и взаимодействуют в обоих направлениях, Он в основном используется в службах, требующих реального времени(realtime), таких как чат.

Веб-сокет

Websocket - это протокол, разработанный для обеспечения двухсторонней связи за счет совместимости с существующими односторонним протоколом HTTP. В отличие от обычной связи через сокеты, используется HTTP и порт 80, поэтому для брандмауэра нет ограничений, и он обычно называется Websocket. протокол HTTP используется до установления соединения, а связь после этого осуществляется с помощью собственного протокола Websocket

Создание сервера SpringBoot Websocket

Создайте сервер Websocket следующим образом. Его легко построить, так как он ничем не отличается от общей конфигурации загрузки.

Если у вас возникли трудности с первоначальной настройкой нового проекта, то вы можете легко создать проект через Spring Initializr. Вариантов создания Springboot приложения очень много, в зависимости от вашего предпочтения: например можете создать онлайн через start.spring.io сайт, указать Group-у, Artifact своего проекта и добавить зависимости из списка предполагаемого Spring-ом

Для нашего примера я выбрал Maven Project

Добавить в зависимости Spring Web, Spring Websocket и Lombok

Web -  для работы с Rest API, Websocket - для работы с веб-слкетами, Lombok -  для сокращения boilerplate(излишнего) кода в наших POJO объектах(например Message класс)  

Или просто открыть ссылку, которая предложить загрузить вам ZIP архив и открыть в IntelliJ Idea или любой другой среде удобной вам, в своих snippet-ах (код вставках) буду пользоваться исключительно JetBrains-овской средой разработки.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.6.6</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.timurisachenko</groupId>
	<artifactId>chat</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>chat</name>
	<description>Chat project for Spring Boot</description>
	<properties>
		<java.version>18</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>

		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>
		</plugins>
	</build>

</project>
pom.xml

Настройка приложения

Это самое простое Springboot приложение, с точкой инициализации Spring-а и поиска зависимых  бинов, которые Spring Application Context хочет проинициализировать.

@SpringBootApplication
public class ChatApplication {

	public static void main(String[] args) {
		SpringApplication.run(ChatApplication.class, args);
	}

}

Создание websocket обработчика

Связь через сокет устанавливает отношение 1:N между сервером и клиентом. Следовательно, к одному серверу могут подключаться несколько клиентов, и серверу необходимо написать обработчик для приёма и обработки сообщений, отправленных несколькими клиентами. Унаследуйте TextWebSocketHandler и напишите Handler следующим образом. Он выводит сообщение, полученное от клиента, в журнал консоли и отправляет приветственное сообщение клиенту.

package com.timurisachenko.chat;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

@Slf4j
@Component
public class WebSocketHandler extends TextWebSocketHandler {
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        var payload = message.getPayload();
        log.info("payload: {}", payload);
        TextMessage textMessage = new TextMessage("Welcome to the chat!");
        session.sendMessage(textMessage);
    }
}

Создать конфигурацию websocket-а

Создадим файл конфиграции, чтобы включить Websocket, используя написанный нами ранее обработчик. Объявите класс аннотацией @EnableWebSocket, так мы включим веб-сокеты. Конечная точка доступа к Websocket устанавливается /ws/chat, а CORS: setAllowedOrigins("*") добавлен для разрешения доступа с сервером с разных доменов. Теперь базовая настройка завершена для подключения клиента к ws://localhost:8080/ws/chat и отправки сообщений.

package com.timurisachenko.chat;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Slf4j
@RequiredArgsConstructor
@Configuration
@EnableWebSocket
public class WebConfig implements WebSocketConfigurer {
    private final WebSocketHandler handler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(handler, "/ws/chat").setAllowedOrigins("*");
    }
}

Тест веб-сокета

Поскольку веб-экран клиента для тестирования Websocket-а пока нет, можно найти Simple Websocket Client в интернет-магазине Chrome или в IntelliJ Idea и установить этот клиент.

https://chrome.google.com/webstore/detail/simple-websocket-client/pfdhoblngboilpfeibdedpjgfnlcodoo  

Запустите загрузочный сервер.

Затем запустите Simple Websocket Client, введите ws://localhost:8080/ws/chat в поле URL и нажмите Open. Websocket - это отдельный протокол, поэтому его адресная схема начинается с ws, а не с http. Если соединение установлено успешно, статус меняется на OPENED.

Если вы введете сообщение в поле Request и нажмете Send при подключении, в журнале сообщений оранжевым цветом будет напечатано ваше сообщение, и приветствие полученное в ответ от сервера черным цветом. При этом связь клиент-сервера через Websocket оказалось установить довольно просто.

Улучшения чата

Связь через веб-сокет, созданная выше, позволяет обмениваться сообщениями только между клиентами, подключенными к ws://localhost:8080/ws/chat. Проще говоря, это чат-сервер с одной комнатой для чата. Для создания нескольких чатов и обмена сообщениями между клиентами, вошедшими в чаты, требуется улучшения. Итак, давайте реализуем чат со следующей концепцией.

Когда клиенты подключаются к серверу, у них создаются отдельные Websocket - сессии. Поэтому при входе в чат-комнату, если информация Websocket-сессия клиента сопоставляется с чат-комнатой и сохраняется, то сообщение, доставленное на сервер, может быть отправлено в Websocket - сессию определенной комнаты, таким образом можно реализовать отдельные чат-комнаты.

Реализуем чат сообщения

Создадим DTO для отправки и получения сообщений чата. В зависимости от ситуации, есть два случая: вход в чат-комнату и отправка сообщения в чат-комнату, так что ENTER(вход в чат-комнату) TALK(общаться) определены как enum типы. Остальные поля состоящие в чате, уникальный id комнаты, отправитель сообщения from , и само сообщение просто текстовая строка message

@Getter
@Setter
class ChatMessage {
    enum MessageType {
        ENTER, TALK
    }

    private MessageType type;
    private String roomId;
    private String from;
    private String message;
}

Чат-комната реализация

Создаем DTO, чтобы реализовать чат-комнату. Раз чат-комната должна иметь информацию о клиентах, кто вошел в неё, тогда мы должны хранить список Websocket-сессий как поле класса. Еще добавим roomId идентификатор комнаты и name имя комнаты соответсвенно. В чат-комнате будут функции вхождения(entering) и переписки(talking), так что обработка будет осуществляться через handleAction метод. Когда входят в комнату, информация о сессии чат-комнаты добавляется в список клиентских сессий, и когда приходит сообщение в чат-комнату, переписка завершается отправкой сообщения всем сессиям в чат-комнате.

@Getter
class ChatRoom {
    private String roomId;
    private String name;
    private Set<WebSocketSession> sessions = new HashSet<>();

    @Builder
    public ChatRoom(String roomId, String name) {
        this.roomId = roomId;
        this.name = name;
    }

    public void handleActions(WebSocketSession session, ChatMessage message, ChatService chatService) {
        if (message.getType().equals(ChatMessage.MessageType.ENTER)) {
            sessions.add(session);
            message.setMessage(message.getFrom() + " You have entered");
        }
          sendMessage(message, chatService);
    }

    private <T> void sendMessage(T message, ChatService chatService) {
        sessions.parallelStream().forEach(session -> chatService.sendMessage(session, message));
    }
}

Чат-сервис реализация

Сервис создает чат-комнаты и вызывает отправку сообщений одной сессии, реализация. Сохраняем в Map все чат-комнаты, которые когда либо были созданы на сервере. Хранение информации о чат-комнатах организованно в HashMap -e без внешнего хранилища типа Базы Данных(БД) для простоты примера.

Функции сервис класса:  

  • findAllRoom, findRoomById(поиск чат-комнат) - поиск информации хранящийся в хранилище(map-e) чат-комнат.
  • createRoom - создается чат комната как объект с уникальным ID как случайный UUID и добавляется в карту(map) чат-комнат
  • sendMessage - отправляет сообщение в указанную сессию
@Slf4j
@RequiredArgsConstructor
@Service
class ChatService {

    private final ObjectMapper objectMapper;
    private Map<String, ChatRoom> chatRooms;

    @PostConstruct
    private void init() {
        chatRooms = new LinkedHashMap<>();
    }

    public List<ChatRoom> findAllRooms() {
        return new ArrayList<>(chatRooms.values());
    }

    public ChatRoom findRoomById(String roomId) {
        return chatRooms.get(roomId);
    }

    public ChatRoom createRoom(String name) {
        String randomId = UUID.randomUUID().toString();
        ChatRoom room = ChatRoom.builder()
                .roomId(randomId)
                .name(name)
                .build();
        chatRooms.put(randomId, room);
        return room;
    }

    public <T> void sendMessage(WebSocketSession session, T message) {
        try {
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
        } catch (IOException ex) {
            log.error(ex.getMessage(), ex);
        }
    }
}

Создадим чат-контроллер

Так как создание и запросы чат комнат будут реализованы через Rest API, создадим контроллер(Controller), как показано ниже:

@RequiredArgsConstructor
@RestController
@RequestMapping("/chat")
class ChatController {
    private final ChatService chatService;
    
    @PostMapping
    public ChatRoom createRoom(@RequestParam String name) {
        return chatService.createRoom(name);
    }
    
    @GetMapping
    public List<ChatRoom> findAllRooms() {
        return chatService.findAllRooms();
    }
}

Изменим WebSocket обработчик

Добавим логику к нашему чату через обработчик(handler)

  • Получение сообщения от веб-сокет клиента и конвертирование его в объект чат-сообщение
  • Запрос информации о чат-комнате, чтобы при отправки сообщения ID чат-комнаты содержалось в полученном сообщении
  • Отправка сообщений с соответсвующим типом ко всем клиентам(т е вебсокет сессиям) в чат-комнате
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketHandler extends TextWebSocketHandler {
    private final ObjectMapper objectMapper;
    private final ChatService chatService;

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        var payload = message.getPayload();
        log.info("payload: {}", payload);
        ChatMessage chatMessage = objectMapper.readValue(payload,ChatMessage.class);
        ChatRoom room = chatService.findRoomById(chatMessage.getRoomId());
        room.handleActions(session, chatMessage, chatService);
//DELETED        TextMessage textMessage = new TextMessage("Welcome to the chat!");
//DELETED        session.sendMessage(textMessage);
    }
}

Тестируем

Сохраните все изменения и сделайте рестарт сервера.

Создадим чат-комнату

Создаем чат-комнату через Postman:
Вам нужно войти в чат-комнату с созданным ID чат-комнаты и отправить сообщение, так что скопируем roomID как результат запроса.

Вхождение в чат-комнату

Запускаем Simple websocket в Chrome и соединяемся с ws://localhost:8080/ws/chat. И как следствие, собираем Json для вхождения в чат-комнату и отправки по вебсокету.  ID чат-комнаты это roomId созданный ранее. В этот раз, задача сохранить Websocket-сессию и списке сессий в чат-комнате с соответсвующим roomId.

{
  "type":"ENTER",
  "roomId":"d64b52c0-043b-4275-a0ea-d8d740891ca9",
  "from":"owner",
  "message":""
}

Если вы нажмете Send после введения сообщения Json, сообщение доставлено на веб-сокет сервер и показано ниже, и сервер отвечает приветственным сообщением, как и раньше черным цветом ответ с сервера, а оранжевым сообщение с клиента.

Отправка чат-сообщений

Чат-сообщения отправляются с типом TALK . Разница с предыдущим отправленным сообщением, что там был тип ENTER , который сохранял веб-сокет сессию в чат-комнату при вхождении в чат-комнату. TALK запрос отправляет сообщения всем тем кто уже вошел и перешлет сообщение всем кто в списке сессий в чат-комнате.

{
  "type":"TALK",
  "roomId":"d64b52c0-043b-4275-a0ea-d8d740891ca9",
  "from":"owner",
  "message":"Hello websocket world!"
}

Если мы нажмем на кнопку Send наше сообщение Json 👆, мы можем получить сообщение отправленное к и полученное от веб-сокет сервера ниже 👇

Также мы можем открыть еще одну закладку с расширением Simple веб-сокет клиентом и общаться между собой:

Итак, что у нас получилось: мы создали несколько чат комнат на одном сервере и попрактиковались в обмене сообщений. Метод немного хлопотный и не простой сходу для понимания. В следующей части, мы используем Stomp для дальнейшего улучшения чат комнат.  
Source code:
https://github.com/isatimur/serna-chat/tree/feature/websocket-intro