Табло теннисного матча

Проект “Табло теннисного матча” #

Веб-приложение, реализующее табло счёта теннисного матча.

Комментарии по проекту - https://www.youtube.com/watch?v=zAOiNa24jpg.

Работу над проектом можно обсуждать в чатах:

Что нужно знать #

Мотивация проекта #

  • Написать относительно сложную бизнес логику, познакомиться с богатой доменной моделью
  • Научиться покрывать бизнес логику тестами
  • Получить практический опыт работы с ORM Hibernate

Комментарии:

  • Проект не многопользовательский, поэтому не используем сессии
  • Проект подразумевает REST API

Функционал приложения #

Работа с матчами:

  • Создание нового матча
  • Просмотр законченных матчей, поиск матчей по именам игроков
  • Подсчёт очков в текущем матче

Подсчёт очков в теннисном матче #

В теннисе особая система подсчёта очков - https://www.gotennis.ru/read/world_of_tennis/pravila.html

Для упрощения, допустим что каждый матч играется по следующим правилам:

  • Матч играется до двух сетов (best of 3)
  • При счёте 6/6 в сете, играется тай-брейк до 7 очков

База данных #

В качестве базы данных предлагаю использовать Postgres. Для разработки вам потребуется установленный локально Postgres, или запущенный через Docker.

Для практики с многопоточностью, предлагаю хранить незавершённые матчи в потокобезопасной коллекции в памяти приложения.

Таблица Players - игроки #

Имя колонкиТипКомментарий
IDIntПервичный ключ, автоинкремент
NameVarcharИмя игрока

Индексы:

  • Уникальный индекс колонки Name для эффективности поиска игроков по имени и запрета повторяющихся имён

Таблица Matches - завершенные матчи #

Для упрощения, в БД сохраняются только доигранные матчи в момент их завершения.

Имя колонкиТипКомментарий
IDIntПервичный ключ, автоинкремент
Player1IntАйди первого игрока, внешний ключ на Players.ID
Player2IntАйди второго игрока, внешний ключ на Players.ID
WinnerIntАйди победителя, внешний ключ на Players.ID

REST API #

Ответ в случае ошибки #

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

{
    "message": "Имена игроков не могут совпадать"
}

Значение message зависит от того, какая именно ошибка произошла.

Создание нового матча #

POST /matches

Тело запроса (Content-Type: application/json)

{
  "firstPlayerName": "First Player",
  "secondPlayerName": "Second Player"
}

Ответ в случае успеха: 201 Created

{
  "id": "1d5e5fb4-5203-4933-8278-486f3d8db2ca"
}

Коды ошибок:

  • 400 - ошибки валидации

Начисление очков и получение счёта #

POST /matches/{uuid}/point

Параметры пути:

  • uuid

Тело запроса (Content-Type: application/json)

{
  "name": "First Player"
}

Ответ в случае успеха: 200 OK

JSON содержит вложенные объекты firstPlayer и secondPlayer, каждый из которых описывает счёт конкретного игрока.

В обычном гейме поле tieBreakPoints равно null (или отсутствует в JSON).

{
  "firstPlayer": {
    "name": "First Player",
    "points": "40",
    "games": 2,
    "sets": 0,
    "tieBreakPoints": null
  },
  "secondPlayer": {
    "name": "Second Player",
    "points": "AD",
    "games": 3,
    "sets": 1,
    "tieBreakPoints": null
  },
  "winnerName": null
}

В тай-брейке поле points равно null (или отсутствует в JSON).

{
  "firstPlayer": {
    "name": "First Player",
    "points": null,
    "games": 6,
    "sets": 0,
    "tieBreakPoints": 5
  },
  "secondPlayer": {
    "name": "Second Player",
    "points": null,
    "games": 6,
    "sets": 0,
    "tieBreakPoints": 4
  },
  "winnerName": null
}

Коды ошибок:

  • 404 - матч с таким uuid не найден

Получение счёта #

GET /matches/{uuid}

Параметры пути:

  • uuid

Ответ в случае успеха: 200 OK

JSON содержит вложенные объекты firstPlayer и secondPlayer, каждый из которых описывает счёт конкретного игрока.

В обычном гейме поле tieBreakPoints равно null (или отсутствует в JSON).

{
  "firstPlayer": {
    "name": "First Player",
    "points": "40",
    "games": 2,
    "sets": 0,
    "tieBreakPoints": null
  },
  "secondPlayer": {
    "name": "Second Player",
    "points": "AD",
    "games": 3,
    "sets": 1,
    "tieBreakPoints": null
  },
  "winnerName": null
}

