В предыдущей части был закончен прототип клиента, в который уже можно играть с эмуляцией сервера на стороне клиента:
Настало время сделать настоящий сервер.
Дисклеймер (отказ от ответственности)
В качестве технологического стека я возьму не совсем оптимальный HTTP + PHP + MySQL как самую распространённую "пролетарскую" конфигурацию веб-хостингов. Кроме того, я буду применять другие странные решения, без особых на то причин.
Есть два стула...
Возможны две стратегии работы с сервером.
- Стратегия 1
Клиент получает начальные данные и всю игру проводит у себя, не делая запросов к серверу. После окончания игры клиент отправляет на сервер список своих ходов. Сервер, имея те же начальные данные, проверяет весь список ходов (фактически проигрывает игру заново), и если всё хорошо, то сохраняет эти данные. - Стратегия 2
Клиент получает начальные данные, но каждый ход отправляет на сервер. Сервер обрабатывает ход и присылает клиенту обновлённые данные для следующего хода.
Плюсы и минусы
Первая стратегия имеет один жирный плюс. Это минимальная нагрузка на сервер. Всё остальное, увы, минусы.
- Клиент и сервер должны поддерживать один и тот же алгоритм игры. Скажем, у них должен быть одинаковый генератор случайных чисел. Если что-то меняется на сервере, то изменения надо вносить и в клиент, а у кого-то может остаться старая версия клиента и т.д.
- Игровую сессию нельзя будет продолжить с другого устройства или после закрытия браузера, так как состояние игры хранится только в клиенте
- Сервер не знает, что происходит в клиенте. Немного модифицировав клиент, игрок может начинать с одними и теми же данными сколько угодно игр. Они будут уже предопределены, так как состояние генератора случайных чисел у них будет одинаковое. Таким образом, игрок сможет переигрывать одну и ту же игру, пока не добьётся лучшего результата, и только потом отправит его на сервер.
Вторая стратегия, наоборот, имеет один жирный минус. Это высокая нагрузка на сервер. Если ход делается раз в секунду, то 5000 игроков создадут нагрузку в 5000 запросов в секунду. Но если у данной игры будет активная аудитория в 5000 игроков, это можно считать оглушительным успехом и думать об оптимизации и апгрейде сервера.
Всё остальное это плюсы:
- Сервер полностью контролирует сессию игрока
- Клиенту не нужно иметь на своей стороне реализацию алгоритмов сервера, он просто получает готовые состояния
- В онлайне можно наблюдать за игрой в реальном времени, а не дожидаться её окончания
- Игрок сможет закрыть клиент, а потом продолжить игру с любого другого устройства, если у него сохранилась ссылка
Побеждает Стратегия 2!
У меня нет паранойи насчёт того, что кто-то будет мошенничать с результатами игры. Ну будет, и что? Ничего.
Однако мне больше нравится второй вариант, его интересней делать, и он более универсальный.
Требования к сессии пользователя
Сервер не требует никакой регистрации. Но он должен однозначно идентифицировать пользователя, а точнее, его игровую сессию в процессе игры.
Для этого создаётся идентификатор сессии. Он должен быть уникальным. Этого легко добиться, сделав таблицу сессий с полем автоинкремента, которое будет увеличиваться на 1 при создании каждой новой сессии.
Однако уникальности недостаточно. Если пользователь знает, что идентификаторы увеличиваются последовательно, он легко сможет получить доступ к сессии 5, 6, 7 и т.д., просто перебирая номера.
Опять же, это далеко от каких-то реальных киберугроз, но если взялся делать, делать надо хорошо.
Таким образом, идентификатор сессии должен быть уникальным за счёт автоинкремента, и также содержать какую-то порцию данных, которые нельзя предсказать.
Это может быть случайное число, которое дописывается к номеру сессии. Поэтому даже зная, что номера сессий увеличиваются последовательно, пользователь не может предсказать случайное число.
Итак, нам нужны такие поля в таблице для хранения сессий:
- id – уникальный идентификатор
- rand_id – дополнительная случайная часть идентификатора
- rand_seed – параметр для засевания генератора случайных чисел в начале игры
- date_created – дата создания сессии
Рассмотрим остальные данные, которые привязаны к сессии.
- score – текущий счёт
- turn – номер хода
- rand_state – состояние генератора случайных чисел
- tiles – игровое поле
Нужно ли их делать отдельной таблицей, или включить прямо в таблицу сессий?
Числовые поля занимают 28 байт, плюс данные игрового поля можно ужать до 44 байт без особых напрягов. Итого получается 72 байта на одну запись. Размер записи достаточно мал, и все её поля имеют фиксированный размер, поэтому с индексированием не будет проблем. Кроме того, получая сессию из таблицы по идентификатору, сервер сразу получит и её данные, так что не надо будет делать второй запрос для поиска данных.
Принимаю решение объединить всё в одной таблице session.
Схема обма обмена
В первый раз клиент обращается к серверу с пустым запросом. Сервер видит, что клиент пришёл без ничего, создаёт новую сессию и отправляет клиенту идентификационные данные сессии и начальное игровое поле.
В последующие разы клиент посылает серверу ходы игрока вместе с идентификационными данными. Сервер по этим данным ищет сессию, вместе с ней находит состояние игры, модицифирует его, логирует ход игрока и отправляет новое состояние клиенту.
Так как сервер создаёт новую сессию всякий раз, когда пользователь отправляет пустой запрос, клиент может злоупотребить этим и завалить сервер новыми сессиями, просто обновляя страницу. В таблице сессий образуется много "мусорных" записей, которыми никто никогда не воспользуется.
Как бороться?
Предположим, мы просто храним все мусорные сессии, ничего с ними не делая. Тогда размер таблицы будет бесконтрольно расти и также упадёт эффективность обработки нормальных, рабочих сессий. Отмечу, что эти эффекты будут проявляться на миллионах записей, но надо сегодня смотреть в завтрашний день и представлять, что эти миллионы таки наберутся.
Предположим, что сервер периодически удаляет сессии, в которых не сделано ни одного хода в течение какого-то времени. Это может привести к фрагментации данных таблицы, что опять же снизит эффективность. Подчеркну – МОЖЕТ, то есть это не факт. Но мы должны быть начеку.
Моё решение – сделать вторую таблицу с временными сессиями. Записи из этой таблицы можно удалять, а также можно очистить всю таблицу сразу (truncate), не думая о потере данных игроков.
Тогда схема получается такая:
- Клиент шлёт пустой запрос
- Сервер создаёт сессию во временной таблице и возвращает клиенту
- Если клиент больше не обращается к этой сессии, она будет висеть во временной таблице, пока не будет удалена
- Если клиент делает ход, то он шлёт данные сессии. Сервер ищет их в рабочей таблице. Если их там нет, ищет во временной. Если там есть, сессия переносится из временной таблицы в рабочую.
Таким образом, в рабочих сессиях будут те, где сделан хотя бы один ход. Это конечно не гарантирует, что и они не останутся "мусорными" с одним ходом, однако потенциальному зловредному клиенту теперь придётся напрячься немного сильнее, чем просто обновлять страницу.
Для создания таблицы временных сессий используется такой SQL-код:
Здесь есть только необходимые поля, а первичные игровые данные можно сгенерировать, зная rand_seed. Так что таблица временных сессий будет иметь сокращённый размер записи.
Для создания таблицы рабочих сессий используется такой SQL-код:
У неё нет автоинкрементного поля id, так как источником идентификаторов служит таблица временных сессий. Вместо этого есть поле uid, которое будет комбинировать в себе id и rand_id, так как они всегда используются совместно.
Инструментарий для работы с сессиями
Сервер я намерен сделать максимально легковесным, в ущерб любым принципам "хорошего кода", так как он достаточно простой. Но модели для хранения сессий я всё-таки оформлю в виде классов. Начнём с временной сессии.
Класс имеет те же атрибуты, которые есть в таблице БД.
Далее метод для создания новой временной сессии:
Я использую генератор случайных чисел XoShiRo, о котором речь шла ранее:
Его нужно нормально засеять. Для засева требуются четыре 32-битных числа. БД MySQL умеет возвращать гарантированно уникальные идентификаторы в формате UUID. Для этого надо просто выполнить запрос SELECT UUID(). UUID выглядит так:
Это несколько 16-ричных чисел, разделённых дефисами. Я удаляю оттуда дефисы и получаю одно длинное число. Однако для засева оно не подходит, потому что хотя оно каждый раз разное, большая его часть не изменяется (точнее, изменяется очень медленно), в результате на коротком промежутке времени XoShiRo даёт один и тот же первый результат. Поэтому я беру ещё таймер высокого разрешения hrtime(), который возвращает секунды и наносекунды. Используя части UUID и данные таймера, засеваю XoShiRo.
Затем из засеянного XoShiRo получаю rand_id – поле, которое будет использоваться вместе с id для получения уникального идентификатора.
После этого состояние XoShiRo сохраняется в поле rand_seed – оно является стартом для всех последующих действий в игре, и из него можно всё повторить.
Сохранение временной сессии:
Стоит отметить метод Session::encodeBinary().
Суть в том, что у сессии поле rand_seed представлено массивом из 4-х чисел, а в базу оно должно записаться как 16-байтовая бинарная строка. Поэтому метод encodeBinary() конвертирует массив в такую строку:
Функция pack() в PHP предназначена для упаковки различных значений в строку. В данном случае используется формат 'L4', что означает четыре 32-битных числа.
Аналогично, при чтении данных из базы нужно строку превратить обратно в массив чисел:
Здесь функция unpack() с тем же форматом 'L4' возвращает массив из 4 элементов, значения которого я переписываю в другой массив типа SplFixedArray. Я использую его, так как массивы в PHP имеют динамический размер и ассоциативны, то есть по факту это хэшмапы, а SplFixedArray больше похож на классический массив и обещает меньше потребления памяти и выше скорость. Правда, на таких смехотворных объёмах данных этого видно не будет.
Ну и да, надо обратить внимание, что unpack() создаёт массив, который начинается с индекса 1, а не 0.
Получение временной сессии из базы:
Что тут интересного? В качестве аргумента для поиска передаётся $uid, который является комбинацией id и rand_id, и соответственно раскладывается на них при заполнении параметров запроса.
Также используется метод DBHelper::findOne(). Он нужен для упрощения получения результата запроса, когда нужна только одна запись. Так как такие запросы встречаются в разных местах, для удобcтва сделан отдельный метод:
Код сервера
Вот практически весь код сервера:
Он устанавливает соединение с базой, проверяет параметр $uid, который должен прийти в GET- или POST-запросе, и если ничего не пришло, то вызывает обработчик пустого запроса processEmptyRequest(). Если же пришло, то вызывает обработчик сессионного запроса processSessionRequest(). Обработчики должны сформировать ответ сервера ($response). Если не удалось, сервер умирает. А если удалось, то отсылает ответ в браузер.
Как работают обработчики, в деталях разберём в следующей части.
Читайте дальше: