Как я создавал онлайн игру «нарды» (часть вторая). Сервер

Всем привет!

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

3ffdf8ea38efb49c510f930df7177038.png

Последние несколько лет я использую исключительно облачную инфраструктуру для запуска проектов, это не только позволяет существенно экономить финансовые ресурсы на этапе разработки, но и дает возможность в дальнейшем легко масштабировать мощности по мере роста нагрузки на сервера в процессе эксплуатации. По этой причине я решил, что можно пропустить описание базовых настроек сервера, по сути в моем случае они сводятся к закрытию всех портов за исключением HTTP/HTTPS (80/443) и SSH (22), доступ к которому в свою очередь разрешен только с определенных IP адресов, так как ходим в облака мы исключительно используя собственный VPN.

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

Итак, с железно/инфраструктурной частью закончили, двигаемся в сторону архитектуры. Обычно проектирование архитектуры у меня начинается со схемы, которая позволяет разложить всю систему на отдельные составляющие и понять, как самым оптимальным образом соединить все узлы и сразу же заложить возможность масштабирования в случае успеха проекта.

Ниже представлена схема, показывающая основные части архитектуры.

схема по которой строилась серверная часть проекта

схема по которой строилась серверная часть проекта

Для этого проекта я решил использовать проверенную временем конфигурацию. На входе у нас будет прокси-сервер NGINX, который позволит мне использовать HTTP2 c TLS и он же будет проксировать все запросы на Node.js приложение.

Красным цветом на схеме отмечены пути трафика который закрыт TLS и позволяет безопасно обмениваться данными через интернет с клиентом, объектным хранилищем и базами данных. Зеленым цветом отмечен внутренний трафик который не использует шифрования так как не выходит за периметры облачного инстанса или контейнера.

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

В завершении этой темы я хочу отметить, что данный подход отлично масштабируется в рамках одного сервера за счет увеличения используемых CPU/vCPU и запуска дополнительных процессов Node.js (он работает в однопоточном режиме) через PM2, а также можно собирать целые кластеры таких серверов вынося NGINX на отдельную машину или используя балансировщики нагрузки, которые предоставляют практически все облачные провайдеры. Таким образом можно наращивать огромную производительность системы. Конечно же по мере роста, будут возникать дополнительные сложности, которые сопровождают обслуживание любой системы с высокой нагрузкой, но в рамках этого проекта я так далеко не стал заглядывать.

Со схемой закончили, почти все блоки описаны, осталось собственно само Node.js приложение, оно же пресловутый «сервер».

Сервер я строил на базе замечательной библиотеки uWebSockets.js, которая «из коробки» поддерживает HTTP и WebSocket транспорт, что позволит мне использовать в разных частях приложения оптимальные варианты общения между клиентом и сервером.

Частый вопрос в личных сообщениях был о том почему я не использую только WebSocket, тем более, что у меня уже были проекты, которые построены исключительно только на этом транспорте и я обещал ответить на этот вопрос в статье. Так вот основное предназначение HTTP в этом проекте это безопасная передача refreshToken посредством HTTP-only cookie и загрузка необходимых ресурсов на этапе инициализации клиента. Первый снимает головную боль о способе хранения ключей на клиенте для сохранения авторизации при последующих сеансах работы приложения, а второй позволяет загружать необходимые данные без лишней нагрузки на сервер с возможностью переноса в последствии этой задачи на CDN, ведь я надеюсь, что игра найдет своих пользователей.

Теперь я хочу рассказать как устроено само приложение. Для наглядности я подготовил еще одну схему, которая отображает его архитектуру.

архитектура приложения

архитектура приложения

Как показано на схеме во главе всего у меня стоит uWebSockets.js, ее интерфейс предоставляет возможность обрабатывать HTTP запросы содержащие данные отправленные пользователем — заголовки (headers) и само тело сообщения (body). В функциях обрабатывающих эти сообщения, производится разбор заголовков, которые могут содержать ключи доступа (JWT), куки (cookies) и другую полезную информацию, которая необходима для корректной обработки запроса. На этом же уровне проводятся все базовые проверки поступающих данных — ключи доступа проверяются на соответствие подписи, тело сообщения преобразуется в JSON и так далее. Все запросы не прошедшие базовую проверку попросту игнорируются.