В тай-брейке поле points равно null (или отсутствует в JSON).

{
  "firstPlayer": {
    "name": "First Player",
    "points": null,
    "games": 6,
    "sets": 0,
    "tieBreakPoints": 5
  },
  "secondPlayer": {
    "name": "Second Player",
    "points": null,
    "games": 6,
    "sets": 0,
    "tieBreakPoints": 4
  },
  "winnerName": null
}

Коды ошибок:

  • 404 - матч с таким uuid не найден

Список завершённых матчей #

GET /matches

Параметры запроса:

  • page - число, необязательный
  • player_name - строка, необязательный

Ответ в случае успеха: 200 OK

{
  "matches": [
    {
      "firstPlayerName": "First Player",
      "secondPlayerName": "Second Player",
      "winnerName": "Second Player"
    },
    {
      "firstPlayerName": "First Player",
      "secondPlayerName": "Second Player",
      "winnerName": "First Player"
    }
  ],
  "currentPage": 0,
  "totalPages": 10
}

Фронтенд #

Для тестирования ваших реализаций и визуализации результата, написан фронтенд - https://github.com/zhukovsd/tennis-scoreboard-frontend.

Фронтенд представляет из себя набор статических HTML/CSS/JS файлов и состоит из четырёх веб-страниц, отвечающих за работу со всеми эндпоинтами API.

Как пользоваться:

  • Положите статические файлы фронтенда внутрь нашего проекта, чтобы index.html был доступен по корневому пути
  • Поднимите проект
  • Проверьте что браузер может загрузить JS/CSS файлы, а нажатия на кнопки на интерфейсе корректно вызывают API эндпоинты

Богатая доменная модель #

В предыдущем проекте вся архитектура без проблем вписывалась в классический MVC(S).

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

Это хороший шанс попробовать на практике богатую доменную модель - подход, в котором бизнес логика описывается напрямую в классах-моделях, привязанных к сущностям из доменной области проекта. В данном случае, такие классы могут описывать стадии теннисного матча - Match, Set, Game, и так далее.

Тесты #

Покроем юнит тестами подсчёт очков в матче. Примеры кейсов:

  • Если игрок 1 выигрывает очко при счёте 40-40, гейм не заканчивается
  • Если игрок 1 выигрывает очко при счёте 40-0, то он выигрывает и гейм
  • При счёте 6-6 начинается тайбрейк вместо обычного гейма

Предлагаю студентам самостоятельно придумать тест кейсы для покрытия всех вариантов изменения счёта в матче, особенно правила “больше-меньше” и тайбрейк. Набор тестов должен быть реализован с помощью JUnit 5.

Деплой #

Будем вручную деплоить war артефакт в Tomcat, установленный на удалённом сервере. Потребуется установить на сервер Postgres.

Шаги:

  • Локально собрать war артефакт приложения
  • В хостинг-провайдере по выбору арендовать облачный сервер на Linux
  • Установить JRE и Tomcat
  • Установить Postgres, настроить доступы. Можете воспользоваться Docker
  • Зайти в админский интерфейс Tomcat, установить собранный war артефакт

Ожидаемый результат - приложение доступно по адресу http://$server_ip:8080/$app_root_path.

План работы над приложением #

  • Классы-модели Hibernate для таблиц БД
  • Эндпоинт создания нового матча
  • Сервисы для хранения текущих матчей и подсчета очков в матче. Не обязательно сразу писать богатую доменную модель, можно сначала написать подсчёт очков “в лоб”, далее отрефакторить
  • Тесты
  • Эндпоинт счёта матча
  • Сервис для сохранения законченного матча в БД
  • Сервис поиска законченных матчей по имени игрока
  • Эндпоинт законченных матчей, поиска матчей по имени игрока
  • Деплой на удалённый сервер

Ресурсы для работы над ошибками #

Чеклист для самопроверки #

❗️Спойлеры: советую не читать этот список до того момента, пока не допишете первую самостоятельную работающую версию проекта❗️

Entity (сущности БД) #

  • Сущности позволяют создание объекта в невалидном состоянии: например, с установленным ID или без установки обязательных полей
  • Не заданы ограничения для БД проверяющие, что:
    • Игроки в матче разные
    • Победитель является одним из игроков
    • Игроки и победитель не могут быть null (@ManyToOne(optional = false) или @JoinColumn(nullable = false))
  • Нет ограничений на длину имени игрока (параметр length в аннотации @Column).
  • Используются @Data, @EqualsAndHashCode, @ToString без необходимости или без явного указания полей для них. Не нужно использовать @Data для JPA-сущностей.
  • Нет индекса для БД на поле имени игрока

