Сегодня мы узнаем как правильно обрабатывать исключения в Spring приложениях с помощью ControllerAdvice и RestControllerAdvice аннотации.
Когда мы пишем веб приложение, у нас возникает необходимость обрабатывать исключения. Пришли некорректные данные, в пользователя нет доступа, запись не найдена в базе и так далее. Причин по которым могут возникать ошибки в приложении – очень много.
В связи с этим возникает закономерное желание прийти к единому механизму обработки ошибок а также их корректной отправки клиенту. Неважно будь то пользователи нашей системы или сторонние сервера. Всем нужна понятная и точная информация о том, что именно пошло не так.
Именно для вышеописанных целей и создан механизм ControllerAdvice в фреймворке Spring.
Аннотации ControllerAdvice и RestControllerAdvice позволяют перехватывать и обрабатывать исключения по всему проекту из одного компонента обработки (класса). Все, что вам нужно сделать – создать класс, навешать на него аннотацию ControllerAdvice, создать методы по перехвату ошибок. Давайте сейчас на практике попробуем посмотреть как это делать.
Первое что нам нужно – RESTful веб приложение на Spring Boot.
Для нашего примера я напишу приложение с нуля. Для удобства и быстроты мы не будем подключать базу данных и другие компоненты, которые зачастую характерны для современных веб приложений.
Допустим у меня есть приложение для управлением автомобилями. В наличии контроллер и сервис, который отвечает за создание и поиск авто.

