- Прозоренко К.В ИП-312
Учебный проект — backend микросервисного приложения для заказа такси на Java 25 / Spring Boot 4. Реализованы три самостоятельных сервиса, разделённых по доменам, с асинхронной очередью уведомлений и атомарным назначением водителей при конкурентных запросах.
- Архитектура
- Стек технологий
- Структура репозитория
- Быстрый старт
- Сервисы и их API
- Ключевые инженерные решения
- База данных
- Конфигурация
- Тестирование
┌──────────────┐
│ Client │
└──────┬───────┘
│ HTTP + JWT
┌─────────────────────┼─────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼─────────┐ ┌───────▼──────────────┐
│ user-service │ │ trip-service │ │ notification-service │
│ :8081 │◄──┤ :8082 ├──►│ :8083 │
│ passenger/driver│ │ trip lifecycle │ │ worker-pool queue │
│ JWT issuer + DB │ │ pricing, stats │ │ (4 потока) │
│ Redis cache │ │ scheduler (TTL) │ │ retries + DLQ │
└───────┬────────┘ └────────┬─────────┘ └──────────┬───────────┘
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────────────────┐
│ PostgreSQL 16 │
│ passengers • drivers • trips • notification_tasks │
└────────────────────────────────────────────────────────────────┘
│
▼ (используется только user-service)
┌─────────────┐
│ Redis 7 │
│ cache TTL │
└─────────────┘
Flow одного заказа:
- Клиент логинится в
user-service→ получает JWT. - Клиент шлёт
POST /tripsвtrip-service. trip-serviceатомарно резервирует свободного водителя черезuser-service(SELECT FOR UPDATE SKIP LOCKED).- Создаётся запись поездки со статусом
ASSIGNED. trip-serviceставит две задачи уведомления (DRIVER + PASSENGER) вnotification-service.- Пул из 4 воркеров параллельно тянет задачи из очереди и доставляет их.
- Если водитель не подтвердил поездку за TTL (60с) — фоновый скедулер
trip-serviceотменяет поездку и возвращает водителя вAVAILABLE.
| Слой | Технология |
|---|---|
| Язык | Java 25 |
| Фреймворк | Spring Boot 4.0 |
| Сборка | Gradle 9 (Kotlin/Groovy DSL) |
| Контейнеризация | Docker + Docker Compose |
| Хранилище | PostgreSQL 16 |
| Кэш | Redis 7 |
| Миграции БД | Flyway |
spring-boot-starter-webmvc— REST APIspring-boot-starter-data-jpa+ Hibernatespring-boot-starter-security+ JWTspring-boot-starter-validation— Bean Validationspring-boot-starter-actuator— health, metricsspring-boot-starter-cache+ Redis (только вuser-service)@Scheduled— фоновые задачи (TTL отмены поездок)
- OpenAPI 3.1 — single source of truth для API (openapi/)
openapi-generator-gradle-plugin7.10 — генерирует интерфейсы контроллеров и DTO из YAMLspringdoc-openapi-starter-webmvc-ui2.7 — рантайм Swagger UI
- Spring HTTP Interface (
@HttpExchange) — декларативные HTTP-клиенты наRestClient - Resilience4j 2.2 — Retry (3 попытки, exponential backoff) + Circuit Breaker (50% failure rate, sliding window 10)
- JJWT 0.12 — выпуск и парсинг JWT (HS256, общий
JWT_SECRETмежду сервисами) - Stateless — никаких сессий, токен живёт 1 час
- Системный токен для фоновых задач (роль
ADMIN) — выпускается trip-service'ом для вызова user-service из@Scheduled
- Lombok — boilerplate (геттеры, конструкторы, билдеры)
- MapStruct 1.6 — типобезопасный маппинг между DTO и Entity
- Jackson — сериализация (с
jackson-databind-nullableдля OpenAPI nullable)
- JUnit 5 + Spring Boot Test
- Testcontainers 1.20 — реальные PostgreSQL и Redis в Docker для интеграционных тестов
- MockMvc + spring-security-test — слой контроллеров
taxi-platform/
├── docker-compose.yml # вся инфра + три сервиса
├── build.gradle # общие зависимости (Spring, OpenAPI, Lombok, MapStruct)
├── settings.gradle # multi-module конфиг
│
├── openapi/ # ← API-first: источник истины
│ ├── user-service.yaml
│ ├── trip-service.yaml
│ └── notification-service.yaml
│
├── user-service/ # :8081 — passengers, drivers, auth
│ ├── Dockerfile
│ ├── build.gradle
│ └── src/
│ ├── main/java/com/taxi/user/
│ │ ├── controller/ # реализации сгенерированных интерфейсов
│ │ ├── service/ # бизнес-логика
│ │ ├── repository/ # Spring Data JPA + custom queries
│ │ ├── entity/ # JPA Entity
│ │ ├── mapper/ # MapStruct
│ │ ├── security/ # JWT
│ │ └── config/
│ └── main/resources/
│ ├── application.yml
│ └── db/migration/ # Flyway: V1, V2…
│
├── trip-service/ # :8082 — trips lifecycle
│ └── src/main/java/com/taxi/trip/
│ ├── controller/
│ ├── service/ # TripService, UserGateway, NotificationGateway
│ ├── client/ # HTTP-клиенты (Spring HTTP Interface)
│ ├── scheduler/ # StuckTripCleanupScheduler (TTL)
│ ├── repository/
│ ├── entity/
│ ├── exception/
│ └── ...
│
└── notification-service/ # :8083 — async queue + worker pool
└── src/main/java/com/taxi/notification/
├── controller/
├── service/ # NotificationService, DeliveryGateway
├── worker/ # WorkerPool, TaskClaimer
├── repository/
├── entity/
└── ...
Сгенерированный из OpenAPI код кладётся в
*/build/generated/и в Git не коммитится. Генерация запускается автоматически передcompileJava.
- Docker + Docker Compose
- (для локальной разработки) JDK 21+ и Gradle 8+
# Поднять весь стек
docker compose up -d
# Логи всех сервисов
docker compose logs -f
# Логи конкретного сервиса
docker compose logs -f trip-serviceПосле старта (около 30–60 секунд) доступны:
| Что | Где |
|---|---|
| User Service | http://localhost:8081 |
| Trip Service | http://localhost:8082 |
| Notification Service | http://localhost:8083 |
| Swagger UI (User) | http://localhost:8081/swagger-ui.html |
| Swagger UI (Trip) | http://localhost:8082/swagger-ui.html |
| Swagger UI (Notification) | http://localhost:8083/swagger-ui.html |
| Health-чек (любой сервис) | :PORT/actuator/health |
# 1. Логин (admin засеян миграцией)
TOKEN=$(curl -s -X POST http://localhost:8081/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@taxi.com","password":"secret"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# 2. Зарегистрировать водителя
curl -X POST http://localhost:8081/drivers \
-H "Content-Type: application/json" \
-d '{"name":"Пётр","email":"p@m.ru","phone":"+79001112233","license_number":"L001","password":"secret123"}'
# 3. Создать поездку
curl -X POST http://localhost:8082/trips \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"passenger_id":1,"origin":"ул. Ленина, 1","destination":"пр. Мира, 20","distance_km":12.5}'./gradlew build # все сервисы
./gradlew :trip-service:test # тесты одного сервиса
./gradlew :user-service:bootRun # запустить локально
./gradlew openApiGenerate # обновить сгенерированный код после правки openapi/| Метод | Путь | Описание |
|---|---|---|
| POST | /auth/login |
Получить JWT |
| POST | /passengers |
Регистрация пассажира |
| GET | /passengers/{id} |
Профиль пассажира |
| POST | /drivers |
Регистрация водителя |
| GET | /drivers/{id} |
Профиль водителя |
| PATCH | /drivers/{id}/status |
Сменить статус водителя |
| GET | /drivers/available |
Список свободных (кэш Redis 30с) |
| POST | /drivers/assign |
Атомарно зарезервировать водителя (used by trip-service) |
Статусы водителя: AVAILABLE ↔ BUSY / OFFLINE.
| Метод | Путь | Описание |
|---|---|---|
| POST | /trips |
Создать поездку (поддерживает Idempotency-Key) |
| GET | /trips/{id} |
Получить поездку |
| GET | /trips |
Список с фильтрами (?passenger_id=…&status=…) |
| PATCH | /trips/{id}/status |
Сменить статус |
| POST | /trips/{id}/rating |
Оценка (1–5, только для COMPLETED) |
| GET | /statistics |
Аналитика за день |
Стейт-машина поездки:
SEARCHING ─► ASSIGNED ─► ACCEPTED ─► IN_PROGRESS ─► COMPLETED
│ │ │ │
└───────────┴───────────┴─────────────┴────► CANCELLED
Расчёт цены: price = distance_km × tariff_rate (по умолчанию tariff_rate=25.0).
| Метод | Путь | Описание |
|---|---|---|
| POST | /notifications |
Поставить задачу в очередь |
| GET | /notifications |
Список (?trip_id=…&status=…&recipient_id=…) |
| GET | /notifications/{id} |
Одна задача |
Жизненный цикл задачи:
PENDING ─► IN_PROGRESS ─► SENT
└► PENDING (retry, attempts++)
└► DEAD (после 3 неудач)
Используется PostgreSQL SELECT … FOR UPDATE SKIP LOCKED — Postgres блокирует строку для одного запроса и пропускает её для конкурирующих, не заставляя их ждать. При 10 параллельных POST /trips каждый получает уникального водителя или 409 — без шанса double-booking. См. DriverRepository.lockOneAvailable().
notification_tasks — обычная таблица. Пул из 4 воркеров поллит её через тот же SELECT FOR UPDATE SKIP LOCKED. Гарантии at-least-once обеспечены транзакциями Postgres. Захват и доставка идут в разных транзакциях (REQUIRES_NEW), чтобы не держать row-lock на время медленной доставки. См. WorkerPool и TaskClaimer.
Брокер не используется специально: один потребитель, нет потоковой обработки, нет нужды в нескольких consumer-группах. Postgres даёт те же гарантии без отдельной инфраструктуры.
Защита от разрывов связи у клиента:
- Idempotency-Key (
POST /trips): повторный запрос с тем же ключом возвращает уже созданную поездку, а не создаёт дубль. Уникальный частичный индекс(passenger_id, idempotency_key). - TTL 60 с на ASSIGNED: фоновый скедулер каждые 30с ищет поездки, висящие в
ASSIGNEDслишком долго (водитель не подтвердил / пассажир пропал), отменяет их и возвращает водителя вAVAILABLE. Порядок операций важен: сначала освобождение водителя через user-service, только потом смена статуса поездки. Если HTTP-вызов упал — поездка остаётся вASSIGNED, скедулер повторит на следующем тике. См.StuckTripCleanupSchedulerиTripService.cancelStuckAssigned().
Все вызовы между сервисами завёрнуты в @Retry (3 попытки, экспоненциальный backoff 500ms × 2^n) и @CircuitBreaker (открывается при 50% ошибок в окне из 10 запросов, restore через 10с). Уведомления — best-effort: ошибка enqueue не откатывает создание поездки.
HttpClientsConfig через RestClient interceptor пробрасывает Authorization входящего запроса в межсервисные вызовы — вышестоящий сервис видит того же пользователя. Для фоновых задач (нет HTTP-контекста) JwtService.issueSystem() выпускает короткоживущий (5 минут) системный токен с ролью ADMIN.
Контракт описан в openapi/*.yaml. Перед каждой компиляцией openapi-generator создаёт интерфейсы контроллеров (*Api.java) и DTO. Контроллеры реализуют интерфейсы — Java-компилятор гарантирует, что код не разойдётся со спецификацией. Swagger UI доступен в браузере и подхватывает спецификацию автоматически.
passengers(user-service) —id, name, email, phone, password_hash, created_atdrivers(user-service) —id, name, email, phone, license_number, password_hash, status, created_attrips(trip-service) —id, passenger_id, driver_id, status, origin, destination, distance_km, price, rating, idempotency_key, created_at, updated_atnotification_tasks(notification-service) —id, trip_id, recipient_type, recipient_id, message, status, attempts, last_error, created_at, updated_at
Все три сервиса делят одну базу данных
taxi, но каждый управляет своими таблицами и своим Flyway-history (flyway_schema_history_user/trip/notification). При желании каждый сервис можно вынести в отдельную БД — миграции писать переписать не придётся.
Flyway применяет файлы из src/main/resources/db/migration/V*__*.sql при старте каждого сервиса. Файлы Vn__seed_…sql содержат базовые данные (например, admin@taxi.com).
Все параметры читаются через application.yml с переопределением через переменные окружения. Основные:
| Переменная | Дефолт | Что |
|---|---|---|
POSTGRES_HOST |
localhost |
PostgreSQL host |
POSTGRES_PORT |
5432 |
PostgreSQL port |
POSTGRES_DB / _USER / _PASSWORD |
taxi / taxi_user / taxi_password |
Кред-ы БД |
REDIS_HOST / REDIS_PORT |
localhost / 6379 |
Redis |
JWT_SECRET |
(dev-default) | 256-битный секрет, обязан совпадать у всех сервисов |
JWT_EXPIRATION_MS |
3600000 |
Время жизни access-токена (1ч) |
USER_SERVICE_URL |
http://localhost:8081 |
base URL для trip-service |
NOTIFICATION_SERVICE_URL |
http://localhost:8083 |
base URL для trip-service |
TRIP_TARIFF_RATE |
25.0 |
Цена за км |
TRIP_ASSIGNED_TTL_SECONDS |
60 |
TTL зависшей ASSIGNED поездки |
TRIP_ASSIGNED_CLEANUP_INTERVAL_MS |
30000 |
Интервал работы скедулера |
NOTIFICATION_WORKER_POOL_SIZE |
4 |
Кол-во воркеров |
NOTIFICATION_WORKER_POLL_INTERVAL_MS |
1000 |
Пауза при пустой очереди |
NOTIFICATION_MAX_ATTEMPTS |
3 |
После — задача в DEAD |
./gradlew test # все
./gradlew :trip-service:test # один сервис
./gradlew test jacocoTestReport # с покрытиемИнтеграционные тесты используют Testcontainers — поднимают реальные Postgres и Redis в Docker. Никаких H2/in-memory моков, тесты гоняют те же миграции, что и прод.
MIT