Проект “Табло теннисного матча” #
Веб-приложение, реализующее табло счёта теннисного матча.
Комментарии по проекту - 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
- Java Servlets, JSP
- HTTP - GET и POST запросы, формы
- Базы данных - SQL, Hibernate, H2 (in-memory SQL database)
- [Опционально] Frontend - HTML/CSS, блочная вёрстка
- Тесты - юнит тестирование, JUnit 5
- Деплой - облачный хостинг, командная строка Linux, Tomcat
Мотивация проекта #
- Создать клиент-серверное приложение с веб-интерфейсом
- Получить практический опыт работы с ORM Hibernate
- Сверстать простой веб-интерфейс без сторонних библиотек
- Познакомиться с архитектурным паттерном MVC(S)
Комментарии:
- Проект не подразумевает фреймворки, ради практики с паттерном MVC, Spring Boot начнется с проекта #6
- Не используем Bootstrap, для практики верстки вручную, Bootstrap можно будет использовать в проекте #5
- Проект не многопользовательский, поэтому не используем сессии
Функционал приложения #
Работа с матчами:
- Создание нового матча
- Просмотр законченных матчей, поиск матчей по именам игроков
- Подсчёт очков в текущем матче
Подсчёт очков в теннисном матче #
В теннисе особая система подсчёта очков - https://www.gotennis.ru/read/world_of_tennis/pravila.html
Для упрощения, допустим что каждый матч играется по следующим правилам:
- Матч играется до двух сетов (best of 3)
- При счёте 6/6 в сете, играется тай-брейк до 7 очков
Интерфейс приложения #
Главная страница #
- Ссылки, ведущие на страницы нового матча и списка завершенных матчей
Страница нового матча #
Адрес - /new-match.
Интерфейс:
- HTML форма с полями “Имя игрока 1”, “Имя игрока 2” и кнопкой “начать”. Для упрощения допустим, что имена игроков уникальны. Игрок не может играть сам с собой.
- Нажатие кнопки “начать” приводить к POST запросу по адресу
/new-match
Обработчик POST запроса:
- Проверяет существование игроков в таблице
Players. Если игрока с таким именем не существует, создаём - Создаём экземпляр класса, содержащего айди игроков и текущий счёт, и кладём в коллекцию текущих матчей (существующую только в памяти приложения, либо в key-value storage). Ключом коллекции является UUID, значением - счёт в матче
- Редирект на страницу
/match-score?uuid=$match_id
Страница счёта матча - /match-score
#
Адрес - /match-score?uuid=$match_id. GET параметр uuid содержит UUID матча.
Интерфейс:
- Таблица с именами игроков, текущим счётом
- Формы и кнопки для действий - “игрок 1 выиграл текущее очко”, “игрок 2 выиграл текущее очко”
- Нажатие кнопок приводит к POST запросу по адресу
/match-score?uuid=$match_id, в полях отправленной формы содержится айди выигравшего очко игрока
Обработчик POST запроса:
- Извлекает из коллекции экземпляр класса Match
- В соответствии с тем, какой игрок выиграл очко, обновляет счёт матча
- Если матч не закончился - рендерится таблица счёта матча с кнопками, описанными выше
- Если матч закончился:
- Удаляем матч из коллекции текущих матчей
- Записываем законченный матч в SQL базу данных
- Рендерим финальный счёт
Страница сыгранных матчей - /matches
#
Адрес - /matches?page=$page_number&filter_by_player_name=$player_name. GET параметры:
page- номер страницы. Если параметр не задан, подразумевается первая страницаfilter_by_player_name- имя игрока, матчи которого ищем. Если параметр не задан, отображаются все матчи
Постранично отображает список сыгранных матчей. Позволяет искать матчи игрока по его имени. Для постраничного отображения потребуется реализация пагинации.
Интерфейс:
- Форма с фильтром по имени игрока. Поле ввода для имени и кнопка “искать”. По нажатию формируется GET запрос вида
/matches?filter_by_player_name=${NAME} - Список найденных матчей
- Переключатель страниц, если матчей найдено больше, чем влезает на одну страницу
Фронтенд #
Я считаю, что уметь делать простой фронтенд с нуля - универсально полезный навык для всех разработчиков. Однако, в условиях дефицита времени это может быть нерационально.
Поэтому, для проекта существует готовая верстка, которую можно взять за основу. Если хотите расширить функционал проекта или сделать верстку с нуля - приветствую желание самостоятельно это сделать.
Репозиторий с версткой - https://github.com/zhukovsd/tennis-scoreboard-html-layouts.
Что внутри - макеты четырёх страниц, адаптивная верстка (десктопы, телефоны). Минимальный Javascript для показа меню навигации на телефонах.
Задеплоенные на GitHub Pages страницы для демонстрации:
Как пользоваться:
- Перенести в проект нужные для веб-страниц ресурсы - CSS, картинки
- На основе HTML верстки создать шаблонизированные страницы с добавлением тегов JSP
База данных #
В качестве базы данных предлагаю использовать H2. Это in-memory SQL база для Java. In-memory означает то, что движок БД и сами таблицы существуют только внутри памяти Java приложения. При использовании in-memory хранилища необходимо инициализировать таблицы базы данных при каждом старте приложения.
Таблица Players - игроки
#
| Имя колонки | Тип | Комментарий |
|---|---|---|
| ID | Int | Первичный ключ, автоинкремент |
| Name | Varchar | Имя игрока |
Индексы:
- Уникальный индекс колонки
Nameдля эффективности поиска игроков по имени и запрета повторяющихся имён
Таблица Matches - завершенные матчи
#
Для упрощения, в БД сохраняются только доигранные матчи в момент их завершения.
| Имя колонки | Тип | Комментарий |
|---|---|---|
| ID | Int | Первичный ключ, автоинкремент |
| Player1 | Int | Айди первого игрока, внешний ключ на Players.ID |
| Player2 | Int | Айди второго игрока, внешний ключ на Players.ID |
| Winner | Int | Айди победителя, внешний ключ на Players.ID |
MVCS #
MVCS - архитектурный паттерн, особенно хорошо подходящий под реализацию подобных приложений. Я подразумеваю, что студент самостоятельно изучил что такое MVCS, и ниже приведу только пример того, как он может быть использован в данном проекте.
Учёт счёта матча #
Пример (именование классов и сервисов на мой вкус):
MatchScoreController:
- Обрабатывает POST запросы к
/match-score - Через
OngoingMatchesServiceполучает экземпляр классаMatchдля текущего матча, который является моделью/частью моделиMatchScoreModel - Через
MatchScoreCalculationServiceобновляет счёт в матче - Если матч закончился - через
FinishedMatchesPersistenceServiceсохраняет законченный матч в базу данных - С помощью JSP шаблона отображает
MatchScoreModelв виде отрендеренного HTML
Каждый из упомянутых сервисов делает конкретную работу:
OngoingMatchesServiceхранит текущие матчи и позволяет их записывать/читатьMatchScoreCalculationServiceреализует логику подсчёта счёта матча по очкам/геймам/сетамFinishedMatchesPersistenceServiceинкапсулирует чтение и запись законченных матчей в БД
Тесты #
Покроем юнит тестами подсчёт очков в матче. Примеры кейсов:
- Если игрок 1 выигрывает очко при счёте 40-40, гейм не заканчивается
- Если игрок 1 выигрывает очко при счёте 40-0, то он выигрывает и гейм
- При счёте 6-6 начинается тайбрейк вместо обычного гейма
Предлагаю студентам самостоятельно придумать тест кейсы для покрытия всех вариантов изменения счёта в матче, особенно правила “больше-меньше” и тайбрейк. Набор тестов должен быть реализован с помощью JUnit 5.
Деплой #
Будем вручную деплоить war артефакт в Tomcat, установленный на удалённом сервере. При использовании базы данных H2, установка внешней SQL БД не требуется.
Шаги:
- Локально собрать war артефакт приложения
- В хостинг-провайдере по выбору арендовать облачный сервер на Linux
- Установить JRE и Tomcat
- Зайти в админский интерфейс 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 при обработке счёта: объект текущего матча не защищён от случаев, когда несколько потоков (запросов) пытаются обновить его счёт одновременно
Servlet #
- Смешение слоёв: например, обращение из сервлета к классам DAO, работа с JPA Entity или доменными моделями
- Антипаттерн “Толстый контроллер” (Fat Controller): сервлет оркестрирует работу сервисов и других компонентов или содержит бизнес-логику
- Передача во View объектов JPA Entity
- Оригинальное сообщение из исключения передаётся во View:
req.setAttribute("errorMessage", e.getMessage()); - Редирект происходит без учёта контекстного пути.
JSP #
- Страницы JSP открыты для прямого доступа и не лежат в
/WEB-INF - В пагинации на странице завершённых матчей отображаются сразу все страницы вместо ограниченного диапазона страниц вокруг текущей (например,
<< 1 ... 4 5 [6] 7 8 ... 1000 >>)
Другое #
- Разные ограничения в бизнес-правилах (в валидаторе или DTO) и на уровне БД (в JPA Entity) для имён игроков
- “Проглатывание” исключений: оригинальные исключения не передаются в конструктор исключения специализированного для приложения.
- Экземпляры классов сервисов и DAO создаются в приложении более одного раза.
- Инициализация БД происходит при первом обращении, а не при старте приложения.
- Нет закрытия ресурсов (например,
SessionFactory) при остановке приложения. - Учётные данные (секреты) для доступа к БД попали в репозиторий GitHub.
- Недостаточное покрытие тестами основной бизнес-логики (обработка счёта).
Ревью на ваш проект #
Лучший способ получить максимум пользы от проекта - получить ревью и сделать работу над ошибками.
Делитесь ссылкой на реализованный проект в чате сообщества - https://t.me/zhukovsd_it_chat. Мы ведём коллекцию всех реализаций и ревью.
Способы получить ревью:
- Учебная подписка гарантирует 1 ревью в месяц на ваши проекты
- Заказать ревью у конкретного ментора сообщества, цены и условия
- Я спонсирую ревью 10-15 проектов в месяц