Для удобства управления объектами я создал класс авто:
package com.example.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CarModel {
private String name;
private BigDecimal price;
}
Не пугайтесь строк:
@Data
@AllArgsConstructor
@NoArgsConstructor
Это аннотации из библиотеки Lombok, которые призваны немного упростить жизнь программистам и сделать код более читаемым. В нашем случае @Data генерирует геттеры, сеттеры, equals, hashCode, toString. Две остальные аннотации конструктор с параметрами и пустой соответственно.
Мой сервис класс призван хранить в себе логику по обработке авто:
package com.example.demo.service;
import com.example.demo.exception.AuthException;
import com.example.demo.exception.NotFoundException;
import com.example.demo.exception.ValidationException;
import com.example.demo.model.CarModel;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import static java.util.Objects.isNull;
import static org.apache.logging.log4j.util.Strings.isBlank;
@Service
public class DefaultCarService {
static List<CarModel> carList = new ArrayList<>();
static {
carList.add(new CarModel("Honda", BigDecimal.TEN));
}
public void save(CarModel car) throws ValidationException {
if (isNull(car)) {
throw new ValidationException("Car object is null");
}
if (isBlank(car.getName())) {
throw new ValidationException("Name can't be empty");
}
if (isNull(car.getPrice()) || car.getPrice().compareTo(BigDecimal.ZERO) < 0) {
throw new ValidationException("Price can't be empty or less than 0");
}
//proceed with car saving
}
public CarModel getCar(String name) throws NotFoundException {
return carList.stream()
.filter(it -> it.getName().equals(name))
.findFirst()
.orElseThrow(() -> new NotFoundException("Car not found by name " + name));
}
public void adminFunctional(String username) throws AuthException {
if (!username.equals("admin")) {
throw new AuthException(String.format("Username %s is not allowed to perform this action", username));
}
}
}
Так как базы данных у меня нет, поиск авто я выполнил по обычному списку. Туда я положил одно авто с именем Honda.
Обратите внимание на метод adminFunctional. Добавил я его только потому что хотел показать больше примеров исключений. Хоть код и получился довольно странный – он очень наглядно продемонстрирует нам как можно обработать множество ошибок в несколько строк.
Что касается классов исключений, то их код довольно простой и однотипный для каждого класса. Вот например ValidationException:
package com.example.demo.exception;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public class ValidationException extends Exception {
private final String message;
}
Здесь все то, что мы делали когда знакомились с исключениями в Java. В блоке, где писали собственные классы исключений.
Код для NotFoundException и AuthException идентичный.
Дальше я написал самый обычный REST контроллер, который будет принимать запросы по созданию и поиску авто:
package com.example.demo.controller;
import com.example.demo.exception.AuthException;
import com.example.demo.exception.NotFoundException;
import com.example.demo.exception.ValidationException;
import com.example.demo.model.CarModel;
import com.example.demo.service.DefaultCarService;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@AllArgsConstructor
@RequestMapping("/car")
public class CarController {
private final DefaultCarService carService;
@PostMapping
public ResponseEntity<Void> save(@RequestBody CarModel car) throws ValidationException {
carService.save(car);
return ResponseEntity.ok().build();
}
@GetMapping("/{name}")
public ResponseEntity<CarModel> getByName(@PathVariable String name) throws NotFoundException {
return ResponseEntity.ok(carService.getCar(name));
}
@GetMapping("/admin/{username}")
public ResponseEntity<Void> adminFunctional(@PathVariable String username) throws AuthException {
carService.adminFunctional(username);
return ResponseEntity.ok().build();
}
}
Теперь самое интересное. Я создал обычный класс ControllerAdvisor, навешал на него RestControllerAdvice аннотацию и получил перехват ошибок в одном месте. Здесь, кстати, нет разницы использовать RestControllerAdvice или ControllerAdvice. Первый добавили совсем недавно и по сути RestControllerAdvice – это @ControllerAdvice + @ResponseBody.
В нашем случае я использую @RestControllerAdvice аннотацию, так как у меня REST контроллер в приложении и это просто удобнее. Не нужно добавлять @ResponseBody аннотацию отдельно.
Дальше я создал класс, который будет возвращаться клиенту в случае ошибки:
package com.example.demo.controller;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.http.HttpStatus;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ErrorResponse {
private String message;
private HttpStatus status;
private LocalDateTime timestamp;
}
Я подумал что будет уместно отправлять не только сообщение об ошибке, но и статус (хотя статус мы будем отправлять в ответе), а также поле timestamp, чтобы видеть когда произошла ошибка.
В моем перехватчике ошибок я создаю методы для каждого типа ошибки и возвращаю соответствующий результат клиенту:
package com.example.demo.controller;
import com.example.demo.exception.AuthException;
import com.example.demo.exception.NotFoundException;
import com.example.demo.exception.ValidationException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import static java.time.LocalDateTime.now;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
@RestControllerAdvice
public class ControllerAdvisor {
@ExceptionHandler(ValidationException.class)
@ResponseStatus(BAD_REQUEST)
public ErrorResponse handleValidationException(ValidationException validationException) {
return ErrorResponse.builder()
.message(validationException.getMessage())
.status(BAD_REQUEST)
.timestamp(now())
.build();
}
@ExceptionHandler(NotFoundException.class)
@ResponseStatus(NOT_FOUND)
public ErrorResponse handleNotFoundException(NotFoundException notFoundException) {
return ErrorResponse.builder()
.message(notFoundException.getMessage())
.status(HttpStatus.NOT_FOUND)
.timestamp(now())
.build();
}
@ExceptionHandler(AuthException.class)
@ResponseStatus(UNAUTHORIZED)
public ErrorResponse handleNotFoundException(AuthException authException) {
return ErrorResponse.builder()
.message(authException.getMessage())
.status(HttpStatus.UNAUTHORIZED)
.timestamp(now())
.build();
}
}
Аннотация ExceptionHandler принимает класс исключение, на который будет реагировать метод. @ResponseStatus – код http ошибки, которую мы будем отправлять в ответе. Также код ошибки я возвращаю в теле. Хотя это и не обязательно. Я не забываю при этом получать сообщение (message) из класса исключения и перебрасывать его в ErrorResponse, чтобы клиент видел что именно случилось на сервере.
Настало время протестировать наш код. Для этого я использую Postman.

На скрине выше видно, что я делаю попытку сохранить авто с отрицательной ценой. Срабатывает одна из проверок и я получаю ошибку.
Тоже самое будет и с другими типами исключений. Я стандартизировал ответ для исключений и теперь фронтенд сможет прийти к одному подходу обработки таких ситуаций.
Это все, что я хотел написать о ControllerAdvice механизме в Spring. Валидируйте входящие данные, стандартизируйте подход к ошибкам. Это делает код чище и с меньшим количеством багов.
Спасибо за такую статью, мне было очень полезно 😉
Отлично и понятно написано! Спасибо