add tests for controllers and ActionCable channels; improve main backend docs
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
# Архитектура Main Server (Ruby on Rails)
|
||||
|
||||
Документ описывает архитектуру главного сервера проекта «Ruby Sea Battle», структуру компонентов Rails-приложения, модель безопасности и схему взаимодействия с клиентом и игровым сервером.
|
||||
|
||||
---
|
||||
|
||||
## 1. Роль Main Server в общей системе
|
||||
|
||||
Система «Ruby Sea Battle» состоит из трёх ключевых узлов:
|
||||
1. **Frontend (React):** SPA-интерфейс в браузере.
|
||||
2. **Main Server (Ruby on Rails):** центральный сервер бизнес-логики, аккаунтов и матчмейкинга.
|
||||
3. **Game Server (Go):** сервер игровых комнат в реальном времени по WebSocket.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client["React Frontend"]
|
||||
MainServer["Main Server (Rails 8)"]
|
||||
RedisStore[("Redis (Очереди и Pub/Sub)")]
|
||||
DB[(База данных SQLite/PostgreSQL)]
|
||||
GameServer["Game Server (Go)"]
|
||||
|
||||
Client -- "REST API (Auth, Profile)" --> MainServer
|
||||
Client -- "WebSocket /cable (Matchmaking)" --> MainServer
|
||||
MainServer -- "State & Lua Scripts" --> RedisStore
|
||||
MainServer -- "Пользователи и рейтинг" --> DB
|
||||
MainServer -. "JWT с общим секретом" .-> GameServer
|
||||
Client -- "WebSocket (Игровой процесс)" --> GameServer
|
||||
```
|
||||
|
||||
### Основные обязанности Main Server:
|
||||
- Регистрация и аутентификация пользователей с выдачей JWT.
|
||||
- Хранение профилей и аватарок игроков (через Active Storage).
|
||||
- Ведение базового рейтинга пользователей (`users.rating`).
|
||||
- Очередь матчмейкинга и подбор соперников в реальном времени через Action Cable и Redis.
|
||||
- Выпуск одноразовых билетов матча (`game_token`) для подключения к игровому серверу Go.
|
||||
|
||||
---
|
||||
|
||||
## 2. Структура слоёв приложения
|
||||
|
||||
Главный сервер построен на принципах Rails API-only приложения:
|
||||
|
||||
```
|
||||
app/
|
||||
├── channels/
|
||||
│ ├── application_cable/
|
||||
│ │ ├── channel.rb # Базовый класс каналов
|
||||
│ │ └── connection.rb # Аутентификация WebSocket по JWT
|
||||
│ └── matchmaking_channel.rb # Канал поиска соперника (start/stop/claim)
|
||||
├── controllers/
|
||||
│ ├── application_controller.rb # Базовый контроллер: проверка JWT и current_user
|
||||
│ └── api/
|
||||
│ ├── registrations_controller.rb # Регистрация и загрузка аватара
|
||||
│ ├── sessions_controller.rb # Вход, выход, профиль (/api/me)
|
||||
│ └── matchmaking_controller.rb # HTTP-отмена поиска
|
||||
├── jobs/
|
||||
│ ├── application_job.rb # Базовый класс фоновых задач
|
||||
│ └── matchmaking_sweep_job.rb # Периодическая проверка очереди (Solid Queue)
|
||||
├── models/
|
||||
│ ├── application_record.rb # Базовый класс моделей
|
||||
│ └── user.rb # Модель игрока (никнейм, пароль, рейтинг, аватар)
|
||||
└── services/
|
||||
├── jwt_service.rb # Кодирование и валидация JWT
|
||||
└── matchmaking_service.rb # Логика очереди и вызовы Lua в Redis
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Компоненты приложения
|
||||
|
||||
### 3.1. Контроллеры и фильтры аутентификации
|
||||
|
||||
Все защищённые маршруты наследуются от `ApplicationController`.
|
||||
- **`authenticate_request!`:** выполняет проверку токена перед каждым действием.
|
||||
- **Извлечение токена (`extract_token`):** поддерживает как заголовок `Authorization: Bearer <token>`, так и параметр строки запроса `params[:token]`.
|
||||
- При отсутствии или невалидности токена клиенту возвращается стандартизированный ответ:
|
||||
```json
|
||||
{ "error": "Unauthorized" }
|
||||
```
|
||||
со статусом `401 Unauthorized`.
|
||||
|
||||
### 3.2. Модель пользователя (`User`)
|
||||
|
||||
- Использует механизм `has_secure_password` (BCrypt).
|
||||
- **Нормализация никнейма:** автоматическая очистка крайних пробелов через `normalizes :nickname, with: ->(n) { n.strip }`.
|
||||
- **Валидации:**
|
||||
- Никнейм: обязателен, длина от 3 до 24 символов, регистронезависимая уникальность.
|
||||
- Пароль: минимум 6 символов.
|
||||
- Рейтинг: обязателен, значение по умолчанию в БД — `100`.
|
||||
- Аватар (`has_one_attached :avatar`): проверка допустимых типов файлов (`image/png`, `image/jpeg`, `image/gif`, `image/webp`).
|
||||
|
||||
### 3.3. Action Cable и WebSocket
|
||||
|
||||
- **Подключение (`ApplicationCable::Connection`):** при коннекте к `/cable?token=...` сокет проверяет подпись токена. Если токен валиден и пользователь найден в базе, сокет идентифицируется как `current_user`. В противном случае вызывается `reject_unauthorized_connection`.
|
||||
- **Канал `MatchmakingChannel`:**
|
||||
- При подписке клиент слушает индивидуальный стрим `matchmaking:<user_id>`.
|
||||
- Метод `claim` обеспечивает атомарную выдачу уже найденного матча, если клиент переподключился.
|
||||
- Действие `start` запускает подбор.
|
||||
- Действие `stop` или закрытие соединения (`unsubscribed`) отменяет поиск.
|
||||
|
||||
### 3.4. Сервисный слой
|
||||
|
||||
- **`JwtService`:**
|
||||
- Алгоритм: HMAC-SHA256 (`HS256`).
|
||||
- Секретный ключ: `Rails.application.secret_key_base`.
|
||||
- Стандартный срок действия токена авторизации: 7 дней.
|
||||
- Обработка исключений `JWT::DecodeError` и `JWT::ExpiredSignature` с возвратом `nil`.
|
||||
- **`MatchmakingService`:**
|
||||
- Инкапсулирует вызовы Redis Lua-скриптов (`PAIR_SCRIPT`, `FINALIZE_SCRIPT`, `CANCEL_SCRIPT`).
|
||||
- Выполняет создание матчей, подписание игровых токенов и бродкаст уведомлений через `MatchmakingChannel`.
|
||||
|
||||
### 3.5. Фоновые задачи (Background Processing)
|
||||
|
||||
- В качестве движка фоновых задач используется `Solid Queue` (на базе БД) с периодическим расписанием `config/recurring.yml`.
|
||||
- `MatchmakingSweepJob` запускается каждые 2 секунды (`MATCHMAKING_SWEEP_INTERVAL`) и вызывает `MatchmakingService.sweep` для постепенного расширения диапазона поиска и очистки таймаутов.
|
||||
|
||||
---
|
||||
|
||||
## 4. Модель токенов и безопасность
|
||||
|
||||
В системе используются два вида JWT:
|
||||
|
||||
| Параметр | User Auth Token | Game Match Token (`game_token`) |
|
||||
|----------|-----------------|---------------------------------|
|
||||
| **Назначение** | Авторизация запросов к REST API и Action Cable | Доступ к комнате матча на `game-server` |
|
||||
| **Payload** | `{ "user_id": 1, "exp": ... }` | `{ "match_id": "...", "player1_id": 1, "player2_id": 2, "game_server": "...", "exp": ... }` |
|
||||
| **Срок жизни** | 7 дней | `MATCHMAKING_TOKEN_TTL` (по умолчанию 120 сек) |
|
||||
| **Секрет подписи** | `SECRET_KEY_BASE` | `SECRET_KEY_BASE` (общий для Rails и Go) |
|
||||
| **Алгоритм** | HS256 | HS256 |
|
||||
|
||||
### Безопасность делегирования на Game Server:
|
||||
1. Игровой сервер Go не имеет прямого доступа к базе данных пользователей Main Server.
|
||||
2. При подключении к Game Server игрок предъявляет `game_token`.
|
||||
3. Game Server валидирует подпись токена тем же общим секретом `SECRET_KEY_BASE`, сверяет `match_id` и идентификатор подключившегося игрока (`player1_id` или `player2_id`).
|
||||
4. Токен действует всего 120 секунд, что исключает возможность повторного использования после начала игры.
|
||||
@@ -0,0 +1,227 @@
|
||||
# Алгоритм матчмейкинга (Matchmaking Algorithm)
|
||||
|
||||
Документ содержит подробное описание алгоритма подбора соперников в `Ruby Sea Battle`, структур данных Redis, математической модели допуска рейтинга, механизмов атомарности и отказоустойчивости.
|
||||
|
||||
---
|
||||
|
||||
## 1. Цели и требования к системе
|
||||
|
||||
Матчмейкинг отвечает за сведение двух игроков в онлайн-матч и решает следующие задачи:
|
||||
1. **Баланс по навыку (рейтингу):** игроки со схожим рейтингом должны играть друг с другом.
|
||||
2. **Минимизация времени ожидания:** если идеального соперника нет, допустимая разница в рейтинге со временем плавно увеличивается.
|
||||
3. **Строгая атомарность и исключение race conditions:** ни один игрок не может быть одновременно взят в два разных матча при конкурентных запросах.
|
||||
4. **Отказоустойчивость (Fault Tolerance):** сбой процесса сервера между нахождением пары и генерацией JWT-токенов не должен приводить к потере игроков из очереди.
|
||||
5. **Надёжная доставка результата:** матч должен быть доставлен игроку даже при кратковременном разрыве WebSocket-соединения.
|
||||
|
||||
---
|
||||
|
||||
## 2. Структуры данных в Redis
|
||||
|
||||
Все данные матчмейкинга хранятся в Redis и имеют ограниченное время жизни (TTL):
|
||||
|
||||
| Ключ | Тип данных Redis | Значение / Элемент | Назначение |
|
||||
|------|-------------------|-------------------|------------|
|
||||
| `mm:queue` | Sorted Set | `score` = `rating`, `member` = `user_id` | Основная очередь игроков, ожидающих матч. Отсортирована по рейтингу для быстрого диапазонного поиска (`ZRANGEBYSCORE`). |
|
||||
| `mm:since` | Hash | `field` = `user_id`, `value` = `timestamp` (мс) | Время входа игрока в очередь в миллисекундах. Используется для расчёта времени ожидания. |
|
||||
| `mm:rating` | Hash | `field` = `user_id`, `value` = `rating` | Сохранённый рейтинг игрока. Необходим для возврата игрока в очередь при сбое на этапе финализации (`requeue`). |
|
||||
| `mm:pending` | Sorted Set | `score` = `timestamp` спаривания (мс), `member` = `user_id` | Игроки, для которых пара найдена, но генерация матча и JWT ещё выполняется в Ruby. |
|
||||
| `mm:match:<match_id>` | String (JSON) | Полный payload матча | Данные матча (TTL = `MATCHMAKING_MATCH_TTL`, по умолчанию 120 сек). |
|
||||
| `mm:user:<user_id>` | String (JSON) | Копия payload матча | Индивидуальный указатель для игрока для механизма `claim` при реконнекте (TTL = `MATCHMAKING_MATCH_TTL`). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Модель динамического допуска рейтинга (Rating Tolerance)
|
||||
|
||||
Чтобы игроки быстро находили матч даже при небольшом онлайне, допуск по рейтингу плавно растёт с течением времени ожидания.
|
||||
|
||||
### Формула допуска
|
||||
|
||||
Для игрока с временем постановки в очередь $t_{\text{start}}$ в текущий момент времени $t_{\text{now}}$:
|
||||
|
||||
$$\Delta t = \frac{t_{\text{now}} - t_{\text{start}}}{1000} \quad \text{(секунды ожидания)}$$
|
||||
|
||||
$$\text{tolerance}(\Delta t) = \min\Big(\text{base\_range} + \Delta t \times \text{range\_per\_second}, \; \text{max\_range}\Big)$$
|
||||
|
||||
### Конфигурационные параметры (из `Rails.application.config.x.matchmaking`)
|
||||
|
||||
- `base_range` = `100` — начальный допуск рейтинга в момент входа в очередь.
|
||||
- `range_per_second` = `50` — прирост допуска за каждую секунду ожидания.
|
||||
- `max_range` = `1000` — верхний предел допуска рейтинга.
|
||||
- `queue_ttl` = `60` сек — максимальное время нахождения в очереди до таймаута.
|
||||
|
||||
### Динамика расширения диапазона
|
||||
|
||||
| Время ожидания ($\Delta t$) | Допуск ($\pm$ очков рейтинга) | Диапазон при рейтинге 1000 |
|
||||
|-----------------------------|-------------------------------|---------------------------|
|
||||
| 0 секунд | $\pm 100$ | [900 ... 1100] |
|
||||
| 2 секунды | $\pm 200$ | [800 ... 1200] |
|
||||
| 6 секунд | $\pm 400$ | [600 ... 1400] |
|
||||
| 10 секунд | $\pm 600$ | [400 ... 1600] |
|
||||
| 18+ секунд | $\pm 1000$ (максимум) | [0 ... 2000] |
|
||||
|
||||
### Правило симметрии для пары
|
||||
|
||||
При периодической сверке очереди (`sweep`) пара $(A, B)$ считается совместимой, если:
|
||||
|
||||
$$| \text{rating}_A - \text{rating}_B | \le \text{tolerance}\big(\min(t_{\text{since}}(A), t_{\text{since}}(B))\big)$$
|
||||
|
||||
То есть допуск рассчитывается по **дольше ожидающему** игроку из пары. Это предотвращает дискриминацию игроков, которые долго ждут в очереди.
|
||||
|
||||
---
|
||||
|
||||
## 4. Двухфазный алгоритм подбора (Two-Phase Matchmaking)
|
||||
|
||||
Подбор разделен на две взаимодополняющие фазы:
|
||||
|
||||
```
|
||||
[Игрок отправляет start]
|
||||
│
|
||||
▼
|
||||
Фаза 1: Немедленный поиск (enqueue в Lua)
|
||||
├─► Подходящий соперник найден ──► [Перевод в pending] ──► [Генерация матча]
|
||||
└─► Соперник не найден ──────────► [Добавление в mm:queue]
|
||||
│
|
||||
▼
|
||||
Фаза 2: Фоновая сверка (sweep)
|
||||
(каждые 2 сек)
|
||||
├─► Расширение допуска и спаривание
|
||||
├─► Таймаут (waited > queue_ttl)
|
||||
└─► Requeue зависших игроков
|
||||
```
|
||||
|
||||
### Фаза 1: Синхронная проверка при входе (`enqueue`)
|
||||
|
||||
Выполняется мгновенно в контексте команды WebSocket `start`:
|
||||
1. Вызывается Redis Lua-скрипт `PAIR_SCRIPT` с аргументом `mode = "enqueue"`.
|
||||
2. Из очереди принудительно удаляются предыдущие записи этого пользователя (сброс сессии ожидания).
|
||||
3. Через `ZRANGEBYSCORE mm:queue (rating - max_range) (rating + max_range)` запрашиваются потенциальные кандидаты.
|
||||
4. Среди кандидатов выбирается игрок с **минимальной разницей в рейтинге**, у которого:
|
||||
$$| \text{rating} - \text{other\_rating} | \le \text{tolerance}(\text{other\_since})$$
|
||||
5. **Если кандидат найден:**
|
||||
- Оба игрока атомарно удаляются из `mm:queue`, `mm:since`, `mm:rating`.
|
||||
- Оба игрока добавляются в `mm:pending` с текущим временем.
|
||||
- Скрипт возвращает строку вида `"P:<id1>:<id2>"`.
|
||||
6. **Если кандидат не найден:**
|
||||
- Игрок сохраняется в `mm:queue` с весом `rating`.
|
||||
- В `mm:since` записывается время входа.
|
||||
- В `mm:rating` сохраняется рейтинг игрока.
|
||||
|
||||
### Фаза 2: Периодическая фоновая сверка (`sweep`)
|
||||
|
||||
Каждые `MATCHMAKING_SWEEP_INTERVAL` секунд (по умолчанию каждые 2 секунды) запускается фоновая задача `MatchmakingSweepJob`, вызывающая `MatchmakingService.sweep`:
|
||||
|
||||
1. Вызывается Redis Lua-скрипт `PAIR_SCRIPT` с аргументом `mode = "sweep"`.
|
||||
2. Из `mm:queue` считываются все ожидающие игроки со своими рейтингами и временем ожидания.
|
||||
3. Массив сортируется по возрастанию рейтинга: $O(N \log N)$.
|
||||
4. Выполняется жадный проход:
|
||||
- Если текущий игрок ждёт дольше `queue_ttl` (60 сек): он помечается как экспирированный (`"E:<id>"`).
|
||||
- Иначе ищется ближайший по рейтингу свободный соперник в списке, для которого разница в рейтинге не превышает допуск по времени ожидания более старого игрока.
|
||||
- При нахождении оба игрока помечаются как использованные и добавляются в результат (`"P:<id1>:<id2>"`).
|
||||
5. Все найденные пары атомарно переносятся из `mm:queue` в `mm:pending`, а экспирированные игроки удаляются из очереди.
|
||||
6. **Requeue (защита от падений):**
|
||||
- Запрашиваются все игроки из `mm:pending`, находящиеся там дольше `pending_ttl` (15 сек).
|
||||
- Если для игрока ключ матча `mm:user:<uid>` так и не был создан (например, Ruby-процесс упал при генерации токена), игрок автоматически возвращается назад в `mm:queue` со своим исходным рейтингом.
|
||||
|
||||
---
|
||||
|
||||
## 5. Создание матча и атомарная финализация
|
||||
|
||||
Когда Lua-скрипт возвращает сформированную пару `"P:<id1>:<id2>"`:
|
||||
|
||||
1. **Генерация идентификаторов и токенов в Ruby:**
|
||||
- Генерируется уникальный UUID матча: `match_id = SecureRandom.uuid`.
|
||||
- Генерируется JWT-токен для `game-server`:
|
||||
```ruby
|
||||
JwtService.encode(
|
||||
match_id: match_id,
|
||||
player1_id: player1_id,
|
||||
player2_id: player2_id,
|
||||
game_server: config.game_server_url,
|
||||
exp: config.token_ttl.seconds.from_now.to_i
|
||||
)
|
||||
```
|
||||
2. **Атомарная финализация в Redis (`FINALIZE_SCRIPT`):**
|
||||
- Сохраняет `mm:match:<match_id>` с TTL.
|
||||
- Сохраняет указатели `mm:user:<player1_id>` и `mm:user:<player2_id>` с тем же TTL.
|
||||
- Удаляет обоих игроков из `mm:pending`, `mm:rating`, `mm:since`.
|
||||
3. **Push-уведомление через Action Cable:**
|
||||
- Для каждого игрока выполняется атомарное чтение и удаление указателя: `redis.getdel("mm:user:<id>")`.
|
||||
- Если указатель успешно прочитан, сообщение с типом `matched` отправляется в персональный поток пользователя:
|
||||
`MatchmakingChannel.broadcast_to(user_id, payload)`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Гарантия доставки (At-most-once & Reconnection)
|
||||
|
||||
Если в момент формирования матча у пользователя произошёл кратковременный разрыв соединения или переподключение:
|
||||
- Сообщение broadcast могло быть отправлено в момент отсутствия активного WebSocket-клиента.
|
||||
- Персональный ключ `mm:user:<user_id>` сохраняется в Redis до момента подтверждённой доставки.
|
||||
- При повторном подключении к WebSocket метод `MatchmakingChannel#subscribed` вызывает:
|
||||
```ruby
|
||||
if match = MatchmakingService.claim(current_user.id)
|
||||
transmit(match)
|
||||
end
|
||||
```
|
||||
- Метод `claim` выполняет Redis-команду `GETDEL mm:user:<user_id>`, что гарантирует:
|
||||
1. Матч будет немедленно доставлен клиенту при переподключении.
|
||||
2. Никакой матч не может быть получен дважды (атомарное удаление при первой же выдаче).
|
||||
|
||||
---
|
||||
|
||||
## 7. Диаграммы взаимодействия
|
||||
|
||||
### Sequence Diagram: Успешный подбор матча
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor PlayerA as Игрок A
|
||||
actor PlayerB as Игрок B
|
||||
participant AC as ActionCable (MatchmakingChannel)
|
||||
participant MS as MatchmakingService
|
||||
participant Redis as Redis (Lua Scripts)
|
||||
participant Game as Game Server (Go)
|
||||
|
||||
PlayerA->>AC: subscribe
|
||||
PlayerA->>AC: action: "start"
|
||||
AC->>MS: enqueue(PlayerA)
|
||||
MS->>Redis: eval PAIR_SCRIPT (enqueue)
|
||||
Redis-->>MS: соперник не найден -> добавлен в mm:queue
|
||||
|
||||
PlayerB->>AC: subscribe
|
||||
PlayerB->>AC: action: "start"
|
||||
AC->>MS: enqueue(PlayerB)
|
||||
MS->>Redis: eval PAIR_SCRIPT (enqueue)
|
||||
Redis-->>MS: пара сформирована! (P:PlayerB:PlayerA)
|
||||
|
||||
MS->>MS: генерация match_id (UUID) и game_token (JWT)
|
||||
MS->>Redis: eval FINALIZE_SCRIPT (сохранить матч, перевести из pending)
|
||||
MS->>Redis: getdel mm:user:PlayerA & mm:user:PlayerB
|
||||
MS->>AC: broadcast_to(PlayerA, matched)
|
||||
MS->>AC: broadcast_to(PlayerB, matched)
|
||||
AC-->>PlayerA: message { type: "matched", match_id, game_token, game_server }
|
||||
AC-->>PlayerB: message { type: "matched", match_id, game_token, game_server }
|
||||
|
||||
PlayerA->>Game: WebSocket connect(game_token)
|
||||
PlayerB->>Game: WebSocket connect(game_token)
|
||||
```
|
||||
|
||||
### State Diagram: Жизненный цикл игрока в матчмейкинге
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Idle: Пользователь авторизован
|
||||
|
||||
Idle --> Queued: Отправка action "start" (enqueue)
|
||||
Queued --> Pending: Найден соперник в Phase 1 или Phase 2
|
||||
Queued --> Idle: Отправка action "stop" / разрыв сокета (cancel)
|
||||
Queued --> TimedOut: Ожидание > MATCHMAKING_QUEUE_TTL
|
||||
|
||||
TimedOut --> Idle: Уведомление { type: "timeout" }
|
||||
|
||||
Pending --> Matched: Успешная финализация (генерация токена)
|
||||
Pending --> Queued: Сбой финализации (requeue через 15 сек)
|
||||
|
||||
Matched --> InGame: Подключение к Game Server по game_token
|
||||
InGame --> Idle: Завершение игры
|
||||
```
|
||||
Reference in New Issue
Block a user