Руководство по обработке ошибок Spring Boot REST API
Опубликовано: 2022-03-11Правильная обработка ошибок в API с предоставлением осмысленных сообщений об ошибках — очень желательная функция, поскольку она может помочь клиенту API правильно реагировать на проблемы. Поведение по умолчанию имеет тенденцию возвращать трассировки стека, которые трудно понять и в конечном итоге бесполезны для клиента API. Разделение информации об ошибке на поля также позволяет клиенту API анализировать ее и предоставлять пользователю более точные сообщения об ошибках. В этой статье мы расскажем, как правильно обрабатывать ошибки при создании REST API с помощью Spring Boot.
Создание REST API с помощью Spring стало стандартным подходом для разработчиков Java за последние пару лет. Использование Spring Boot существенно помогает, так как удаляет много стандартного кода и позволяет автоматически настраивать различные компоненты. Мы предполагаем, что вы знакомы с основами разработки API с этими технологиями, прежде чем применять описанные здесь знания. Если вы все еще не знаете, как разработать базовый REST API, вам следует начать с этой статьи о Spring MVC или другой статьи о создании службы Spring REST.
Делаем ответы на ошибки более понятными
В этой статье мы будем использовать исходный код, размещенный на GitHub, приложения, реализующего REST API для извлечения объектов, представляющих птиц. Он имеет функции, описанные в этой статье, и еще несколько примеров сценариев обработки ошибок. Вот сводка конечных точек, реализованных в этом приложении:
GET /birds/{birdId} | Получает информацию о птице и генерирует исключение, если она не найдена. |
GET /birds/noexception/{birdId} | Этот вызов также получает информацию о птице, за исключением того, что он не генерирует исключение в случае, если птица не найдена. | POST /birds | Создает птицу. | </tr>
Модуль Spring framework MVC поставляется с некоторыми замечательными функциями, помогающими с обработкой ошибок. Но разработчику остается использовать эти функции для обработки исключений и возврата осмысленных ответов клиенту API.
Давайте посмотрим на пример ответа Spring Boot по умолчанию, когда мы отправляем HTTP POST в конечную точку /birds
со следующим объектом JSON, который имеет строку «aaa» в поле «mass», которое должно ожидать целое число:
{ "scientificName": "Common blackbird", "specie": "Turdus merula", "mass": "aaa", "length": 4 }
Ответ Spring Boot по умолчанию без надлежащей обработки ошибок:
{ "timestamp": 1500597044204, "status": 400, "error": "Bad Request", "exception": "org.springframework.http.converter.HttpMessageNotReadableException", "message": "JSON parse error: Unrecognized token 'three': was expecting ('true', 'false' or 'null'); nested exception is com.fasterxml.jackson.core.JsonParseException: Unrecognized token 'aaa': was expecting ('true', 'false' or 'null')\n at [Source: java.io.PushbackInputStream@cba7ebc; line: 4, column: 17]", "path": "/birds" }
Ну… в ответном сообщении есть несколько хороших полей, но оно слишком сосредоточено на том, что было исключением. Кстати, это класс DefaultErrorAttributes
из Spring Boot. Поле timestamp
представляет собой целое число, которое даже не содержит информации о том, в какой единице измерения находится временная метка. Поле exception
интересно только разработчикам Java, и сообщение оставляет потребителя API потерянным во всех деталях реализации, которые не имеют к ним отношения. . А что, если бы мы могли извлечь больше деталей из исключения, из-за которого произошла ошибка? Итак, давайте узнаем, как правильно обрабатывать эти исключения и заключать их в более красивое представление JSON, чтобы облегчить жизнь нашим клиентам API.
Поскольку мы будем использовать классы даты и времени Java 8, нам сначала нужно добавить зависимость Maven для преобразователей Jackson JSR310. Они заботятся о преобразовании классов даты и времени Java 8 в представление JSON с помощью аннотации @JsonFormat
:
<dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-jsr310</artifactId> </dependency>
Итак, давайте определим класс для представления ошибок API. Мы создадим класс с именем ApiError
, в котором будет достаточно полей для хранения соответствующей информации об ошибках, возникающих во время вызовов REST.
class ApiError { private HttpStatus status; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy hh:mm:ss") private LocalDateTime timestamp; private String message; private String debugMessage; private List<ApiSubError> subErrors; private ApiError() { timestamp = LocalDateTime.now(); } ApiError(HttpStatus status) { this(); this.status = status; } ApiError(HttpStatus status, Throwable ex) { this(); this.status = status; this.message = "Unexpected error"; this.debugMessage = ex.getLocalizedMessage(); } ApiError(HttpStatus status, String message, Throwable ex) { this(); this.status = status; this.message = message; this.debugMessage = ex.getLocalizedMessage(); } }
Свойство
status
содержит статус вызова операции. Это может быть что угодно: от 4xx для обозначения ошибок клиента до 5xx для обозначения ошибок сервера. Распространенным сценарием является http-код 400, что означает BAD_REQUEST, когда клиент, например, отправляет неправильно отформатированное поле, например недопустимый адрес электронной почты.Свойство
timestamp
содержит экземпляр даты и времени, когда произошла ошибка.Свойство
message
содержит удобное для пользователя сообщение об ошибке.Свойство
debugMessage
содержит системное сообщение с более подробным описанием ошибки.Свойство
subErrors
содержит массив произошедших вложенных ошибок. Это используется для представления нескольких ошибок в одном вызове. Примером могут быть ошибки проверки, при которых несколько полей не прошли проверку. КлассApiSubError
используется для их инкапсуляции.
abstract class ApiSubError { } @Data @EqualsAndHashCode(callSuper = false) @AllArgsConstructor class ApiValidationError extends ApiSubError { private String object; private String field; private Object rejectedValue; private String message; ApiValidationError(String object, String message) { this.object = object; this.message = message; } }
Таким образом, ApiValidationError
— это класс, который расширяет ApiSubError
и выражает проблемы проверки, возникающие во время вызова REST.
Ниже вы увидите несколько примеров ответов JSON, которые генерируются после того, как мы внедрили описанные здесь улучшения, просто чтобы получить представление о том, что у нас будет к концу этой статьи.
Вот пример JSON, который возвращается, когда сущность не найдена при вызове конечной точки GET /birds/2
:
{ "apierror": { "status": "NOT_FOUND", "timestamp": "18-07-2017 06:20:19", "message": "Bird was not found for parameters {id=2}" } }
Вот еще один пример JSON, возвращенного при выполнении вызова POST /birds
с недопустимым значением массы птицы:
{ "apierror": { "status": "BAD_REQUEST", "timestamp": "18-07-2017 06:49:25", "message": "Validation errors", "subErrors": [ { "object": "bird", "field": "mass", "rejectedValue": 999999, "message": "must be less or equal to 104000" } ] } }
Обработка ошибок Spring Boot
Давайте рассмотрим некоторые аннотации Spring, которые будут использоваться для обработки исключений.

RestController
— это базовая аннотация для классов, обрабатывающих операции REST.
ExceptionHandler
— это аннотация Spring, предоставляющая механизм обработки исключений, возникающих во время выполнения обработчиков (операций контроллера). Эта аннотация, если она используется в методах классов контроллера, будет служить точкой входа для обработки исключений, создаваемых только внутри этого контроллера. В целом наиболее распространенным способом является использование @ExceptionHandler
в методах классов @ControllerAdvice
, чтобы обработка исключений применялась глобально или к подмножеству контроллеров.
ControllerAdvice
— это аннотация, представленная в Spring 3.2, и, как следует из названия, это «совет» для нескольких контроллеров. Он используется для того, чтобы один ExceptionHandler
мог применяться к нескольким контроллерам. Таким образом, мы можем всего в одном месте определить, как обрабатывать такое исключение, и этот обработчик будет вызываться, когда исключение выдается из классов, на которые распространяется этот ControllerAdvice
. Подмножество затронутых контроллеров можно определить с помощью следующих селекторов в @ControllerAdvice
: annotations()
, basePackageClasses()
и basePackages()
. Если селекторы не предоставлены, то ControllerAdvice
применяется глобально ко всем контроллерам.
Таким образом, используя @ExceptionHandler
и @ControllerAdvice
, мы сможем определить центральную точку для обработки исключений и оборачивать их в объект ApiError
с лучшей организацией, чем механизм обработки ошибок Spring Boot по умолчанию.
Обработка исключений
Следующим шагом является создание класса, который будет обрабатывать исключения. Для простоты мы называем его RestExceptionHandler
и он должен расширяться от ResponseEntityExceptionHandler
Spring Boot. Мы будем расширять ResponseEntityExceptionHandler
, поскольку он уже обеспечивает некоторую базовую обработку исключений Spring MVC, поэтому мы будем добавлять обработчики новых исключений, улучшая существующие.
Переопределение исключений, обрабатываемых в ResponseEntityExceptionHandler
Если вы посмотрите на исходный код ResponseEntityExceptionHandler
, вы увидите множество методов с именем handle******()
, таких как handleHttpMessageNotReadable()
или handleHttpMessageNotWritable()
. Давайте сначала посмотрим, как мы можем расширить handleHttpMessageNotReadable()
для обработки исключений HttpMessageNotReadableException
. Нам просто нужно переопределить метод handleHttpMessageNotReadable()
в нашем классе RestExceptionHandler
:
@Order(Ordered.HIGHEST_PRECEDENCE) @ControllerAdvice public class RestExceptionHandler extends ResponseEntityExceptionHandler { @Override protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { String error = "Malformed JSON request"; return buildResponseEntity(new ApiError(HttpStatus.BAD_REQUEST, error, ex)); } private ResponseEntity<Object> buildResponseEntity(ApiError apiError) { return new ResponseEntity<>(apiError, apiError.getStatus()); } //other exception handlers below }
Мы объявили, что в случае создания HttpMessageNotReadableException
сообщением об ошибке будет «Malformed JSON request», и ошибка будет инкапсулирована внутри объекта ApiError
. Ниже мы можем увидеть ответ на вызов REST с переопределением этого нового метода:
{ "apierror": { "status": "BAD_REQUEST", "timestamp": "21-07-2017 03:53:39", "message": "Malformed JSON request", "debugMessage": "JSON parse error: Unrecognized token 'aaa': was expecting ('true', 'false' or 'null'); nested exception is com.fasterxml.jackson.core.JsonParseException: Unrecognized token 'aaa': was expecting ('true', 'false' or 'null')\n at [Source: java.io.PushbackInputStream@7b5e8d8a; line: 4, column: 17]" } }
Обработка пользовательских исключений
Теперь мы увидим, как создать метод, обрабатывающий исключение, которое еще не объявлено внутри ResponseEntityExceptionHandler
Spring Boot.
Распространенным сценарием для приложения Spring, которое обрабатывает вызовы базы данных, является вызов для поиска записи по ее идентификатору с использованием класса репозитория. Но если мы посмотрим на метод CrudRepository.findOne()
, то увидим, что он возвращает null
, если объект не найден. Это означает, что если наш сервис просто вызовет этот метод и вернётся непосредственно к контроллеру, мы получим HTTP-код 200 (ОК), даже если ресурс не будет найден. На самом деле правильный подход заключается в возврате HTTP-кода 404 (НЕ НАЙДЕНО), как указано в спецификации HTTP/1.1.
Чтобы справиться с этим случаем, мы создадим пользовательское исключение EntityNotFoundException
. Это созданное пользователем исключение отличается от javax.persistence.EntityNotFoundException
, поскольку оно предоставляет некоторые конструкторы, которые упрощают создание объекта, и можно по-разному обрабатывать исключение javax.persistence
.
Тем не менее, давайте создадим ExceptionHandler
для этого недавно созданного EntityNotFoundException
в нашем классе RestExceptionHandler
. Для этого создайте метод с именем handleEntityNotFound()
и аннотируйте его с помощью @ExceptionHandler
, передав ему объект класса EntityNotFoundException.class
. Это сигнализирует Spring, что каждый раз, когда EntityNotFoundException
, Spring должен вызывать этот метод для его обработки. При аннотации метода с помощью @ExceptionHandler
он будет принимать широкий спектр автоматически вводимых параметров, таких как WebRequest
, Locale
и другие, как описано здесь. Мы просто предоставим само исключение EntityNotFoundException
в качестве параметра для этого метода handleEntityNotFound
.
@Order(Ordered.HIGHEST_PRECEDENCE) @ControllerAdvice public class RestExceptionHandler extends ResponseEntityExceptionHandler { //other exception handlers @ExceptionHandler(EntityNotFoundException.class) protected ResponseEntity<Object> handleEntityNotFound( EntityNotFoundException ex) { ApiError apiError = new ApiError(NOT_FOUND); apiError.setMessage(ex.getMessage()); return buildResponseEntity(apiError); } }
Здорово! В handleEntityNotFound()
мы устанавливаем для кода состояния HTTP значение NOT_FOUND
и используем новое сообщение об исключении. Вот как теперь выглядит ответ для конечной точки GET /birds/2
:
{ "apierror": { "status": "NOT_FOUND", "timestamp": "21-07-2017 04:02:22", "message": "Bird was not found for parameters {id=2}" } }
Заключение
Важно получить контроль над обработкой исключений, чтобы мы могли правильно сопоставить эти исключения с объектом ApiError
и предоставить важную информацию, которая позволит клиентам API узнать, что произошло. Следующим шагом будет создание большего количества методов-обработчиков (с @ExceptionHandler) для исключений, которые возникают в коде приложения. В коде GitHub есть больше примеров для некоторых других распространенных исключений, таких как MethodArgumentTypeMismatchException
, ConstraintViolationException
и других.
Вот некоторые дополнительные ресурсы, которые помогли в написании этой статьи:
Baeldung — обработка ошибок для REST с помощью Spring
Блог Spring — обработка исключений в Spring MVC