Проект “Табло теннисного матча” #
Веб-приложение, реализующее табло счёта теннисного матча.
Комментарии по проекту - https://www.youtube.com/watch?v=zAOiNa24jpg.
Работу над проектом можно обсуждать в чатах:
- Основной чат сообщества - https://t.me/zhukovsd_it_chat
- Чат сообщества по работе над проектами, где каждому проекту посвящена отдельная ветка - https://t.me/zhukovsd_projects_it_chat
Что нужно знать #
- Java - коллекции, ООП
- Паттерн MVC(S)
- Maven/Gradle
- Backend
- Spring MVC
- HTTP - GET и POST запросы, формы
- Базы данных - SQL, Hibernate, Postgres
- Тесты - юнит тестирование, JUnit 5
- Деплой - облачный хостинг, командная строка Linux, Tomcat
Мотивация проекта #
- Написать относительно сложную бизнес логику, познакомиться с богатой доменной моделью
- Научиться покрывать бизнес логику тестами
- Получить практический опыт работы с ORM Hibernate
Комментарии:
- Проект не многопользовательский, поэтому не используем сессии
- Проект подразумевает REST API
Функционал приложения #
Работа с матчами:
- Создание нового матча
- Просмотр законченных матчей, поиск матчей по именам игроков
- Подсчёт очков в текущем матче
Подсчёт очков в теннисном матче #
В теннисе особая система подсчёта очков - https://www.gotennis.ru/read/world_of_tennis/pravila.html
Для упрощения, допустим что каждый матч играется по следующим правилам:
- Матч играется до двух сетов (best of 3)
- При счёте 6/6 в сете, играется тай-брейк до 7 очков
База данных #
В качестве базы данных предлагаю использовать Postgres. Для разработки вам потребуется установленный локально Postgres, или запущенный через Docker.
Для практики с многопоточностью, предлагаю хранить незавершённые матчи в потокобезопасной коллекции в памяти приложения.
Таблица Players - игроки
#
| Имя колонки | Тип | Комментарий |
|---|---|---|
| ID | Int | Первичный ключ, автоинкремент |
| Name | Varchar | Имя игрока |
Индексы:
- Уникальный индекс колонки
Nameдля эффективности поиска игроков по имени и запрета повторяющихся имён
Таблица Matches - завершенные матчи
#
Для упрощения, в БД сохраняются только доигранные матчи в момент их завершения.
| Имя колонки | Тип | Комментарий |
|---|---|---|
| ID | Int | Первичный ключ, автоинкремент |
| Player1 | Int | Айди первого игрока, внешний ключ на Players.ID |
| Player2 | Int | Айди второго игрока, внешний ключ на Players.ID |
| Winner | Int | Айди победителя, внешний ключ на 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 проектов в месяц