Spring Boot REST API – обработка исключений. Часть 1

В этой статье — обзор способов обработки исключений в Spring Boot.

Приложение

Мы рассмотрим простое REST API приложение с одной сущностью Person и с одним контроллером.

Класс Person:

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Person {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

}

Контроллер PersonController:

@RestController
@RequestMapping("/persons")
public class PersonController {

    @Autowired
    private PersonRepository personRepository;

    @GetMapping
    public List<Person> listAllPersons() {
        List<Person> persons = personRepository.findAll();
        return persons;
    }

    @GetMapping(value = "/{personId}")
    public Person getPerson(@PathVariable("personId") long personId){
        return personRepository.findById(personId).orElseThrow(() -> new MyEntityNotFoundException(personId));
    }

    @PostMapping
    public Person createPerson(@RequestBody Person person) {
        return personRepository.save(person);
    }

    @PutMapping("/{id}")
    public Person updatePerson(@RequestBody Person person, @PathVariable long id)  {
        Person oldPerson = personRepository.getOne(id);
        oldPerson.setName(person.getName());
        return personRepository.save(oldPerson);
    }
    
}

При старте приложения выполняется скрипт data.sql, который добавляет в базу данных H2 одну строку — Person c id=1. То есть Person c id=2 в базе отсутствует.

При попытке запросить Person c id=2:

GET localhost:8080/persons/2

метод контроллера getPerson() выбрасывает исключение — в данном случае наше пользовательское MyEntityNotFoundException:

public class MyEntityNotFoundException extends RuntimeException {

    public MyEntityNotFoundException(Long id) {
        super("Entity is not found, id="+id);
    }
}

BasicErrorController

По умолчанию все исключения попадают на адрес /error в BasicErrorController, в метод error():

@Controller
@RequestMapping({"${server.error.path:${error.path:/error}}"})
public class BasicErrorController extends AbstractErrorController {

...
    @RequestMapping
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
        HttpStatus status = this.getStatus(request);
        if (status == HttpStatus.NO_CONTENT) {
            return new ResponseEntity(status);
        } else {
            Map<String, Object> body = this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.ALL));
            return new ResponseEntity(body, status);
        }
    }
 ...
}

Если поставить в этом методе break point, то будет понятно, из каких атрибутов собирается ответное JSON сообщение.

Проверим ответ по умолчанию, запросив с помощью клиента Postman отсутствующий Person, чтобы выбросилось MyEntityNotFoundException:

GET localhost:8080/persons/2

Получим ответ:

{
    "timestamp": "2021-02-28T15:33:56.339+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "Entity is not found, id=2",
    "path": "/persons/2"
}

Причем для того, чтобы поле message было непустым, в application.properties нужно включить свойство:

server.error.include-message=always
В текущей версии Spring сообщение не включается по умолчанию из соображений безопасности.

Обратите внимание, что поле status JSON-тела ответа дублирует реальный http-код ответа. В Postman он виден:

Поле message заполняется полем message выброшенного исключения.

Независимо от того, какое исключение выбросилось: пользовательское или уже существующее, ответ стандартный — в том смысле, что набор полей одинаковый. Меняется только внутренняя часть и, возможно, код ответа (он не обязательно равен 500, некоторые существующие в Spring исключения подразумевают другой код).

Но структура ответа сохраняется.

Проверим это.

Не пользовательское исключение

Например, если изменить код, убрав пользовательское MyEntityNotFoundException, то при отсутствии Person исключение будет все равно выбрасываться, но другое:

@GetMapping(value = "/{personId}")
public Person getPerson(@PathVariable("personId") long personId){
    return personRepository.findById(personId).get();
}

findById() возвращает тип Optional, а Optional.get() выбрасывает исключение NoSuchElementException с другим сообщением:

java.util.NoSuchElementException: No value present

в итоге при запросе несуществующего Person:

GET localhost:8080/persons/2

ответ сохранит ту же структуру, но поменяется поле message:

{
    "timestamp": "2021-02-28T15:44:20.065+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "No value present",
    "path": "/persons/2"
}

Вернем обратно пользовательское исключение MyEntityNotFoundException.

Попробуем поменять ответ, выдаваемый в ответ за запрос. Статус 500 для него явно не подходит.

Рассмотрим способы изменения ответа.

@ResponseStatus

Пока поменяем только статус ответа. Сейчас возвращается 500, а нам нужен 404 — это логичный ответ, если ресурс не найден.

Для этого аннотируем наше исключение:

@ResponseStatus(HttpStatus.NOT_FOUND)
public class MyEntityNotFoundException extends RuntimeException {

    public MyEntityNotFoundException(Long id) {
        super("Entity is not found, id="+id);
    }
}

Теперь ответ будет таким:

{
    "timestamp": "2021-02-28T15:54:37.070+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "Entity is not found, id=2",
    "path": "/persons/2"
}

Уже лучше.

@ControllerAdvice

Есть еще более мощный способ изменить ответ — @ControllerAdvice, и он имеет больший приоритет, чем @ResponseStatus.

В @ControllerAdvice можно не только изменить код ответа, но и тело. К тому же один обработчик можно назначить сразу для нескольких исключений.

Допустим мы хотим, чтобы ответ на запрос несуществующего Person имел такую структуру:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ApiError {
    private String message;
    private String debugMessage;
}

Для этого создадим обработчик в @ControllerAdvice, который перехватывает наше исключение MyEntityNotFoundException:

@ControllerAdvice
public class RestExceptionHandler {


