Всем привет!

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

Последние несколько лет я использую исключительно облачную инфраструктуру для запуска проектов, это не только позволяет существенно экономить финансовые ресурсы на этапе разработки, но и дает возможность в дальнейшем легко масштабировать мощности по мере роста нагрузки на сервера в процессе эксплуатации. По этой причине я решил, что можно пропустить описание базовых настроек сервера, по сути в моем случае они сводятся к закрытию всех портов за исключением 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 транспорта.

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

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

Комментарии (13)


  1. gudvinr
    17.08.2024 20:26

    Для чего в этой схеме S3, который подключён к ноде, но к которому ни клиент, ни nginx доступ не имеет?


    1. tsmar Автор
      17.08.2024 20:26

      на S3 я складываю аватарки и прочие ресурсы, он подключен к CDN, клиент к нему обращается, надо будет дополнить схему, спасибо


  1. sonytruelove
    17.08.2024 20:26

    Дополню комментарий выше.
    Запуск нескольких процессов в одном контейнере противоречит концепции докера. Докер сделан так, чтобы запускать один процесс (не считая дочерних процессов). Не совсем понятно почему в скобках на схеме указан Docker Container, но уверен что автор имел ввиду что-то иное.

    Про авторизацию рискну предположить:
    Мне нравится своей простотой передача в URL через Query-параметр. Ввиду того что TLS шифрует все кроме хоста и порта это относительно безопасно. У такого подхода есть ряд недостатков, наиболее существенный из которых в том, что query-параметры обычно логируются на промежуточных серверах как части запроса, и если риск попадания токена в логи недопустим, то с таким вариантом возникают сложности. Для решения данной проблемы существует схема, которая основывается на разделении сессионного токена и отдельного токена для установки WebSocket-соединения.
    Так же есть вариант:
    Сначала можно выполнить обычный HTTP-запрос авторизации (например, POST запрос для получения токена). После успешной аутентификации можно сохранять токен в локальном хранилище (localStorage) или куках и затем устанавливать WebSocket-соединение.

    Проект интересен с точки зрения "подводных камней" и фич, которые могут быть полезны для разработчиков.
    Жду следующих частей!


    1. tsmar Автор
      17.08.2024 20:26

      Вы совершенно правы относительно концепции докера и он указан в скобках скорее как один из вариантов развертывания приложения. В статье я упоминал о масштабировании за счет увеличения количества cpu/vcpu и в схеме хотел отразить именно этот подход.

      Про авторизацию также соглашусь с вами, вариант с передачей токена в url я использовал в первой версии игры и действительно его единственный минус на мой взгляд это попадание токена в логи. Сейчас я использую куку с коротким сроком действия которую вешаю перед подключением и удаляю сразу после этого. Интересно было бы узнать ваше мнение на этот счет, с учетом того, что хранить локально токены доступа я категорически не хочу ни в localStorage ни где либо еще.

      Спасибо за интерес к проекту!


      1. sonytruelove
        17.08.2024 20:26

        Да, думаю ваше решение хорошее и безопасное при правильной настройке.


  1. feligz
    17.08.2024 20:26

    Вы в первой части написали, что не использовали веб фреймворки, а обошлись typescript + html+ css, вот об этом подробности было бы интересно почитать.


    1. tsmar Автор
      17.08.2024 20:26

      Обязательно доберемся до фронта сразу после того как закончим с серверной частью


  1. noker81
    17.08.2024 20:26

    Интересная реализация. Вот только 4 кубика показывать не обязательно, лучше пусть будет 2 как в нормальной игре. И вопрос про рандом цифр, чтобы-то четверка прямо одна за одной шла, может конечно совпадение, но во всех 10 бросках она выпадала.


    1. tsmar Автор
      17.08.2024 20:26

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

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

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

      Сейчас для генерации используется классический Math.floor(Math.random() * 6) + 1, я посчитал, что его будет достаточно, а вы как думаете?


      1. noker81
        17.08.2024 20:26

        Интересный подход с комбинацией. 128 бросков будет достаточно как думаете, если скажем даже всегда будет выпадать 1 + 2 или в случаи блокировки соперника.


        1. tsmar Автор
          17.08.2024 20:26

          хороший вопрос ) я сделал сейчас запрос в базу для получения среднего количества ходов в игре и получил 80, цифра получена на базе из полутора тысяч проведенных игр. Максимальное количество шагов 111, что прям таки подбирается к границе, думаю надо увеличивать, спасибо )


          1. noker81
            17.08.2024 20:26

            Было бы интересно получить данные по броскам равномерно ли они распределяются или какие-то цифры все же чаще показываются.


            1. tsmar Автор
              17.08.2024 20:26

              готов предоставить выгрузку для анализа, в каком формате будет лучше всего?