
В этой статье мы собираемся исследовать возможности использования сокетов, используя один из вариантов использования в качестве основы для многопользовательской онлайн-игры.
Мы рассмотрим создание игрового сервера с
- Socket.io, одна из бесспорно мощных библиотек для построения надежного двунаправленного канала связи между клиентом и сервером.
- Redis как слой кеширования для отслеживания подключений и состояния игры, также мы будем использовать его для решения некоторых условий гонки, используя базовую блокировку с помощью Redis.
Системные требования, чего ожидать
Мы начинаем с определения функциональных возможностей, которые ожидаются от системы, чтобы иметь ясный ум при разработке нашего сервиса и не беспокоиться о добавлении каких-либо дополнительных деталей в наш интерфейс:
- Мы ожидаем, что сервер будет обрабатывать соединение игроков вместе, в основном любой игрок, который нажимает «начать случайную игру», должен быть связан с другими игроками, которые также хотят играть.
- Мы ожидаем, что сервер будет обрабатывать преднамеренные или случайные отключения от игроков либо во время запуска игры и сбора игроков, либо во время самой игры.
- Мы ожидаем, что сервер обновит все клиенты статусом других игроков, чтобы иметь возможность показывать на экране каждого игрока, сколько игроков присоединяется к игре и кто покинул / присоединился.
- Мы ожидаем, что сможем общаться в чате во время игры.
Системный дизайн
Хорошо, для начала достаточно функций, давайте начнем с базового дизайна такой службы.
События сокета
Как вы, возможно, уже знаете, сокеты - это все о событиях и отправке сообщений повсюду. Чтобы отправить сообщение от сервера к клиенту, вам необходимо определить имя события для сообщения, потому что связь здесь не синхронизируется, а скорее асинхронна. И наоборот, клиентам необходимо транслировать имя события для любого сообщения, которое они хотят, чтобы другие клиенты или сервер слушали.

Итак, мы начнем с выбора набора событий для представления нашей системы:
- Подключиться: кажется простым, конечно, нам нужно событие при подключении, чтобы мы регистрировали информацию о пользователе в нашей системе и идентифицировали его, когда он отправляет новые сообщения, к счастью, это даже запускается автоматически всякий раз, когда клиент подключается к нашему серверу. Это событие передается от клиента к серверу.
- Запрос пары: это событие сигнализирует о том, что клиент хочет сыграть в новую игру, поэтому всякий раз, когда оно запускается, мы должны начинать сопоставлять этого игрока с любой доступной комнатой с другими игроками, ожидающими присоединения других людей. Это событие передается от клиента к серверу.
- Игрок присоединился: как только мы находим комнату, в которой нужен игрок, отправивший запрос пары, мы должны добавить его в этот список ожидания и уведомить других игроков через это событие, что новый участник только что вошел в комнату. Это событие передается от сервера клиенту.
- Игрок слева. Аналогичным образом, когда игрок намеренно отключается или нажимает кнопку «Уйти» в пользовательском интерфейсе, нам необходимо уведомить другого игрока о том, что кто-то покинул игру, чтобы они сообщали своим пользователям эту информацию. Это событие передается от сервера клиенту.
- Запрос на выход игрока / отключение: со стороны клиента нам нужен способ получать уведомление в качестве сервера о том, что кто-то покинул сеть (встроенное событие отключения) или что кто-то нажал кнопку выхода и хочет выйти из игры сейчас, поэтому что мы объявляем всем, что он ушел, используя событие Player Left. Это событие передается от клиента к серверу.
- Игрок готов: это событие означает, что игрок готов начать игру, даже если в комнате нет максимального количества игроков. Это действительно полезно в играх, в которых требуется более двух игроков, но они еще можно запустить игру на 3 игрока например, а остальных дополнить ботами. Это событие исходит от клиента, но также должно транслироваться всем остальным клиентам с сервера.
- Игра началась. Сервер должен запускать это событие для клиентов, когда уже доступно максимальное количество игроков или достаточно готовых игроков.
- Игра завершена: это событие должно транслироваться от победившего клиента на сервер и от сервера ко всем другим клиентам. Если никто не запустил это событие, то все отключились, и в игре нет победителя.
Это в значительной степени исчерпывающий список всех событий, которые мы хотим прослушивать как на стороне сервера, так и на стороне клиента.
Целевые события и комнаты в Socket.io
Здесь следует учесть небольшую деталь: мы не хотим, чтобы эти события транслировались для всех постоянно! только группа игроков, ожидающих в одной комнате, должна слышать друг друга, и никто другой не должен отвлекаться на их обновления.

