Во время работы вашего приложения часто будут возникать исключительные ситуации. Когда у вас простое консольное приложение, то все просто – ошибка выводится в консоль. Но как быть с веб-приложением?
Допустим у пользователя отсутсвует доступ, или он передал некорректные данные. Лучшим вариантом будет в ответ на такие ситуации, отправлять пользователю сообщения с описанием ошибки. Это позволит клиенту вашего API скорректировать свой запрос.
В данной статье разберём основные возможности, которые предоставляет SpringBoot для решения этой задачи и на простых примерах посмотрим как всё работает.
@ExceptionHandler
@ExceptionHandler
позволяет обрабатывать исключения на уровне отдельного контроллера. Для этого достаточно объявить метод в контроллере, в котором будет содержаться вся логика обработки нужного исключения, и пометить его аннотацией.
Для примера у нас будет сущность Person
, бизнес сервис к ней и контроллер. Контроллер имеет один эндпойнт, который возвращает пользователя по логину. Рассмотрим классы нашего приложения:
Сущность Person
:
public class Person {
private String lastName;
private String firstName;
private Integer age;
//getters and setters
}
Контроллер PersonController
:
@Slf4j
@RestController
@RequestMapping("api/person")
@RequiredArgsConstructor
public class PersonController {
private final PersonService personService;
@GetMapping
public ResponseEntity<Person> getByLogin(@RequestParam("login") String login) {
return ResponseEntity.ok(personService.getByLoginOrThrown(login));
}
@GetMapping("{id}")
public ResponseEntity<Person> getById(@PathVariable("id") UUID id) {
return ResponseEntity.ok(personService.getById(id).orElseThrow());
}
}
И наконец PersonService
, который будет возвращать исключение NotFoundException
, если пользователя не будет в мапе persons
.
@Service
public class PersonService {
private final Map<UUID, Person> people = new HashMap<>();
public PersonService() {
final UUID komarId = UUID.randomUUID();
people.put(komarId, new Person(komarId, "komar", "Алексей", "ertyuiop"));
}
public Person getByLoginOrThrown(@NonNull String login) {
return people.values().stream()
.filter(person -> person.getLogin().equals(login))
.findFirst()
.orElseThrow(() -> new NotFoundException("Пользователь не найден"));
}
public Optional<Person> getById(@NonNull UUID id) {
return Optional.ofNullable(people.get(id));
}
}
Перед тем, как проверить работу исключения, давайте посмотрим на успешную работу эндпойнта.
Все отлично. Нам в ответ пришел код 200, а в теле ответа пришел JSON нашей сущности. А теперь мы отправим запрос с логином пользователя, которого у нас нет. Посмотрим, что сделает Spring по умолчанию.
Обратите внимание, ошибка 500 – это стандартный ответ Spring на возникновение любого неизвестного исключения. Также исключение было выведено в консоль.
Как я уже говорил, отличным решением будет сообщить пользователю, что он делает не так. Для этого добавляем метод с аннотацией @ExceptionHandler
, который будет перехватывать исключение и отправлять понятный ответ пользователю.
@RequestMapping("api/person")
@RequiredArgsConstructor
public class PersonController {
private final PersonService personService;
@GetMapping
public ResponseEntity<Person> getByLogin(@RequestParam("login") String login) {
return ResponseEntity.ok(personService.getByLoginOrThrown(login));
}
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorMessage> handleException(NotFoundException exception) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorMessage(exception.getMessage()));
}
}
Вызываем повторно наш метод и видим, что мы стали получать понятное описание ошибки.
Но теперь вернулся 200 http код, куда корректнее вернуть 404 код.
Однако некоторые разработчики предпочитают возвращать объект, вместо ResponseEntity<T>
. Тогда вам необходимо воспользоваться аннотацией @ResponseStatus
.
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler(NotFoundException.class)
public ErrorMessage handleException(NotFoundException exception) {
return new ErrorMessage(exception.getMessage());
}
Если попробовать совместить ResponseEntity<T>
и @ResponseStatus
, http-код будет взят из ResponseEntity<T>
.
Главный недостаток @ExceptionHandler
в том, что он определяется для каждого контроллера отдельно. Обычно намного проще обрабатывать все исключения в одном месте.
Хотя это ограничение можно обойти если @ExceptionHandler
будет определен в базовом классе, от которого будут наследоваться все контроллеры в приложении, но такой подход не всегда возможен, особенно если перед нами старое приложение с большим количеством легаси.
HandlerExceptionResolver
Как мы знаем в программировании магии нет, какой механизм задействуется, чтобы перехватывать исключения?
Интерфейс HandlerExceptionResolver
является общим для обработчиков исключений в Spring. Все исключения выброшенные в приложении будут обработаны одним из подклассов HandlerExceptionResolver
. Можно сделать как свою собственную реализацию данного интерфейса, так и использовать существующие реализации, которые предоставляет нам Spring из коробки.
Давайте разберем стандартные для начала:
ExceptionHandlerExceptionResolver
— этот резолвер является частью механизма обработки исключений помеченных аннотацией @ExceptionHandler
, которую мы рассмотрели выше.
DefaultHandlerExceptionResolver
— используется для обработки стандартных исключений Spring и устанавливает соответствующий код ответа, в зависимости от типа исключения:
Exception | HTTP Status Code |
---|---|
BindException | 400 (Bad Request) |
ConversionNotSupportedException | 500 (Internal Server Error) |
HttpMediaTypeNotAcceptableException | 406 (Not Acceptable) |
HttpMediaTypeNotSupportedException | 415 (Unsupported Media Type) |
HttpMessageNotReadableException | 400 (Bad Request) |
HttpMessageNotWritableException | 500 (Internal Server Error) |
HttpRequestMethodNotSupportedException | 405 (Method Not Allowed) |
MethodArgumentNotValidException | 400 (Bad Request) |
MissingServletRequestParameterException | 400 (Bad Request) |
MissingServletRequestPartException | 400 (Bad Request) |
NoSuchRequestHandlingMethodException | 404 (Not Found) |
TypeMismatchException | 400 (Bad Request) |
Мы можем создать собственный HandlerExceptionResolver
. Назовем его CustomExceptionResolver
и вот как он будет выглядеть:
package dev.struchkov.example.controlleradvice.service;
@Component
public class CustomExceptionResolver extends AbstractHandlerExceptionResolver {
@Override
protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception e) {
final ModelAndView modelAndView = new ModelAndView(new MappingJackson2JsonView());
if (e instanceof NotFoundException) {
modelAndView.setStatus(HttpStatus.NOT_FOUND);
modelAndView.addObject("message", "Пользователь не найден");
return modelAndView;
}
modelAndView.setStatus(HttpStatus.INTERNAL_SERVER_ERROR);
modelAndView.addObject("message", "При выполнении запроса произошла ошибка");
return modelAndView;
}
}
Мы создаем объект представления – ModelAndView
, который будет отправлен пользователю, и заполняем его. Для этого проверяем тип исключения, после чего добавляем в представление сообщение о конкретной ошибке и возвращаем представление из метода. Если ошибка имеет какой-то другой тип, который мы не предусмотрели в этом обработчике, то мы отправляем сообщение об ошибке при выполнении запроса.
Так как мы пометили этот класс аннотацией @Component
, Spring сам найдет и внедрит наш резолвер куда нужно. Посмотрим, как Spring хранит эти резолверы в классе DispatcherServlet
.
Все резолверы хранятся в обычном ArrayList
и в случае исключнеия вызываются по порядку, при этом наш резолвер оказался последним. Таким образом, если непосредственно в контроллере окажется @ExceptionHandler
обработчик, то наш кастомный резолвер не будет вызван, так как обработка будет выполнена в ExceptionHandlerExceptionResolver
.
Важное замечание. У меня не получилось перехватить здесь ни одно Spring исключение, например MethodArgumentTypeMismatchException
, которое возникает если передавать неверный тип для аргументов @RequestParam
.
Этот способ был показан больше для образовательных целей, чтобы показать в общих чертах, как работает этот механизм. Не стоит использовать этот способ, так как есть вариант намного удобнее.
@RestControllerAdvice
Исключения возникают в разных сервисах приложения, но удобнее всего обрабатывать все исключения в каком-то одном месте. Именно для этого в SpringBoot предназначены аннотации @ControllerAdvice
и @RestControllerAdvice
. В статье мы рассмотрим @RestControllerAdvice
, так как у нас REST API.
На самом деле все довольно просто. Мы берем методы помеченные аннотацией @ExceptionHandler
, которые у нас были в контроллерах и переносим в отдельный класс аннотированный @RestControllerAdvice
.
@RestControllerAdvice
public class ExceptionApiHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorMessage> notFoundException(NotFoundException exception) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorMessage(exception.getMessage()));
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorMessage> mismatchException(MethodArgumentTypeMismatchException exception) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorMessage(exception.getMessage()));
}
}
За обработку этих методов класса точно также отвечает класс ExceptionHandlerExceptionResolver
. При этом мы можем здесь перехватывать даже стандартные исключения Spring, такие как MethodArgumentTypeMismatchException
.
На мой взгляд, это самый удобный и простой способ обработки возвращаемых пользователю исключений.
Еще про обработку
Все написанное дальше относится к любому способу обработки исключений.
Запись в лог
Важно отметить, что исключения больше не записываются в лог. Если помимо ответа пользователю, вам все же необходимо записать это событие в лог, то необходимо добавить строчку записи в методе обработчике, например вот так:
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorMessage> handleException(NotFoundException exception) {
log.error(exception.getMessage(), exception);
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorMessage(exception.getMessage()));
}
Перекрытие исключений
Вы можете использовать иерархию исключений с наследованием и обработчики исключений для всей своей иерархии. В таком случае обработка исключения будет попадать в самый специализированный обработчик.
Допустим мы бросаем NotFoundException
, как в примере выше, который наследуется от RuntimeException
. И у вас будет два обработчика исключений для NotFoundException
и RuntimeException
. Исключение попадет в обработчик для NotFoundException
. Если этот обработчик убрать, то попадет в обработчик для RuntimeException
.
Резюмирую
Обработка исключений это важная часть REST API. Она позволяет возвращать клиентам информационные сообщения, которые помогут им скорректировать свой запрос.
Мы можем по разному реализовать обработку в зависимости от нашей архитектуры. Предпочитаемым способом считаю вариант с @RestControllerAdvice
. Этот вариант самый чистый и понятный.