1. Введение (Introduction)
1.1 Фон проекта
Существующая старая структура мессенджер-сервисов испытывает трудности с адаптацией к изменяющейся веб-среде, и в отношении обслуживания существует множество неудовлетворительных моментов. Проект 'Loopin' был запущен с целью демонтажа этой системы Smalltalk и реконструкции (Refactoring) на современной платформе Vizend.
Ключевая ценность мессенджер-сервиса заключается в 'независимом и беспрерывном опыте общения в реальном времени' между пользователями. Для достижения этой цели стояли две основные технические задачи. Во-первых, нужно было создать мессенджинговый канал, позволяющий клиенту и серверу поддерживать постоянное соединение и отправлять/получать сообщения без задержек, отказавшись от однонаправленной модели запроса-ответа существующего протокола HTTP. Во-вторых, для максимизации эффективности сотрудничества и общения необходимо было реализовать систему управления состоянием присутствия (Presence Engine), которая в реальном времени определяет текущее состояние подключения пользователя и отображает его на экране.
1.2 Цели проекта
Наиболее важным аспектом при выборе технологий является опасность 'избыточной инженерии (Overengineering)'. Несмотря на то, что архитектура не должна выдерживать глобальный трафик большого объема, неуместное добавление множества сложных распределенных решений, таких как Redis и Kafka, под влиянием моды приводит к дополнительной операционной нагрузке, сложности развертывания и ненужному увеличению облачных затрат. Проект Loopin избрал 'простоту и эффективность (KISS - Keep It Simple, Stupid)' в качестве ключевой ценности. Мы определили, что основной задачей является исключение внешних инфраструктурных элементов и максимально полное использование реляционной базы данных (RDB), которая является основным хранилищем сервиса, для полного контроля над реальным состоянием присутствия и жизненным циклом сообщений.
2. Обзор архитектуры системы (System Architecture)
Поток данных мессенджера Loopin основан на четком разделении ответственности и исключении ненужных слоев. Коммуникационный уровень, обеспечивающий реальное время, полностью синхронизирован с уровнем постоянства, гарантирующим целостность данных.
[Рисунок 1] Архитектура системы Loopin и поток данных на основе вебсокетов
-
React Frontend: после установления соединения с сервером с использованием протокола STOMP браузер остается в фоновом режиме, ожидая дальнейших действий. При вводе пользователем информации отправляется асинхронное сообщение, и получение событий приводит к повторной отрисовке компонента для мгновенной обратной связи с пользователем.
-
Spring Boot Backend: отвечает за прием вебсокетных конечных точек, проверку безопасности на этапе подключения с помощью JWT токена (Interceptor), управление маршрутом с использованием адресной схемы STOMP и обработку внутренних событий жизненного цикла (Event Listener).
-
Реляционная база данных: сохраняет не только постоянные сообщения чата, но и встраивает внутреннюю схему управления доступом в реальном времени, обеспечивая надежное сохранение состояния сессии и истории сессий.
3. Реальное время сообщений с использованием вебсокетов (WebSocket) и STOMP
3.1 Предпосылки внедрения субпротокола STOMP
Стандарт HTML5 под названием Raw WebSocket открывает только двухсторонний туннель связи, аналогичный TCP-сокету, не определяя конкретный формат данных или адрес назначения. Это означает, что разработчику необходимо определить свои собственные правила разбора текста и создать пользовательские обработчики, чтобы различать, являются ли строки, передаваемые между клиентом и сервером, чат-данными, заявлением о входе в комнату или сообщением об ошибке. Это может привести к возникновению дефектов и усложнению архитектуры.
Loopin внедрил подпротокол STOMP (Simple Text Oriented Messaging Protocol), работающий на базе вебсокетов, чтобы сократить такие потери. STOMP имеет структурированную рамочную структуру в виде команд (COMMAND), заголовков (Headers) и тела (Body), что позволяет революционно интуитивно организовать маршрутизацию в сообщенческой системе.
3.2 Исходный код конфигурации инфраструктуры вебсокетов Spring Boot
Реализованный код для активации встроенного месседж-брокера на основе STOMP и сопоставления конечных точек в Spring Boot выглядит следующим образом.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 프론트엔드가 핸드셰이크를 요청할 종단점 주소 매핑
// SockJS 폴백을 적용하여 웹소켓이 차단된 프록시나 구형 브라우저 환경 지원
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS()
.setSessionCookieNeeded(false);
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/user");
registry.setUserDestinationPrefix("/user");
registry.setApplicationDestinationPrefixes("/app");
}
}
4. Проектирование функции Presence (состояние подключения) на основе базы данных (БД)
4.1 Обоснование исключения дополнительной инфраструктуры (Redis) и архитектурные намерения выбора RDB
Многие руководства для разработчиков рекомендуют хранить временные данные сеанса в таких базах данных, как Redis, которые являются in-memory NoSQL Key-Value, из-за их высокой эфемерности. Однако, когда мы тщательно изучили масштаб проекта Loopin и его доменные особенности, это оказалось явной тратой ресурсов. Конкретные причины проектирования структуры с использованием только реляционных баз данных (RDB) следующие:
Во-первых, удобство обслуживания простой архитектуры развертывания. Использование существующей структуры базы данных с единственным экземпляром или первично-реплицирующей архитектурой позволяет централизовать системы мониторинга, политики резервного копирования и т.д. Во-вторых, высокая целостность данных и возможность отслеживания статистики. История, когда пользователь вошел в систему и вышел из нее, имеет значение, превосходя простые значения состояния. При сбоях системы или аудита безопасности критически важна история журналов сессий, и RDB может принимать сложные запросы на анализ временных журналов, используя реляционные схемы и индексы. В-третьих, удобство объединения с данными домена. При реализации требований, таких как 'отсортировать только членов, которые сейчас присутствуют, в верхней части моего списка друзей', если данные разрознены между Redis и RDB, это может приводить к двум операциям ввода-вывода на уровне приложения и необходимости ручного объединения памяти, тогда как в среде RDB это можно обработать с помощью простого JOIN или подзапроса IN в миллисекунды без сложной структуры.
4.2 Моделирование схемы базы данных
-- Presence 테이블
CREATE TABLE IF NOT EXISTS active_participant
(
id VARCHAR(255) NOT NULL,
actor_id VARCHAR(255),
stage_id VARCHAR(255),
pavilion_id VARCHAR(255),
entity_version BIGINT NOT NULL,
registered_by VARCHAR(255),
registered_on BIGINT NOT NULL,
modified_by VARCHAR(255),
modified_on BIGINT NOT NULL,
session_id VARCHAR(255),
login_time BIGINT NOT NULL,
CONSTRAINT pk_active_participant PRIMARY KEY (id)
);
4.3 Реализация обработчика событий Presence
Это метод верификации активных счетчиков таблицы Presence Loopin в реальном времени.
@Component
@RequiredArgsConstructor
public class PresenceEventListener {
//
private static final String SIMP_CONNECT_MESSAGE_HEADER = "simpConnectMessage";
private static final String NATIVE_HEADERS = "nativeHeaders";
private static final String SIMP_USER_HEADER = "simpUser";
private final PresenceTask presenceTask;
@EventListener
public void handleSessionConnect(SessionConnectEvent event) {
SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.wrap(event.getMessage());
log.info("### STOMP CONNECT Attempt ### SessionId: {}", headers.getSessionId());
}
@EventListener
public void handleSessionConnected(SessionConnectedEvent event) {
//
SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.wrap(event.getMessage());
log.info("WebSocket Session Connected! SessionId: {}", headers.getSessionId());
Map<String, ArrayList<String>> nativeHeaders = this.extractNativeHeaders(headers);
if (nativeHeaders == null || nativeHeaders.isEmpty()) {
throw new IllegalArgumentException("Native headers cannot be found in Socket header.");
}
List<String> actorId = nativeHeaders.get(SIMP_USER_HEADER);
log.info("WebSocket Session Connected! SessionId: {}, ActorId: {}, Headers: {}", headers.getSessionId(), actorId, nativeHeaders);
LoginEvent loginEvent = new LoginEvent(actorId.getFirst());
presenceTask.addParticipant(headers.getSessionId(), loginEvent);
}
@EventListener
public void handleSessionDisconnect(SessionDisconnectEvent event) {
log.info("WebSocket Session Disconnect! sessionId : {}", event.getSessionId());
presenceTask.removeParticipant(event.getSessionId());
}
@SuppressWarnings("unchecked")
private Map<String, ArrayList<String>> extractNativeHeaders(SimpMessageHeaderAccessor headers) {
//
GenericMessage<?> gm = (GenericMessage<?>) headers.getMessageHeaders().get(SIMP_CONNECT_MESSAGE_HEADER);
return (Map<String, ArrayList<String>>) gm.getHeaders().get(NATIVE_HEADERS);
}
}
@Service
@Transactional
@RequiredArgsConstructor
public class PresenceTask {
//
private final ActiveParticipantLogic activeParticipantLogic;
public void addParticipant(String sessionId, LoginEvent event) {
// 1인 1세션 정책: 동일 사용자의 기존 세션 정보를 모두 정리한 후 새 세션 등록
activeParticipantLogic.removeByActorId(event.getActorId());
ActiveParticipantCdo cdo = new ActiveParticipantCdo();
cdo.setActorId(event.getActorId());
cdo.setSessionId(sessionId);
cdo.setLoginTime(System.currentTimeMillis());
activeParticipantLogic.registerActiveParticipant(cdo);
}
public LoginEvent getParticipant(String sessionId) {
ActiveParticipant activeParticipant = activeParticipantLogic.findBySessionId(sessionId);
if (activeParticipant == null) {
return null;
}
return new LoginEvent(activeParticipant.getActorId());
}
public void removeParticipant(String sessionId) {
ActiveParticipant activeParticipant = activeParticipantLogic.findBySessionId(sessionId);
if (activeParticipant != null) {
activeParticipantLogic.removeActiveParticipant(activeParticipant.getId());
}
}
public Map<String, LoginEvent> getActiveSessions() {
return activeParticipantLogic.findActiveParticipants(null).stream()
.collect(Collectors.toMap(
ActiveParticipant::getSessionId,
ap -> new LoginEvent(ap.getActorId())
));
}
public boolean isCitizenOnline(String citizenId) {
return activeParticipantLogic.existsByActorId(citizenId);
}
}
5. Заключение и ретроспектива (Conclusion & Retrospective)
Работа по восстановлению месседж-сервиса Loopin на современном Vizend платформе из существующей системы Smalltalk Messenger была успешно завершена, обеспечив надежный и высоко видимый Presence-движок только с помощью стратегии комплексного индексирования и хорошо продуманной схемы таблицы журнала сессий для взаимной проверки, без громоздкого и дорогостоящего внешнего распределенного кэш-уровня.
Этот проект стал отличной возможностью понаблюдать за основным потоком реализации реального времени сообщений с использованием WebSocket и STOMP, понять, как контролируются сессии за пределами рамки и как обрабатываются исключения разрыва сессий WebSocket, шаг за шагом осваивая логику реальной связи. Вместо того чтобы гнаться за яркими новейшими технологиями, решая проблемы с помощью только базы данных с учетом текущих требований и затрат, я смог снова узнать о преимуществах обслуживания проектов, которые предоставляет простая архитектура.
BigJumbo