Найти в Дзене
ZDG

Пишу онлайн-игру Color Lines: Сервер

В предыдущей части был закончен прототип клиента, в который уже можно играть с эмуляцией сервера на стороне клиента: Настало время сделать настоящий сервер. В качестве технологического стека я возьму не совсем оптимальный HTTP + PHP + MySQL как самую распространённую "пролетарскую" конфигурацию веб-хостингов. Кроме того, я буду применять другие странные решения, без особых на то причин. Возможны две стратегии работы с сервером. Первая стратегия имеет один жирный плюс. Это минимальная нагрузка на сервер. Всё остальное, увы, минусы. Вторая стратегия, наоборот, имеет один жирный минус. Это высокая нагрузка на сервер. Если ход делается раз в секунду, то 5000 игроков создадут нагрузку в 5000 запросов в секунду. Но если у данной игры будет активная аудитория в 5000 игроков, это можно считать оглушительным успехом и думать об оптимизации и апгрейде сервера. Всё остальное это плюсы: У меня нет паранойи насчёт того, что кто-то будет мошенничать с результатами игры. Ну будет, и что? Ничего. Одн
Оглавление

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

Настало время сделать настоящий сервер.

Дисклеймер (отказ от ответственности)

В качестве технологического стека я возьму не совсем оптимальный HTTP + PHP + MySQL как самую распространённую "пролетарскую" конфигурацию веб-хостингов. Кроме того, я буду применять другие странные решения, без особых на то причин.

Есть два стула...

Возможны две стратегии работы с сервером.

  • Стратегия 1

    Клиент получает начальные данные и всю игру проводит у себя, не делая запросов к серверу. После окончания игры клиент отправляет на сервер список своих ходов. Сервер, имея те же начальные данные, проверяет весь список ходов (фактически проигрывает игру заново), и если всё хорошо, то сохраняет эти данные.
  • Стратегия 2

    Клиент получает начальные данные, но каждый ход отправляет на сервер. Сервер обрабатывает ход и присылает клиенту обновлённые данные для следующего хода.

Плюсы и минусы

Первая стратегия имеет один жирный плюс. Это минимальная нагрузка на сервер. Всё остальное, увы, минусы.

  • Клиент и сервер должны поддерживать один и тот же алгоритм игры. Скажем, у них должен быть одинаковый генератор случайных чисел. Если что-то меняется на сервере, то изменения надо вносить и в клиент, а у кого-то может остаться старая версия клиента и т.д.
  • Игровую сессию нельзя будет продолжить с другого устройства или после закрытия браузера, так как состояние игры хранится только в клиенте
  • Сервер не знает, что происходит в клиенте. Немного модифицировав клиент, игрок может начинать с одними и теми же данными сколько угодно игр. Они будут уже предопределены, так как состояние генератора случайных чисел у них будет одинаковое. Таким образом, игрок сможет переигрывать одну и ту же игру, пока не добьётся лучшего результата, и только потом отправит его на сервер.

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

Всё остальное это плюсы:

  • Сервер полностью контролирует сессию игрока
  • Клиенту не нужно иметь на своей стороне реализацию алгоритмов сервера, он просто получает готовые состояния
  • В онлайне можно наблюдать за игрой в реальном времени, а не дожидаться её окончания
  • Игрок сможет закрыть клиент, а потом продолжить игру с любого другого устройства, если у него сохранилась ссылка

Побеждает Стратегия 2!

У меня нет паранойи насчёт того, что кто-то будет мошенничать с результатами игры. Ну будет, и что? Ничего.

Однако мне больше нравится второй вариант, его интересней делать, и он более универсальный.

Требования к сессии пользователя

Сервер не требует никакой регистрации. Но он должен однозначно идентифицировать пользователя, а точнее, его игровую сессию в процессе игры.

Для этого создаётся идентификатор сессии. Он должен быть уникальным. Этого легко добиться, сделав таблицу сессий с полем автоинкремента, которое будет увеличиваться на 1 при создании каждой новой сессии.

Однако уникальности недостаточно. Если пользователь знает, что идентификаторы увеличиваются последовательно, он легко сможет получить доступ к сессии 5, 6, 7 и т.д., просто перебирая номера.

Опять же, это далеко от каких-то реальных киберугроз, но если взялся делать, делать надо хорошо.

-2

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

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

Итак, нам нужны такие поля в таблице для хранения сессий:

  • id – уникальный идентификатор
  • rand_id – дополнительная случайная часть идентификатора
  • rand_seed – параметр для засевания генератора случайных чисел в начале игры
  • date_created – дата создания сессии

Рассмотрим остальные данные, которые привязаны к сессии.

  • score – текущий счёт
  • turn – номер хода
  • rand_state – состояние генератора случайных чисел
  • tiles – игровое поле

Нужно ли их делать отдельной таблицей, или включить прямо в таблицу сессий?

Числовые поля занимают 28 байт, плюс данные игрового поля можно ужать до 44 байт без особых напрягов. Итого получается 72 байта на одну запись. Размер записи достаточно мал, и все её поля имеют фиксированный размер, поэтому с индексированием не будет проблем. Кроме того, получая сессию из таблицы по идентификатору, сервер сразу получит и её данные, так что не надо будет делать второй запрос для поиска данных.

Принимаю решение объединить всё в одной таблице session.

Схема обма обмена

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

В последующие разы клиент посылает серверу ходы игрока вместе с идентификационными данными. Сервер по этим данным ищет сессию, вместе с ней находит состояние игры, модицифирует его, логирует ход игрока и отправляет новое состояние клиенту.

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

