Сервер для Чата на 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>
Настройка приложения
Это самое простое 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