Добавить в проект доставки еды из ЛР-4: валидацию входных данных (@Valid), единую обработку ошибок через @RestControllerAdvice с иерархией кастомных исключений и структурированное логгирование ключевых событий.
Ссылку на PR в ваш репозиторий (шаблон у вас есть).
Сейчас в вашем проекте ошибки возвращаются как попало: Spring сам формирует ответ с трейсом, статусы непредсказуемы, клиент не знает, чего ожидать.
Пример того, что Spring вернёт по умолчанию при необработанном исключении:
{
"timestamp": "2025-03-07T12:00:00.000+00:00",
"status": 500,
"error": "Internal Server Error",
"trace": "java.lang.RuntimeException: Something went wrong\n\tat com.example...",
"path": "/api/v1/restaurants"
}Проблемы:
- Клиент видит внутренности сервера (
trace) — это небезопасно. - Формат меняется от ошибки к ошибке.
- Нет полезной информации о том, что именно пошло не так.
Цель — сделать так, чтобы API всегда возвращал предсказуемый формат ошибки с правильным HTTP-статусом.
Определим DTO для ошибок. Базовый класс — ErrorResponse, для ошибок валидации — наследник с деталями по полям:
open class ErrorResponse(
val status: Int,
val message: String? = null,
val timestamp: LocalDateTime = LocalDateTime.now()
)
class ValidationErrorResponse(
status: Int,
message: String? = null,
val errors: Map<String, String>,
timestamp: LocalDateTime = LocalDateTime.now()
) : ErrorResponse(status, message, timestamp)
data classнельзя наследовать от другогоdata class, поэтому используем обычные классы сopen.
Пример ответа при ошибке валидации:
{
"status": 400,
"message": "Ошибка валидации",
"errors": {
"name": "Название не может быть пустым",
"price": "Цена должна быть больше 0"
},
"timestamp": "2025-03-07T12:00:00"
}У приложения должна быть собственная надстройка исключений над системными. Бизнес-логика не должна бросать голые Spring/JPA-исключения — она бросает свои, а @ControllerAdvice маппит их на HTTP-статусы.
Удобный подход — sealed class:
sealed class AppException(message: String) : RuntimeException(message)
class NotFoundException(message: String) : AppException(message)
class AlreadyExistsException(message: String) : AppException(message)
class InvalidOrderStateException(message: String) : AppException(message)Преимущества sealed class:
- Компилятор Kotlin гарантирует, что
when-выражение покрывает все варианты. - Иерархия закрыта — нельзя случайно добавить наследника в другом модуле.
- Каждый тип исключения несёт семантику, а не просто сообщение.
Использование в сервисном слое:
@Service
class RestaurantService(
private val restaurantRepository: RestaurantRepositoryPort
) {
fun getById(id: Long): Restaurant {
return restaurantRepository.findById(id)
?: throw NotFoundException("Ресторан с id=$id не найден")
}
fun create(command: CreateRestaurantCommand): Restaurant {
if (restaurantRepository.existsByName(command.name)) {
throw AlreadyExistsException("Ресторан '${command.name}' уже существует")
}
return restaurantRepository.save(command.toEntity())
}
}Обратите внимание: сервис не знает про HTTP-статусы. Он бросает доменное исключение, а маппинг на
404/409происходит в@ControllerAdvice.
@RestControllerAdvice — это специальный бин Spring, который перехватывает исключения, выброшенные из контроллеров, и формирует ответ.
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(AppException::class)
fun handleCommon(e: AppException): ResponseEntity<ErrorResponse> {
val status = when (e) {
is NotFoundException -> status = HttpStatus.NOT_FOUND
is AlreadyExistsException -> status = HttpStatus.CONFLICT
is InvalidOrderStateException,
is BadCredentialsException -> status = Http.BAD_REQUEST
// ...
}
return ResponseEntity
.status(status)
.body(ErrorResponse(status.value(), e.message))
}
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity<ValidationErrorResponse> {
val errors = ex.bindingResult.fieldErrors.associate {
it.field to (it.defaultMessage ?: "Incorrect value")
}
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ValidationErrorResponse(
HttpStatus.BAD_REQUEST,
"Method parameter validation error",
errors
))
}
@ExceptionHandler(Exception::class)
fun handleUnexpected(e: Exception): ResponseEntity<ErrorResponse> {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse(500, "Internal server error"))
}
}Порядок обработки: Spring ищет наиболее конкретный обработчик. Exception::class — это fallback, он сработает только если ни один другой не подошёл.
Важно: в
handleUnexpectedне возвращаемe.messageклиенту — оно может содержать внутренности системы. Вместо этого логируем полную ошибку (см. раздел про логгирование).
Валидация — это проверка данных на входе в контроллер, до того как они попадут в сервисный слой.
Зависимость в pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>data class CreateRestaurantRequest(
@field:NotBlank(message = "Название не может быть пустым")
@field:Size(min = 2, max = 100, message = "Название: от 2 до 100 символов")
val name: String,
@field:NotBlank(message = "Адрес не может быть пустым")
val address: String
)
data class CreateDishRequest(
@field:NotBlank(message = "Название не может быть пустым")
val name: String,
@field:Min(value = 1, message = "Цена должна быть больше 0")
val price: BigDecimal,
val description: String? = null
)
data class CreateOrderRequest(
@field:NotNull(message = "userId обязателен")
val userId: Long,
@field:NotEmpty(message = "Заказ должен содержать хотя бы одно блюдо")
val dishIds: List<Long>
)Обратите внимание: в Kotlin нужно писать
@field:NotBlank, а не просто@NotBlank. Без@field:аннотация попадёт на параметр конструктора, а не на поле, и Spring её не увидит.
@Valid (jakarta) и @Validated (Spring) — две аннотации для включения валидации. Они похожи, но работают по-разному.
@Valid — ставится перед @RequestBody. Проверяет поля объекта:
@PostMapping
fun createRestaurant(@Valid @RequestBody request: CreateRestaurantRequest): ResponseEntity<RestaurantResponse> {
// Если валидация не пройдена, Spring выбросит MethodArgumentNotValidException
// до входа в тело метода. Его поймает наш GlobalExceptionHandler.
val restaurant = restaurantService.create(request.toCommand())
return ResponseEntity.status(HttpStatus.CREATED).body(restaurant.toResponse())
}@Validated — ставится на класс контроллера. Позволяет валидировать @PathVariable и @RequestParam напрямую:
@RestController
@RequestMapping("/api/v1/restaurants")
@Validated
class RestaurantController(private val restaurantService: RestaurantService) {
@GetMapping("/{id}")
fun getById(@PathVariable @Min(1) id: Long): ResponseEntity<RestaurantResponse> {
// Без @Validated на классе аннотация @Min на @PathVariable не сработает
val restaurant = restaurantService.getById(id)
return ResponseEntity.ok(restaurant.toResponse())
}
@GetMapping
fun search(
@RequestParam @Size(min = 2, message = "Минимум 2 символа для поиска") query: String?
): ResponseEntity<List<RestaurantResponse>> {
// ...
}
}При провале валидации через
@ValidatedSpring выброситConstraintViolationException(а неMethodArgumentNotValidException). Его тоже нужно обработать вGlobalExceptionHandler.
@ExceptionHandler(ConstraintViolationException::class)
fun handleConstraintViolation(e: ConstraintViolationException): ResponseEntity<ErrorResponse> {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse(400, e.message))
}| Что | @Valid |
@Validated |
|---|---|---|
| Источник | Jakarta (стандарт) | Spring (расширение) |
| Куда ставить | Перед параметром метода | На класс контроллера |
| Что валидирует | @RequestBody |
@PathVariable, @RequestParam |
| Исключение | MethodArgumentNotValidException |
ConstraintViolationException |
На практике их используют вместе: @Validated на классе + @Valid перед @RequestBody.
| Аннотация | Назначение | Пример |
|---|---|---|
@NotNull |
Не null | @field:NotNull |
@NotBlank |
Не null, не пустая, не только пробелы | @field:NotBlank |
@NotEmpty |
Не null и не пустая коллекция/строка | @field:NotEmpty |
@Size |
Ограничение длины | @field:Size(min = 2, max = 100) |
@Min / @Max |
Числовые границы | @field:Min(1) |
@Email |
Проверка формата email | @field:Email |
@Pattern |
Регулярное выражение | @field:Pattern(regexp = "^[A-Z].*") |
@Positive |
Число > 0 | @field:Positive |
Логгирование — это запись событий, происходящих в приложении. Без логов невозможно диагностировать ошибки на проде.
Spring Boot использует SLF4J + Logback по умолчанию. Дополнительных зависимостей не нужно.
Можно использовать SLF4J напрямую, но более предпочтительный подход в Kotlin — библиотека kotlin-logging. Она является обёрткой над SLF4J и даёт несколько преимуществ:
- Лямбда-синтаксис — строка лога не вычисляется, если уровень отключён (экономия ресурсов).
- Kotlin-идиоматичный API — никаких
{}плейсхолдеров, обычная строковая интерполяция. - Компактнее — не нужно передавать
ClassвgetLogger.
Зависимость в pom.xml:
<dependency>
<groupId>io.github.oshai</groupId>
<artifactId>kotlin-logging-jvm</artifactId>
<version>7.0.13</version>
</dependency>Использование:
@Service
class RestaurantService(
private val restaurantRepository: RestaurantRepositoryPort
) {
private val logger = KotlinLogging.logger {}
fun getById(id: Long): Restaurant {
logger.info { "Запрос ресторана с id=$id" }
return restaurantRepository.findById(id)
?: throw NotFoundException("Ресторан с id=$id не найден").also {
logger.warn { "Ресторан с id=$id не найден" }
}
}
fun create(command: CreateRestaurantCommand): Restaurant {
val restaurant = restaurantRepository.save(command.toEntity())
logger.info { "Создан ресторан: id=${restaurant.id}, name=${restaurant.name}" }
return restaurant
}
}Для сравнения — тот же код на чистом SLF4J (более многословно):
private val logger = LoggerFactory.getLogger(RestaurantService::class.java)
logger.info("Запрос ресторана с id={}", id) // плейсхолдеры вместо интерполяции| Уровень | Когда использовать |
|---|---|
ERROR |
Что-то сломалось, требует внимания |
WARN |
Нештатная ситуация, но приложение работает |
INFO |
Ключевые бизнес-события (создан заказ, удалён ресторан) |
DEBUG |
Детали для отладки (значения переменных, SQL) |
TRACE |
Максимальная детализация (редко используется) |
Простой способ — указать уровни прямо в application.yaml:
logging:
level:
root: INFO
com.example.delivery: DEBUG
org.springframework.web: WARN
org.hibernate.SQL: DEBUGroot— общий уровень логирования для всего приложения (по умолчаниюINFO).com.example.delivery— корневой пакет вашего проекта, для него включенDEBUG.- Остальные пакеты можно переопределить по отдельности (
org.springframework.web: WARNи т.д.).
application.yaml подходит для простых случаев. Для более гибкой настройки (формат вывода, запись в файл, ротация логов) используется файл logback-spring.xml в src/main/resources/:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- Вывод в консоль -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Вывод в файл с ротацией -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- Новый файл каждый день -->
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<!-- Максимальный размер одного файла -->
<maxFileSize>10MB</maxFileSize>
<!-- Хранить логи за последние 30 дней -->
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Уровни для пакетов -->
<logger name="com.example.delivery" level="DEBUG"/>
<logger name="org.springframework.web" level="WARN"/>
<logger name="org.hibernate.SQL" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>Если
logback-spring.xmlприсутствует, он заменяет настройки логирования изapplication.yaml. Не используйте оба способа одновременно.
Элементы паттерна:
%d{...}— дата и время%thread— имя потока%-5level— уровень (INFO, WARN...), выровненный по 5 символам%logger{36}— имя логгера (обрезанное до 36 символов)%msg%n— сообщение и перенос строки
Особенно важно логировать непредвиденные ошибки — те, что попадают в fallback-обработчик:
@ExceptionHandler(Exception::class)
fun handleUnexpected(e: Exception): ResponseEntity<ErrorResponse> {
logger.error(e) { "Непредвиденная ошибка" }
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse(500, "Внутренняя ошибка сервера"))
}
logger.error(e) { ... }— записывает сообщение и стек-трейс в лог, но клиенту возвращает только безопасное сообщение.
sealed class AppException— базовый класс.NotFoundException— ресурс не найден.AlreadyExistsException— конфликт (например, дублирование имени ресторана).InvalidOrderStateException— недопустимый переход статуса заказа.
Создайте GlobalExceptionHandler, который обрабатывает:
NotFoundException→404 Not Found.AlreadyExistsException→409 Conflict.InvalidOrderStateException→400 Bad Request.MethodArgumentNotValidException→400 Bad Requestс ошибками по полям.Exception→500 Internal Server Error(fallback).
Все ответы — в едином формате (ErrorResponse / ValidationErrorResponse).
Используйте аннотации jakarta.validation на всех входных DTO:
CreateRestaurantRequest—nameне пустое,addressне пустой.CreateDishRequest—nameне пустое,price> 0.CreateOrderRequest—userIdне null,dishIdsне пустой.- Используйте
@Validв контроллерах перед@RequestBody.
Замените все места, где сервис возвращает null или бросает стандартные исключения:
findById→ если не найдено, бросатьNotFoundException.- Создание ресторана с дублирующимся именем →
AlreadyExistsException. - Смена статуса заказа на недопустимый →
InvalidOrderStateException.
- Добавьте логгер в сервисный слой и в
GlobalExceptionHandler. - Логируйте: создание/удаление сущностей (
INFO), ошибки «не найдено» (WARN), непредвиденные ошибки (ERRORс трейсом). - Настройте уровни логирования через
logback-spring.xml. - Настройте запись логов уровня
WARNиERRORв отдельный файл (appenderFILE).
| Категория | Критерий | Баллы |
|---|---|---|
| Штраф | Не проходят автотесты | -5 |
| Кастомные исключения | Есть sealed-иерархия, используется в сервисах | 1 |
| @RestControllerAdvice | Единый обработчик, корректные статусы (400/404/409/500) | 2 |
| Валидация DTO | @Valid / @Validated + аннотации на входных DTO |
2 |
| Ошибки валидации | MethodArgumentNotValidException возвращает ошибки по полям |
2 |
| Логгирование | Логгер в сервисах и обработчике ошибок, настроены уровни | 2 |
| Качество решения | Единый формат ответа, чистота кода | 1 |
| Итого | 10 |
POSTс невалидным телом возвращает400с перечнем ошибок по полям.GET /api/v1/restaurants/999999возвращает404в едином формате, а не Spring-трейс.- Создание ресторана с дублирующимся именем возвращает
409. - Непредвиденная ошибка возвращает
500без стек-трейса в теле ответа. - В логах видны
INFO/WARN/ERRORзаписи от вашего приложения. - Все прежние CRUD-эндпоинты из ЛР-4 по-прежнему работают.