В сообщении обязательно должны присутствовать пункты назначения — название модуля и его функции, которая будет обрабатывать данные. После проверки все корректные сообщения отправляются в маршрутизатор (router), к которому в свою очередь подключены доступные ему модули. Модуль это обычный TypeScript файл имеющий экспортируемые функции. Получив сообщение маршрутизатор проверяет наличие модуля и функции в нем, если модуль или функция не найдена, клиенту возвращается сообщение об ошибке, а если все в порядке то передает данные сообщения в функцию конкретного модуля и ожидает ответа, который в свою очередь передается обратно в функцию обработки сообщений на уровень выше, которая формирует ответ и отправляет его клиенту. Результатом работы функции может быть и ошибка, что не является нестандартным поведением системы. В случае возникновения на любом этапе внештатной ситуации функция выбрасывает исключение, которое обрабатывается на верхнем уровне и результат обработки отправляется пользователю в виде ошибки с описанием, если ошибка предсказуема. Помимо описанных выше функций, маршрутизатор также является единой точкой контроля доступа к тем или иным модулям и их функциям. Можно например указать какие модули или отдельные их функции могут быть использованы без авторизации, а какие например требуют дополнительно к авторизации наличия определенных ролей в системе.

Функции всех модулей имеют один интерфейс для взаимодействия. По сути это параметры самой функции и их всего два — отправитель (sender) и данные (data), оба параметры — объекты, таким образом каждая функция получает всю необходимую информацию для обработки сообщения. Объект отправитель (sender) содержит данные о пользователе — его идентификатор, роль в системе, набор кук, разложенный в виде объекта ключ-значение и другую необходимую информацию. Данные (data) переданные в функцию в свою очередь проходят проверку при помощи библиотеки UTP.js, о которой я упоминал в предыдущей статье. Она позволяет проверить типизацию, определить наличие обязательных полей, убрать все лишнее и получить объект строго соответствующий описанию схемы данных для данной конкретной функции. Здесь думаю имеет смысл рассказать немного подробнее, в чем плюс. На стороне сервера, мы один раз описываем в рамках протокола все схемы данных, которыми обмениваются сервер и клиент, а затем используем эти схемы для проверки соответствия данных этим описаниям и для кодирования/декодирования бинарных пакетов которыми обмениваются в последствии клиент и сервер.

Все это в целом описывает процесс обработки сообщений и для HTTP транспорта и для WebSocket, за исключением одного момента. WebSocket это не HTTP, для создания соединения браузер отправляет специальный запрос который сообщает серверу, что клиент желает установить WebSocket соединение и в этом случае uWebSockets.js предоставляет нам возможность обработать это сообщение также как обычный HTTP запрос и провести процесс подключения клиента к сокету. Кстати для тех из вас, кто не до конца понимает что такое сокеты и как вообще работает сеть, я рекомендую прочитать наверное одну из лучших технических книг, которые мне доводилось читать, ее автор Pieter Hintjens, CEO компании iMatix. Так вот в процессе обработки этого пакета мы имеем возможность авторизовать пользователя также, как это происходит при обычном HTTP запросе. И здесь у меня будет вопрос к аудитории. Дело в том, что WebSocket соединение не может быть установлено из браузера с передачей дополнительных заголовков, таких как Authorization и каким тогда, на ваш взгляд, наилучшим образом можно реализовать передачу токена? Поделитесь своим мнением в комментариях, я думаю это будет интересно.

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

На этом описание архитектуры сервера я заканчиваю, а в следующей статье хочу рассказать о модулях, которые содержат логику обработки поступающих сообщений. Голосуйте за модули которые интересуют вас в первую очередь.

Посмотреть текущее состояние проекта и поиграть можно перейдя по ссылке.

© Habrahabr.ru