    @ExceptionHandler({MyEntityNotFoundException.class, EntityNotFoundException.class})
    protected ResponseEntity<Object> handleEntityNotFoundEx(RuntimeException ex, WebRequest request) {
      ApiError apiError = new ApiError("entity not found ex", ex.getMessage());
      return new ResponseEntity<>(apiError, HttpStatus.NOT_FOUND);
    }

}

Теперь в ответ на запрос

GET localhost:8080/persons/2

мы получаем статус 404 с телом:

{
    "message": "entity not found ex",
    "debugMessage": "Entity is not found, id=2"
}

Но помимо MyEntityNotFoundException, наш обработчик  поддерживает и javax.persistence.EntityNotFoundException (см. код выше).

Попробуем сделать так, чтобы оно возникло.

Это исключение EntityNotFoundException возникает в методе updatePerson() в контроллера. А именно, когда мы обращаемся с помощью метода PUT к несуществующей сущности в попытке назначить ей имя:

PUT localhost:8080/persons/2

{
    "name": "new name"
}

В этом случае мы тоже получим ответ с новой структурой:

{
    "message": "entity not found ex",
    "debugMessage": "Unable to find ru.sysout.model.Person with id 2"
}

Итого, обработчик в @ControllerAdvice позволил изменить не только код ответа, но и тело сообщение. Причем один обработчик мы применили для двух исключений.

Последовательность проверок

Обратите внимание, что MyEntityNotFoundException мы «обработали» дважды — изменили код с помощью @ResponseStatus (1) и прописали в @ContollerAdvice — тут изменили как код, так и тело ответа (2). Эти обработки могли быть противоречивы, но существует приоритет:

  • Когда выбрасывается исключение MyEntityNotFoundException, сначала Spring проверяет @ControllerAdvice-класс. А именно, нет ли в нем обработчика, поддерживающего наше исключение. Если обработчик есть, то исключение в нем и обрабатывается. В этом случае код @ResponseStatus значения не имеет, и в BasicErrorController исключение тоже не идет.
  • Если исключение не поддерживается в @ControllerAdvice-классе, то оно идет в BasicErrorController. Но перед этим Spring проверяет, не аннотировано ли исключение аннотацией @ResponseStatus. Если да, то код ответа меняется, как указано в @ResponseStatus. Далее формируется ответ в BasicErrorController.
  • Если же первые два условия не выполняются, то исключение обрабатывается сразу в BasicErrorController — там формируется стандартный ответ со стандартным кодом (для пользовательских исключений он равен 500).

Но и стандартный ответ можно изменить, для этого нужно расширить класс DefaultErrorAttributes.

Попробуем это сделать.

Изменение DefaultErrorAttributes

Давайте добавим в стандартный ответ еще одно поле. Для этого расширим класс:

@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {


    @Override
    public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
        Map<String, Object> errorAttributes=super.getErrorAttributes(webRequest, options);
        errorAttributes.put("newAttribute", "value");
        return errorAttributes;
    }

}

В Map errorAttributes перечисляются поля ответа. Мы взяли их из родительского метода и добавили свое поле newAttribute.

Чтобы выполнить проверку, надо убрать @ControllerAdvice, поскольку он самый приоритетный и с ним мы даже не дойдем до BasicErrorController со «стандартными» полями.

Далее запросим ресурс:

localhost:8080/persons/2

Получим ответ:

{
    "timestamp": "2021-02-28T18:50:14.479+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "Entity is not found, id=2",
    "path": "/persons/2",
    "newAttribute": "value"
}

В JSON-ответе появилось дополнительное поле.

ResponseStatusException

Рассмотрим еще один вариант, позволяющий сразу протолкнуть код ответа и сообщение стандартные поля, не прописывая обработку пользовательских или  встроенных исключений.  А вместо этого просто выбросив специально предназначенное исключение ResponseStatusException.

Изменим код метода контроллера getPerson():

@GetMapping(value = "/{personId}")
public Person getPerson(@PathVariable("personId") long personId){
    return personRepository.findById(personId).orElseThrow(() -> new ResponseStatusException(
            HttpStatus.NOT_FOUND, "Person Not Found"));
}

Теперь тут не выбрасывается ни MyEntityNotFoundException, ни java.util.NoSuchElementException. А выбрасывается ResponseStatusException с заданным сообщением и кодом ответа.

Теперь при запросе

GET localhost:8080/persons/2

ответ будет таким:

{
    "timestamp": "2021-03-01T07:42:19.164+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "Person Not Found",
    "path": "/persons/2",
    "newAttribute": "value"
}

Как код, так и сообщение появилось в полях стандартного ответа.

ResponseStatusException не вступает в конкуренцию ни со способом @ControllerAdvice, ни с @ResponseStatus — просто потому, что это другое исключение.

Итоги

Код примера доступен на GitHub. В следующей части мы унаследуем RestExceptionHandler от ResponseEntityExceptionHandler. Это класс-заготовка, которая уже обрабатывает ряд исключений.

Spring Boot REST API – обработка исключений. Часть 1: 4 комментария

  1. А почему свое исключение унаследовано от RuntimeException, т.е. от непроверяемых исключений?

    1. иначе пришлось бы его ловить в вызывающих методах, а мы его в отдельном месте ловим специально (в этом и суть).

    2. Потому что в данном случае не нужно обрабатывать это исключение в коде, а надо пробросить его далее к разрабам фронта, чтобы они поняли в чём накосячили.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *