Обработка ошибок в формате JSON со Spring Boot

в 19:03, , рубрики: java, java spring

Часто при работе с микросервисами, построенными с помощью технологии Spring Boot, можно видеть стандартный вывод ошибок подобный этому:

{
    "timestamp": 1510417124782,
    "status": 500,
    "error": "Internal Server Error",
    "exception": "com.netflix.hystrix.exception.HystrixRuntimeException",
    "message": "ApplicationRepository#save(Application) failed and no fallback available.",
    "path": "/application"
}

Такой вывод может быть излишним и ненужным клиентам вашего сервиса. Если вы хотите упростить жизнь сторонним сервисам в случае ошибки, то как раз об этом и пойдет речь в данном посте.

Начнем мы с построения небольшого сервиса с одним контроллером. Наш сервис будет принимать запрос на получение пользователя и в случае успеха отдавать данные по пользователю. В случае провала к нам возвращается ошибка. Начнем с простого и далее в нашей статье будем усовершенствовать наш проект.

Итак, первое, что нам понадобится, это пользователь:

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
    private int id;
    private String firstName;
    private String lastName;
}

Здесь я использовал библиотеку lombok. Аннотация Data подставляет геттеры и сеттеры в класс. Остальные аннотации добавляют пустой конструктор и конструктор с параметрами. Если вы хотите повторить данный пример у себя в IntelliJ Idea, то вам необходимо поставить галочку в пункте enable annotation processing, либо написать все руками.

Далее нам понадобится сервис (для краткости репозиторий создавать не будем):

import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

@Service
public class UserService {

    private static final Map<Integer, User> userStorage
            = new HashMap<>();

    static {
        userStorage.put(1, new User(1, "Petr", "Petrov"));
        userStorage.put(2, new User(2, "Ivan", "Ivanov"));
        userStorage.put(3, new User(3, "Sergei", "Sidorov"));
    }

    public User get(int id) {
        return userStorage.get(id);
    }

}

Ну и, конечно, сам контроллер:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("user")
public class UserController {

    private UserService userService;

    @Autowired
    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("{id}")
    public User get(@PathVariable(name = "id") int id) {
        return userService.get(id);
    }

}

Итак, у нас есть почти полноценный сервис с пользователями. Запускаем его и и смотрим.

При запросе на URL localhost:8080/user/1 нам возвращается json в таком формате:

{
    "id": 1,
    "firstName": "Petr",
    "lastName": "Petrov"
}

Все отлично. Но что будет, если сделать запрос на URL localhost:8080/user/4 (у нас всего 3 пользователя)? Правильный ответ: мы получим статус 200 и ничего в ответе. Ситуация не особо приятная. Ошибки нет, но и запрашиваемого объекта тоже нет.

Давайте улучшим наш сервис и добавим в него выбрасывание ошибки в случае неудачи. Для начала создадим исключение:

public class ThereIsNoSuchUserException extends RuntimeException { }

Теперь добавим пробрасывание ошибки в сервис:

public User get(int id) {
    User user = userStorage.get(id);
    if (user == null) {
        throw new ThereIsNoSuchUserException();
    }
    
    return user;
}

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

{
    "timestamp": 1510479979781,
    "status": 500,
    "error": "Internal Server Error",
    "exception": "org.faoxis.habrexception.ThereIsNoSuchUserException",
    "message": "No message available",
    "path": "/user/4"
}

Это уже лучше. Гораздо более информативно и код статуса не 200. Такую ситуацию клиент на своей стороне уже сможет успешно и легко обработать. Но, как говорится, есть один нюанс. Ошибки могут быть совершенно разными, и клиенту нашего сервиса придется ставить кучу условных операторов и исследовать, что у нас пошло не так и как это можно поправить. Получается немного грубо с нашей стороны.

Как раз для таких случаев и была придумана аннотация ResponseStatus. Подставим ее на место нашего исключения и на практике посмотрим, как это работает:

@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "There is no such user")
public class ThereIsNoSuchUserException extends RuntimeException {
}</source
Повторим запрос и посмотрим результат:

<source lang="json">{
    "timestamp": 1510480307384,
    "status": 404,
    "error": "Not Found",
    "exception": "org.faoxis.habrexception.ThereIsNoSuchUserException",
    "message": "There is no such user",
    "path": "/user/4"
}

Отлично! Код статуса и сообщение поменялись. Теперь клиент сможет определись по коду ответа причину ошибки и даже уточнить ее по полю message. Но все же есть проблема. Большинство полей клиенту могут быть просто не нужны. Например, код ответа как отдельное поле может быть излишним, поскольку мы его и так получаем с кодом ответа. С этим нужно что-то делать.

К счастью, со spring boot сделать последний шаг к нашему успешному оповещению об ошибке не так сложно.

Все, что для этого требуется, разобрать пару аннотаций и один класс:

  • Аннотация ExceptionHandler. Используется для обработки собственных и каких-то специфичных исключений. Далее в примере будет понятно, что это значит. На всякий случай ссылка на документацию.
  • Аннотация ServiceAdvice. Данная аннотация дает «совет» группе констроллеров по определенным событиям. В нашем случае — это обработка ошибок. По умолчанию применяется ко всем контроллерам, но в параметрах можно указать отпределенную группу. Подбронее тут.
  • Класс ResponseEntityExceptionHandler. Данный класс занимается обработкой ошибок. У него куча методов, название которых построенно по принципу handle + название исключения. Если мы хотим обработать какое-то базовое исключение, то наследуемся от этого класса и переопределяем нужный метод.

Давайте теперь посмотрим, как все это обЪединить и построить наше уникальное и неповторимое сообщение об ошибке:

import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@ControllerAdvice
public class AwesomeExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(ThereIsNoSuchUserException.class)
    protected ResponseEntity<AwesomeException> handleThereIsNoSuchUserException() {
        return new ResponseEntity<>(new AwesomeException("There is no such user"), HttpStatus.NOT_FOUND);
    }

    @Data
    @AllArgsConstructor
    private static class AwesomeException {
        private String message;
    }
}

Сделаем все тот же запрос и увидим статус ответа 404 и наше сообщение с единственным полем:

{
    "message": "There is no such user"
}

Аннотацию ResponseStatus над нашим исключением можно смело убирать.

В итоге у нас получилось приложение, в котором обработка ошибок настраивается максимально гибко и просто. Полный проект можно найти в репозитории github. Надеюсь, что все было просто и понятно. Спасибо за внимание и пишите комментарии! Буду рад вашим замечаниям и уточнениям!

Автор: Самойлов Сергей

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js