Model (доменные модели) #

  • Классы являются анемичными моделями (Anemic domain model) — хранят данные, но не содержат специфичного для них поведения. Классы моделей должны инкапсулировать не только данные, но и бизнес-поведение, которое оперирует этими данными (например, вся логика подсчета очков должна находиться внутри моделей матча, сета, гейма)
  • Классы дают возможность бесконтрольно изменять своё состояние извне: имеют простые сеттеры или сеттероподобные методы вместо специализированных поведенческих методов
  • Кодирование счёта в гейме условными единицами: не 0-15-30-40-AD, а 0-1-2-3-4. Счёт в гейме не должен быть представлен простыми числами (0, 1, 2…). Стоит использовать типы, отражающие доменную логику, например, enum со значениями LOVE, FIFTEEN, THIRTY и т.д.
  • Нарушение принципа единственной ответственности (SRP). Например, когда один класс отвечает и за счёт в гейме, и за счёт в тай-брейке
  • Хранение вычисляемого состояния в полях (например, winner или isFinished). Эти значения должны вычисляться методами “на лету” на основе счёта, чтобы обеспечить единый источник истины (Single Source of Truth)
  • Смешение слоёв: использование классов JPA Entity, а не доменных моделей (или строк) для хранения игроков. Для представления игроков внутри доменной модели матча (TennisMatch) стоит использовать доменную модель игрока (например, record TennisPlayer), а не JPA-сущность Player

DTO #

  • Не используются DTO
  • DTO содержит JPA Entity. Поля DTO должны быть либо примитивами, либо строками, либо другими DTO

DAO #

  • Нет сортировки в запросах для получения выборки матчей
  • Проблема N+1 в запросах для получения выборки матчей: когда при получении списка матчей, связанные с ними сущности (игроки) получаются отдельными дополнительными запросами
  • Бизнес-логика в слое DAO. Например, расчёт смещения (offset)
  • Отсутствие параметров limit и offset, которые ограничивают размер выборки, в запросах для получения списка матчей: методы загружают сразу все матчи, существующие в БД
  • Исключения (HibernateException или PersistenceException) не перехватываются и не транслируются в специализированные для приложения (например, DataAccessException)
  • Антипаттерн “Session-per-Operation” (“сессия на операцию”): когда для каждого запроса (метода) создаётся новая сессия Hibernate

Service #

  • Использование HashMap вместо потокобезопасной ConcurrentHashMap для хранения текущих матчей
  • Логика обработки счёта находится в сервисном слое, а не в доменных моделях
  • Нарушение принципа единственной ответственности (SRP). Смешение ответственности разных сервисов в одном или наоборот дробление ответственности на разные классы
  • Передача JPA Entity или доменных моделей в контроллеры вместо DTO или простых типов
  • Race condition при обработке счёта: объект текущего матча не защищён от случаев, когда несколько потоков (запросов) пытаются обновить его счёт одновременно

Контроллеры #

  • Смешение слоёв: например, работа с JPA Entity или доменными моделями
  • Антипаттерн “Толстый контроллер” (Fat Controller): контроллер оркестрирует работу сервисов и других компонентов или содержит бизнес-логику

Другое #

  • Разные ограничения в бизнес-правилах (в валидаторе или DTO) и на уровне БД (в JPA Entity) для имён игроков
  • “Проглатывание” исключений: оригинальные исключения не передаются в конструктор исключения специализированного для приложения.
  • Экземпляры классов сервисов и DAO создаются в приложении более одного раза.
  • Инициализация БД происходит при первом обращении, а не при старте приложения.
  • Нет закрытия ресурсов (например, SessionFactory) при остановке приложения.
  • Учётные данные (секреты) для доступа к БД попали в репозиторий GitHub.
  • Недостаточное покрытие тестами основной бизнес-логики (обработка счёта).

Ревью на ваш проект #

Лучший способ получить максимум пользы от проекта - получить ревью и сделать работу над ошибками.

Делитесь ссылкой на реализованный проект в чате сообщества - https://t.me/zhukovsd_it_chat. Мы ведём коллекцию всех реализаций и ревью.

Способы получить ревью:

  • Учебная подписка гарантирует 1 ревью в месяц на ваши проекты
  • Заказать ревью у конкретного ментора сообщества, цены и условия
  • Я спонсирую ревью 10-15 проектов в месяц