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

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

Оглавление

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

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

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

В качестве технологического стека я возьму не совсем оптимальный 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 января