Вот здесь и вступают в игру Комнаты Socket.io, socket.io позволяет нам определить виртуальную комнату для наших сокетов, где трансляция событий достигает только клиентов этих комнат, и никто другой не получает сообщения, звучит аккуратно, не так ли?
Сохранение данных
Возможно, вы заметили, что я никогда не упоминал здесь базу данных, поэтому где будут храниться все эти данные для комнат и текущих игр / игрового чата? Давайте рассмотрим их по очереди и вскоре обсудим.
- Для комнат и тех, кто с кем играет, мы можем просто хранить их в виде очередей, например, в Redis, нам не нужно обращаться к БД каждый раз, когда клиент покидает игру или отключается, база данных будет иметь больше смысла в конце игры, чтобы сохранить только окончательные результаты игры, любые детали до этого вряд ли необходимо сохранять в базе данных.
- Для разговора внутри игры буквально нет необходимости сохранять чат где-либо, кроме самого клиента, в кеше не нужно хранить такую историю чатов, так как она выбрасывается в конце игры, это ценно только до тех пор, пока игрок подключен и играет в игру.
Итак, мы продолжим работу с сокетами + redis только для выполнения этой задачи.
Структура кеша
Нам нужно определить определение типа схемы для того, как данные будут храниться и извлекаться из нашего кеша, у нас есть несколько фрагментов данных, которые нам нужно отслеживать там:
- информация о пользователе: хэш, содержащий идентификатор подключения пользователя и его имя пользователя, текущее состояние (готов к игре или нет), комната (в какой комнате он находится)
- Игровая комната: это должна быть комната, в которой игроки начинают игру или в которой уже играют. Это виртуальная сущность, которую мы создали, чтобы иметь возможность объединять игроков в группы для игры.
Для этого нам нужно определить структуру, которая будет удобна для чтения и не сложна для записи.

Как показано выше, нам нужно будет поддерживать 4 структуры для хранения наших данных, в этом случае мы будем использовать списки Redis, потому что нам нужен произвольный доступ (к сожалению, мы не будем использовать очереди).
- Как только игрок подключается, мы устанавливаем его идентификатор подключения в кеше с его информацией.
- Когда игрок запрашивает создание пары и не может найти комнату (полностью или ни одной из доступных), нам нужно нажать на клавишу с названием новой комнаты на идентификатор игрока и нажать на саму комнату с помощью клавиши Набор комнат.
- Когда комната заполнена или в ней достаточно готовых игроков, мы сделаем всплывающее сообщение из Комнаты набора в список Игровые комнаты.
- Когда игрок отключается, нам нужно удалить ключ его информации и удалить его из списка комнат.
- Если комната доступна и кто-то просит создать пару, мы можем просто отправить его идентификатор в эту комнату и проверить, достаточно ли у нас игроков, чтобы начать игру.
- Когда игрок готов, мы обновляем его информацию, чтобы он был готов, и смотрим, есть ли у нас минимум необходимых игроков, готовых начать игру, или нет.
Условия гонки и блокировка с помощью redis
Теперь наш основной поток очищен, давайте посмотрим на некоторые состояния гонки, которые могут привести к несогласованности данных в нашем кеше.
Первое, на что нужно обратить внимание, - это реализация обработки такого события, как запрос пары.
- мы получаем информацию об игроке из сокета и ищем доступные комнаты в кеше.
- если ничего не найдено, мы создаем новый и транслируем пользователю, что он присоединился к комнате x.
Эта двухэтапная транзакция в некотором роде опасна: если 2/3/4/5 пользователей смогли пройти первый шаг вместе, они перейдут ко второму шагу, где все они начнут создавать новую комнату, это плохо, у нас будет как минимум 2 комнаты с одним игроком в каждой, в то время как мы могли бы создать одну комнату для всех, нам нужно сделать этот поток атомарным, что означает, что нам нужно пометить эту часть как критический раздел, в который можно войти только по одному
Мы могли бы использовать специальный ключ для блокировки / разблокировки критического раздела с помощью команды setnx, или мы могли бы использовать подход, основанный на токенах, когда мы создаем очередь / список токенов в redis и выталкиваем токен при входе в критический раздел и повторно нажать, когда это делается с помощью команд blpop и rpush.
Во втором случае мы имеем тот же поток, но для почти заполненных комнат:
- мы получаем комнату для игрока, запрашивающего соединение, в комнате осталось одно место.
- Затем мы вставляем игрока в комнату.
Этот поток также вызовет проблемы, поскольку многие игроки могут пройти первый шаг и все событие запуска игры с избыточной вместимостью игроков в комнате, это, к счастью, будет решено с использованием того же решения атомарной транзакции, которое мы упоминали выше.
Это все, что нужно для разработки такого сервиса, реальная реализация доступна здесь.
Домашнее задание
В списке все еще есть некоторые недостающие функции, такие как чат и приглашения друзей в приватную комнату, я оставляю их для вас, чтобы вы попробовали и спроектировали / реализовали себя на практике, вы найдете список недостающих функций в репозитории README, получайте удовольствие!