Разработка высоконагруженного игрового WebSocket сервера на Java, Netty с поддержкой BattleRoyale/Matchmaking16.11.2023 13:15
Всем доброго времени суток. В предыдущей статье я затронул тему клиентской разработки браузерной игры. В этой же теме попробую пролить свет на тему разработки мультиплеера, а именно — разработки игрового websocket-tcp сервера на Netty.
О себе
Меня зовут Бальцер Артем, я Java разработчик с более чем 8-летним опытом работы в различных коммерческих проектах на данный момент. Не смотря на то, что формально всю жизнь мне нравился Backend, на самом деле, являюсь фулстеком, очень люблю интерактивные высоконагруженные приложения, люблю программировать игры, компьютерную графику, создавать физические модели и много чего еще, а страсть к делу — залог качественного воплощения идей и крепкого фундамента знаний как это сделать.
Java fullstack разработчик
В свободное время — преподаватель, ментор
Огромный опыт в создании веб-сервисов, в том числе высоконагруженных, коммерческих API
Есть большой собственный проект на стадии разработки — 2D io-мультиплеер (Java 19, Netty4, Phaser3, React, Redux, Typescript, Webpack, Websocket, PostgreSQL). Готовность к релизу на момент написания статьи — 60%.
Концепт
Итак, без лишней воды начнем с концепта демо-проекта. Статья обещает быть длинной портянкой, не смотря на сильное упрощение, поэтому запаситесь терпением.
Что мы имеем (на бэкенде, естественно)? Java; почти всю силу ООП; NIO-фреймворк Netty для работы с сетью, подключениями и сообщениями; Spring; Maven; многопоточность.
Что мы хотим? Допустим, для начала — хотим авторизовываться, подключаться к игровой комнате, заставить кружок двигаться влево-вправо, вверх-вниз на игровом поле, а затем, по истечению определенного времени, закрыть игровую комнату.
Как мы это сделаем? Опишу конфигурацию проекта и его зависимости, далее набросаю на коленке клиент на чистом JS, HTML, CSS. Затем поэтапно расскажу что происходит по ту сторону демки, разобрав основные узлы машины.
Частью режима Battle-royale здесь очевидно является Matchmaker, манипулирующий игровыми комнатами.
Стек
Java 18
Spring boot 3.1.5
Netty 4.1.101.Final
Это все что нужно
Сразу может возникнуть вопрос, почему я выбрал чистый Netty, а не их аналоги reactor-netty, webflux и т.д.? Потому что реактивное программирование для демки не подходит по многим причинам. Для подобного рода проектов всегда требуется более низкоуровневый подход с возможностью управления различными тонкими частями приложения с целью влияния на производительность. Чистый Netty позволяет напрямую контроллировать канал, его Pipeline и многие другие аспекты фреймворка.
Сборка
Исходник проекта здесь — https://github.com/tfkfan/netty-server-game-demo
В качестве сборщика проекта и менеджера зависимостей используется Maven
Все достаточно просто, все что нам нужно — netty-сервер и tomcat. Для удобства демо клиент доступен на этом же сервере в статических ресурсах — используется web стартер, который и будет служить этим контейнером. Разумеется вы всегда можете отделить web от этого проекта, закинув на отдельно поднятый инстанс Tomcat или любой другой, попутно убрав web-starter зависимость внутри демки.
Сборка и запуск
./mvnw clean verify spring-boot:run
Клиент
Клиентское приложение находится в статике /src/main/resources/static. Краткое описание:
index.html — страница клиентской части приложения
app.js — отрисовка объектов и обработка сообщений
network.js — функционал для работы с сетью (websocket)
types.js — справочник типов сообщений
styles.css — таблица стилей приложения
Не будем останавливаться на клиенте, он достаточно примитивен и прост. Вместо этого уделим большее внимание серверной части, про что и писалась статья.
Сервер
Не смотря на то, что по факту запуска приложения, очевидно, стартуют 2 разных сервера по разным портам — http (8080), tcp-websocket (8081), наибольший интерес конечно же вызывает второй, он же и составляет почти 100% написанного кода.
event — Функционал для работы с внутриигровыми событиями
game — Внутрикомнатная игровая логика, игровые модели
networking — Работа с сетью, основной подкапотный функционал
service — Сервисы, отвечающие за конкретную функцию в приложении, например, авторизация
Рассмотрим работу с сетью и пакет networking
Networking. Работа с сетью и конфигурация сервера
Работа сервера начинается разумеется с Application.java, который в свою очередь поднимает Websocket-netty-сервер:
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketServer {
private final ApplicationProperties applicationProperties;
private final DefaultWebsocketInitializer customWebSocketServerInitializer;
public void start() throws InterruptedException {
log.info("WebSocketServer is starting...");
log.info("Reserved {} threads", applicationProperties.getServer().getWorkerThreads()
+ applicationProperties.getServer().getEventLoopThreads()
+ applicationProperties.getServer().getGameThreads());
EventLoopGroup boss = new NioEventLoopGroup(applicationProperties.getServer().getEventLoopThreads());
EventLoopGroup worker = new NioEventLoopGroup(applicationProperties.getServer().getWorkerThreads());
ServerBootstrap boot = new ServerBootstrap();
boot.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(customWebSocketServerInitializer);
ChannelFuture future = boot.bind(applicationProperties.getServer().getPort()).sync();
future.addListener(evt -> log.info("Started ws server, active port:{}", applicationProperties.getServer().getPort()));
future.channel().closeFuture().addListener((evt) -> {
log.info("WebSocketSocket is closing...");
boss.shutdownGracefully();
worker.shutdownGracefully();
}).sync();
}
}
По классике tcp-netty-сервера создается master-slave группа EventLoop, отвечающая за обработку входящих сообщений по сети, и, далее, объединяется в ServerBootstrap как полноценный TCP-сервер по выделенному порту. Стоит обратить, что строка 10 выводит кол-во зарезервированных потоков как для самого сервера, так и для внутреннего ExecutorService, отвечающего за обработку игровых комнат, но об этом позднее.
Пара слов о Netty и как этот фреймворк работает.
Netty — фреймворк для работы с сетью, в основе которого лежат неблокирующие операции. Тоесть, грубо говоря, когда приходит клиентский запрос, поток, в последствии обрабатывающий его, не ждет чтения из сокета, в отличие от блокирующих инструментов ввода-вывода, а может быть занят чем либо еще и вызовется, когда сообщение будет прочитано и готово к обработке. В этой связи, обработка поступающих данных напоминает асинхронную callback-архитектуру, что на самом деле так и есть.
Упрощенная схема архитектуры netty
Соединение по сети, а если быть точнее неблокирующий поток ввода/вывода сообщений для соединения, называется каналом Channel. В канал можно как писать, так и читать из него сообщения, а также добавлять аттрибуты, которые держатся только на сервере, что очень похоже на сервлет-сессию пользователя.
Попадая на сервер, в рамках своего канала сообщение идет по трубе, так называемому ChannelPipeline, представляющему собой коллекцию обработчиков сообщений ChannelHandler, отсортированную строго в порядке добавления элемента в него (разработчик разумеется сам конфигурирует и добавляет элементы Pipeline). Одними из обработчиков являются парсеры, кодирующие/декодирующие входящие сообщения. Между ними вызываются обработчики бизнес логики (далее — ОБЛ). Взависимости от типа (класса) сообщения оно попадет в тот или иной ОБЛ. Будьте внимательны при парсинге.
ChannelPipeline
Каждый из обработчиков в свой момент времени всегда выполняется одним из worker-thread, что закрывает потребность в синхронизации, при условии грамотно реализованной бизнес логики и отсутствия разделяемого состояния между обработчиками. Таким образом, netty очень хорошо экономит ресурсы и позволяет зачастую читать и обрабатывать миллионы сообщений в секунду.
Протоколы ChannelPipeline
При старте Bootstrap также имеет место стартовый инициализатор канала, который в том числе инициализирует и ChannelPipeline:
@Component
public class DefaultWebsocketInitializer extends ChannelInitializer {
private final InitialGameHandler webSocketHandlerMain;
private final PingPongWebsocketHandler pingPongWebsocketHandler;
private final TextWebsocketDecoder textWebsocketDecoder;
public DefaultWebsocketInitializer(InitialGameHandler webSocketHandlerMain, PingPongWebsocketHandler pingPongWebsocketHandler, TextWebsocketDecoder textWebsocketDecoder) {
this.webSocketHandlerMain = webSocketHandlerMain;
this.pingPongWebsocketHandler = pingPongWebsocketHandler;
this.textWebsocketDecoder = textWebsocketDecoder;
}
@Override
protected void initChannel(Channel channel) {
ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast("decoder", new HttpRequestDecoder());
pipeline.addLast("aggregator", new HttpObjectAggregator(ServerConstants.DEFAULT_OBJECT_AGGREGATOR_CONTENT_LENGTH));
pipeline.addLast("handler", new WebSocketServerProtocolHandler(ServerConstants.WEBSOCKET_PATH));
pipeline.addLast(ServerConstants.PING_PONG_HANDLER_NAME, pingPongWebsocketHandler);
pipeline.addLast(ServerConstants.TXT_WS_DECODER, textWebsocketDecoder);
pipeline.addLast(ServerConstants.INIT_HANDLER_NAME, webSocketHandlerMain);
pipeline.addLast("encoder", new HttpResponseEncoder());
}
}
Что почти тоже самое по назначению, что и классы, имплиментирующие интерфейс pipeline-протокола GameChannelMode. Стартовый обработчик InitialGameHandler отвечает исключительно за авторизацию пользователя.
Протоколы необходимы для изменения структуры пайплайна отдельной сессии/канала в тот или иной момент времени.
MainGameChannelMode — главный игровой протокол, переводит сессию игрока на связанные с игровыми комнатами realtime обработчики
OutOfRoomChannelMode — протокол, переводящий на внеигровую логику (вне игровых комнат)
Сессия
Что такое сессия игрока PlayerSession? Это просто контейнер клиентских данных внутри аттрибутов канала, привязанный к отдельному клиенту/соединению и хранящий также объект Player и ключ комнаты, в которой этот игрок в данный момент находится.
Сообщения
Для парсинга TextWebsocketFrame используются кодер и декодер:
@Sharable
@Component
public class TextWebsocketDecoder extends MessageToMessageDecoder {
private final Gson gson = new Gson();
@Override
protected void decode(ChannelHandlerContext ctx, TextWebSocketFrame frame, List