AI [Перевод] 3 критические ошибки в Spring Boot, которые просачиваются в прод (и как их исправить)

AI

Редактор
Регистрация
23 Август 2023
Сообщения
3 600
Лучшие ответы
0
Реакции
0
Баллы
243
Offline
#1


Пишешь на Spring Boot уже пару лет и уверен, что знаешь все подводные камни? Рассмотрим классические ошибки, которые продолжают проникать в прод даже у бывалых разработчиков. Вместе с Mohamed Akthar в новом переводе от команды Java Insider разбираем три распространённые проблемы, которые могут привести к бессонным ночам отладки.
Ошибка №1: Отсутствие валидации входных данных


Сколько раз вы писали endpoints примерно так:

@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest req) {
// Валидация на фронтенде же есть, правда?
return ResponseEntity.ok(authService.register(req));
}


А потом кто-то отправляет пустой email. Или username длиной в 500 символов. Или просто пустой JSON {}. В итоге — мусор в базе данных и вечер пятницы, потраченный на hotfix.

Правильное решение


public class RegisterRequest {
@NotBlank(message = "Имя пользователя не может быть пустым")
@Size(min = 3, max = 30, message = "Длина имени пользователя должна быть от 3 до 30 символов")
private String username;

@Email(message = "Некорректный формат email")
@NotBlank(message = "Email не может быть пустым")
private String email;

@NotNull(message = "Пароль обязателен")
@Size(min = 8, message = "Пароль должен содержать минимум 8 символов")
private String password;
}

@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest req) {
return ResponseEntity.ok(authService.register(req));
}


Ключевые моменты:


  • Аннотация @Valid активирует валидацию


  • Bean Validation автоматически возвращает HTTP 400 с деталями ошибок


  • Затраты времени: 5 минут. Экономия: часы отладки и потенциальные уязвимости
💡 Комментарий от редакции Java Insider

Не полагайтесь исключительно на клиентскую валидацию — это всегда вопрос безопасности, а не только UX. Злоумышленник может отправить запрос напрямую через curl или Postman, минуя вашу красивую форму на React.

Более того, Bean Validation поддерживает создание собственных constraint-аннотаций. Например, можно создать @ValidPhone для проверки телефонных номеров или @UniqueEmail с обращением к базе данных. Это делает код самодокументируемым и переиспользуемым.

Глобальный обработчик ошибок валидации


Чтобы сделать ответы более user-friendly, добавьте @ControllerAdvice:

@RestControllerAdvice
public class ValidationExceptionHandler {

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return ResponseEntity.badRequest().body(errors);
}
}


Теперь вместо стандартного громоздкого ответа Spring вы получите чистый JSON:

{
"username": "Длина имени пользователя должна быть от 3 до 30 символов",
"email": "Некорректный формат email"
}

Ошибка №2: Проблема N+1 запросов


Классическая ситуация — endpoint для получения транзакций с деталями:

@GetMapping("/transactions")
public List<TxnResponse> getAll() {
var txns = txnRepo.findByUserId(userId);
return txns.stream()
.map(t -> new TxnResponse(
t.getId(),
t.getAmount(),
t.getDetails() // ⚠️ Здесь запускается отдельный запрос!
))
.toList();
}


Что происходит на самом деле:


  1. Выполняется 1 запрос для получения транзакций: SELECT * FROM transactions WHERE user_id = ?


  2. Для каждой транзакции выполняется дополнительный запрос: SELECT * FROM transaction_details WHERE transaction_id = ?

Результат: 100 транзакций = 101 запрос к базе данных. Время ответа endpoint'а: 4 секунды. 😱

Решение: JOIN FETCH


@Query("SELECT t FROM Transaction t JOIN FETCH t.details WHERE t.userId = :userId")
List<Transaction> findByUserIdWithDetails(@Param("userId") Long userId);


Теперь Hibernate выполняет один запрос с JOIN:

SELECT t.*, d.*
FROM transactions t
LEFT JOIN transaction_details d ON t.id = d.transaction_id
WHERE t.user_id = ?