Как бороться?

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

Предположим, что сервер периодически удаляет сессии, в которых не сделано ни одного хода в течение какого-то времени. Это может привести к фрагментации данных таблицы, что опять же снизит эффективность. Подчеркну – МОЖЕТ, то есть это не факт. Но мы должны быть начеку.

Моё решение – сделать вторую таблицу с временными сессиями. Записи из этой таблицы можно удалять, а также можно очистить всю таблицу сразу (truncate), не думая о потере данных игроков.

Тогда схема получается такая:

  1. Клиент шлёт пустой запрос
  2. Сервер создаёт сессию во временной таблице и возвращает клиенту
  3. Если клиент больше не обращается к этой сессии, она будет висеть во временной таблице, пока не будет удалена
  4. Если клиент делает ход, то он шлёт данные сессии. Сервер ищет их в рабочей таблице. Если их там нет, ищет во временной. Если там есть, сессия переносится из временной таблицы в рабочую.

Таким образом, в рабочих сессиях будут те, где сделан хотя бы один ход. Это конечно не гарантирует, что и они не останутся "мусорными" с одним ходом, однако потенциальному зловредному клиенту теперь придётся напрячься немного сильнее, чем просто обновлять страницу.

Для создания таблицы временных сессий используется такой SQL-код:

-3

Здесь есть только необходимые поля, а первичные игровые данные можно сгенерировать, зная rand_seed. Так что таблица временных сессий будет иметь сокращённый размер записи.

Для создания таблицы рабочих сессий используется такой SQL-код:

-4

У неё нет автоинкрементного поля id, так как источником идентификаторов служит таблица временных сессий. Вместо этого есть поле uid, которое будет комбинировать в себе id и rand_id, так как они всегда используются совместно.

Инструментарий для работы с сессиями

Сервер я намерен сделать максимально легковесным, в ущерб любым принципам "хорошего кода", так как он достаточно простой. Но модели для хранения сессий я всё-таки оформлю в виде классов. Начнём с временной сессии.

-5

Класс имеет те же атрибуты, которые есть в таблице БД.

Далее метод для создания новой временной сессии:

-6

Я использую генератор случайных чисел XoShiRo, о котором речь шла ранее:

Его нужно нормально засеять. Для засева требуются четыре 32-битных числа. БД MySQL умеет возвращать гарантированно уникальные идентификаторы в формате UUID. Для этого надо просто выполнить запрос SELECT UUID(). UUID выглядит так:

-7

Это несколько 16-ричных чисел, разделённых дефисами. Я удаляю оттуда дефисы и получаю одно длинное число. Однако для засева оно не подходит, потому что хотя оно каждый раз разное, большая его часть не изменяется (точнее, изменяется очень медленно), в результате на коротком промежутке времени XoShiRo даёт один и тот же первый результат. Поэтому я беру ещё таймер высокого разрешения hrtime(), который возвращает секунды и наносекунды. Используя части UUID и данные таймера, засеваю XoShiRo.

Затем из засеянного XoShiRo получаю rand_id – поле, которое будет использоваться вместе с id для получения уникального идентификатора.

После этого состояние XoShiRo сохраняется в поле rand_seed – оно является стартом для всех последующих действий в игре, и из него можно всё повторить.

Сохранение временной сессии:

-8

Стоит отметить метод Session::encodeBinary().

Суть в том, что у сессии поле rand_seed представлено массивом из 4-х чисел, а в базу оно должно записаться как 16-байтовая бинарная строка. Поэтому метод encodeBinary() конвертирует массив в такую строку:

-9

Функция pack() в PHP предназначена для упаковки различных значений в строку. В данном случае используется формат 'L4', что означает четыре 32-битных числа.

Аналогично, при чтении данных из базы нужно строку превратить обратно в массив чисел:

-10

Здесь функция unpack() с тем же форматом 'L4' возвращает массив из 4 элементов, значения которого я переписываю в другой массив типа SplFixedArray. Я использую его, так как массивы в PHP имеют динамический размер и ассоциативны, то есть по факту это хэшмапы, а SplFixedArray больше похож на классический массив и обещает меньше потребления памяти и выше скорость. Правда, на таких смехотворных объёмах данных этого видно не будет.

Ну и да, надо обратить внимание, что unpack() создаёт массив, который начинается с индекса 1, а не 0.

-11

Получение временной сессии из базы:

-12

Что тут интересного? В качестве аргумента для поиска передаётся $uid, который является комбинацией id и rand_id, и соответственно раскладывается на них при заполнении параметров запроса.

Также используется метод DBHelper::findOne(). Он нужен для упрощения получения результата запроса, когда нужна только одна запись. Так как такие запросы встречаются в разных местах, для удобcтва сделан отдельный метод:

-13

Код сервера

Вот практически весь код сервера:

-14

Он устанавливает соединение с базой, проверяет параметр $uid, который должен прийти в GET- или POST-запросе, и если ничего не пришло, то вызывает обработчик пустого запроса processEmptyRequest(). Если же пришло, то вызывает обработчик сессионного запроса processSessionRequest(). Обработчики должны сформировать ответ сервера ($response). Если не удалось, сервер умирает. А если удалось, то отсылает ответ в браузер.

Как работают обработчики, в деталях разберём в следующей части.

Читайте дальше:

Пишу онлайн-игру Color Lines: Серверная логика и изменения клиента
ZDG17 января 2025