До: 101 запрос, 4 секунды
После: 1 запрос, 200 мс

💡 Комментарий от редакции Java Insider

N+1 — это не просто теоретическая проблема из учебников. В реальных проектах мы видели случаи, когда один неоптимизированный endpoint генерировал 10 000+ запросов при загрузке списка заказов с товарами, адресами доставки и статусами. База данных буквально умирала под нагрузкой.

Полезные инструменты для обнаружения N+1:


  • Включите spring.jpa.show-sql=true и spring.jpa.properties.hibernate.format_sql=true в development


  • В production мониторьте метрики через Micrometer: количество запросов на endpoint


  • Попробуйте инструмент Hypersistence Optimizer от Vlad Mihalcea — он автоматически детектирует N+1 в тестах

Альтернативные подходы к решению:


  • @EntityGraph — более декларативный способ, чем JOIN FETCH


  • @BatchSize — группирует запросы, но не решает проблему полностью


  • DTO Projections — получайте только нужные поля без lazy loading
Пример с @EntityGraph


@Entity
public class Transaction {
@Id
private Long id;

@OneToMany(fetch = FetchType.LAZY)
@JoinColumn(name = "transaction_id")
private List<TransactionDetail> details;
}

// В репозитории:
@EntityGraph(attributePaths = {"details"})
List<Transaction> findByUserId(Long userId);

Ошибка №3: Ловля общего Exception


Взгляните на этот код:

public AccountDTO getAccount(Long id) {
try {
var acc = accountRepo.findById(id).orElseThrow();
return mapper.toDto(acc);
} catch (Exception e) {
log.error("failed to get account", e);
throw new RuntimeException("something went wrong");
}
}


"Something went wrong" — супер-полезное сообщение, когда ты дебажишь в 11 вечера. 🤦‍♂️

Что это было:


  • Таймаут базы данных?


  • NullPointerException где-то в mapper?


  • Баг, который я только что внёс?

Лог просто говорит "failed to get account" для всех случаев. Никакого контекста.

Правильный подход


public AccountDTO getAccount(Long id) {
try {
var acc = accountRepo.findById(id)
.orElseThrow(() -> new AccountNotFoundException(
"Аккаунт не найден: " + id));
return mapper.toDto(acc);
} catch (DataAccessException e) {
log.error("Ошибка базы данных при получении аккаунта {}", id, e);
throw new ServiceException("База данных временно недоступна", e);
} catch (MappingException e) {
log.error("Ошибка маппинга аккаунта {}", id, e);
throw new ServiceException("Ошибка обработки данных аккаунта", e);
}
// Другие исключения пробрасываются выше — возможно, это баги
}


Преимущества:


  • Специфичные исключения → специфичные исправления


  • Разные HTTP-коды для разных ошибок (404 для NotFound, 503 для DB issues)


  • Логи с контекстом упрощают отладку
Централизованная обработка с @ControllerAdvice


@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(AccountNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(AccountNotFoundException e) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(
"ACCOUNT_NOT_FOUND",
e.getMessage(),
LocalDateTime.now()
));
}

@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ErrorResponse> handleDatabaseError(DataAccessException e) {
log.error("Database error occurred", e);
return ResponseEntity
.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(
"DATABASE_ERROR",
"Временные проблемы с базой данных. Попробуйте позже.",
LocalDateTime.now()
));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
log.error("Unexpected error", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(
"INTERNAL_ERROR",
"Внутренняя ошибка сервера",
LocalDateTime.now()
));
}
}

record ErrorResponse(String code, String message, LocalDateTime timestamp) {}


Помните: код пишется один раз, а читается и дебажится десятки раз. Инвестируйте в качество сразу.

А какие ошибки в Spring Boot вы совершали или видели в production? Делитесь в комментариях!


Больше материалов о Spring Boot, Java и backend-разработке ищите в нашем телеграм-канале Java Insider.
 
Яндекс.Метрика Рейтинг@Mail.ru
Сверху Снизу