Всем привет! Проверяя задания в учебном центре моей компании, обнаружил, что двумя словами описать то, как можно избавиться от ResponseEntity<?> в контроллерах не получится, и необходимо написать целую статью. Для начала, немного введения.

ВАЖНО! Статья написана для новичков в программировании и Spring в часности, которые знакомы со Spring на базовом уровне.

Что такое ResponseEntity<>? Представим ситуацию - у нас есть интернет магазин. И, при примитивной реализации, мы переходим по продуктам, передавая его Id в качестве параметра@RequestParam. Например, наш код выглядит таким образом:

    @ResponseBody
    @GetMapping("/products")
    public Product getProduct(@RequestParam Long id){
        return productsService.findById(id);
    }

При запросе через адресную строку браузера, вывод будет в виде JSON, таким:

{"id":1,"title":"Milk","price":100}

Однако, если мы обратимся к продукту, который у нас отсутствует, например с id=299, то получим следующую картину:

Для пользователя, или даже для фронтендщика, будет абсолютно непонятно, что пошло не так и в чём проблема. Совершая тот же запрос через Postman, ситуация яснее не будет:

{
    "timestamp": "2022-06-30T18:21:03.634+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/app/api/v1/products"
}

И вот тут мы переходим к ResponseEntity<>. Этот объект представляет собой оболочку для Java классов, благодаря которой мы в полной мере сможем реализовать RESTfull архитектуру. Суть использования сводится к тому, чтобы вместо прямого возвращаемого типа данных в контроллере, использовать оболочку ResponseEntity<> и возвращать конечному пользователю, или, что скорее всего вероятно - фронту, JSON, который бы более-менее подробно описывал ошибку. Выглядит такой код примерно так:

    @GetMapping("/products")
    public ResponseEntity<?> getProductRe(Long id){
        try {
            Product product = productsService.findById(id).orElseThrow();
            return new ResponseEntity<>(product, HttpStatus.OK);
        } catch (Exception e){
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }

Что здесь происходит? Вместо строгого типа Product, мы ставим ResponseEntity<?>, где под ? понимается любой Java объект. Конструктор ResponseEntity позволяет перегружать этот объект, добавляя в него не только наш возвращаемый тип, но и статус, чтобы фронтенд мог понимать, что именно пошло не так. Например, при корректном исполнении программы, передавая id=1, мы увидим просто успешно переданный объект Product с кодом 200, а вот в случае продукта с id = 299 результат уже будет такой:

Всё ещё не красиво, но уже хотя бы понятно, что продукт не найден. Мы имеем статус код 404 и фронт уже как-то может с этим работать. Это здорово, но нам бы хотелось более конкретного описания ошибки и результата. Давайте, в таком случае, создадим новый класс:

public class AppError {
    private int statusCode;
    private String message;

    public int getStatusCode() {
        return statusCode;
    }

    public void setStatusCode(int statusCode) {
        this.statusCode = statusCode;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public AppError() {
    }

    public AppError(int statusCode, String message) {
        this.statusCode = statusCode;
        this.message = message;
    }
}

Это будет вспомогательный класс. Его задача - принять наше сообщение и переслать его фронту вместе со статусом 404. Как мы это сделаем? Очень просто:

    @GetMapping("/products")
    public ResponseEntity<?> getProduct(Long id){
        try {
            Product product = productsService.findById(id).orElseThrow();
            return new ResponseEntity<>(product, HttpStatus.OK);
        } catch (Exception e){
            return new ResponseEntity<>(new AppError(HttpStatus.NOT_FOUND.value(), 
                    "Product with id " + id + " nor found"),
                    HttpStatus.NOT_FOUND);
        }
    }

В этом примере, если мы ловим ошибку, просто отдаём в конструктор ResponseEntity наш кастомный объект и статус 404. Теперь, если мы попробуем получить продукт с id = 299, то ответ будет таким:

{
    "statusCode": 404,
    "message": "Product with id 299 nor found"
}

Отлично! Этого мы и хотели. Стало понятно, в чём проблема. Фронтенд легко распарсит этот JSON и обработает наше сообщение. Однако, сам метод контроллера теперь выглядит не слишком красиво. Да и когда сталкиваешься с чужим кодом, любой из нас сразу хотел бы видеть тип объекта, который будет возвращаться, а не какой-то там ResponseEntity со знаком вопроса в скобочках. Тут мы и переходим к основному материалу статьи.

Как избавиться от ResponseEntity в сигнатуре метода контроллера, при этом сохранив информативность возвращаемой ошибки?

Для этого нам понадобиться глобальный обработчик ошибок, который нам любезно предоставляется в пакете со спрингом. Для начала, создадим какой-то свой кастомный Exception, в котором унаследуемся от RuntimeException:

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

Здесь ничего особенного. Интересное начинается дальше. Давайте внимательно посмотрим на листинг этого класса:

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    @ExceptionHandler
    public ResponseEntity<AppError> catchResourceNotFoundException(ResourceNotFoundException e) {
        log.error(e.getMessage(), e);
        return new ResponseEntity<>(new AppError(HttpStatus.NOT_FOUND.value(), e.getMessage()), HttpStatus.NOT_FOUND);
    }
 }

Начнём сверху вниз. @ControllerAdvice используется для глобальной обработки ошибок в приложении Spring. То есть любой Exception, выпадающий в нашем приложении, будет замечен нашим ControllerAdvice. @Slf4j используется для логгирования, заострять внимание мы на этом не будем. Далее создаём собственный класс, назвать его можем как угодно. И вот тут уже интересное - аннотация@ExceptionHandlerнад методом. Эта аннотация позволяет нам указать, что мы хотим перехватывать и обрабатывать исключения определённого типа, если они возникают, и зашивать их в ResponseEntity, чтобы вернуть ответ нашему фронту. В аргументах метода указываем, какую именно ошибку мы собираемся ловить. В данном случае, это наш кастомный ResourceNotFoundException. И возвращать мы будем точно такой же ResponseEntity, как и в примере выше, однако прописываем мы его уже всего 1 раз - в этом классе. Спринг на этапе обработки этой ошибки самостоятельно поймёт, что в методе нашего контроллера вместо нашего класса Product нужно будет вернуть ResponseEntity.

Теперь мы можем убрать из контроллера все ResponseEntity:

    @GetMapping("/products")
    public Product getProduct(Long id){
            return productsService.findById(id);
    }

А логику появления ошибки перенести в сервисный слой:

    public Product findById(Long id) {
        return productsRepository.findById(id).orElseThrow(
                () -> new ResourceNotFoundException("Product with id " + id + " not found"));
    }

Теперь, если продукт не будет найден, выбросится ResourceNotFoundException. Наш глобальный обработчик исключений поймает это исключение, самостоятельно преобразует его в ResponseEntity и вместо Product'a вернут JSON с подробным описанием ошибки, как и прежде:

{
    "statusCode": 404,
    "message": "Product with id 299 not found"
}

Таким образом, мы избавились от ResponseEntity и кучи лишнего кода, переписываемого из метода в метод, при этом сохранив всю функциональность, которую нам предоставляет ResponseEntity.

Комментарии (9)


  1. rogue06
    07.07.2022 17:10
    +2

    А как на счет кейса, когда мне нужно использовать ResponseEntity<> не только в случае ошибки. Например: при успешном создании объекта нам необходимо вернуть не 200 OK, а 201 CREATED. Что тогда? Придется его явно прописывать опять?


    1. lampa
      07.07.2022 17:22

      Предполагается, что для создания/изменения/удаления и просмотра вы используете разные эндпоинты

      @ResponseStatus(HttpStatus.CREATED)


      1. balakshinphil
        08.07.2022 09:52

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


        1. lampa
          08.07.2022 11:38

          "Гора" исключений на самом деле не гора, а структура для ответа ошибок. Ответы в одном месте, ошибки в другом. ResponseEntity - это конечный объект, кастомный exception - это то, что содержит объект запроса (например) и далее обрабатывается по своей логике. Постоянно наблюдаю в концепции ResponseEntity разные форматы ошибок (как одна из болей) для одной одинаковой ситуации в разных эндпоинтах. Ну и ломание дженериков вообще попахивает не очень))) В целом исчерпывающая статья https://spring.io/blog/2013/11/01/exception-handling-in-spring-mvc


  1. ak0oval
    07.07.2022 17:29
    +2

    В ControllerAdvice тоже можно избавиться от ResponseEntity (если хочется, конечно), применив аннотации ResponseBody и ResponseStatus. Пример:

        @ResponseStatus(HttpStatus.NOT_FOUND)
        @ExceptionHandler(ResourceNotFoundException.class)
        @ResponseBody
        public AppError handle(ResourceNotFoundException ex) {
          ...
        }


  1. upagge
    07.07.2022 17:36
    -1

    Все конечно замечательно, но зачем?


    1. aleksandy
      08.07.2022 06:01
      -1

      Потому что зачем просто, если можно сложно.


  1. Lewigh
    08.07.2022 09:51

    Рубрика "вредные советы". Сами создали проблему, сами героически пытаетесь ее решить непонятным образом.

    Что здесь происходит? Вместо строгого типа Product, мы ставим ResponseEntity<?>, где под ? понимается любой Java объект.

    Почему Вы сами не поставили параметр типа ответа а вместо него "?" и жалуетесь что не понятен тип?

        public Product findById(Long id) {
            return productsRepository.findById(id).orElseThrow(
                    () -> new ResourceNotFoundException("Product with id " + id + " not found"));
        }

    Вы когда логику вынесли в сервисы и радостно выбрасываете исключение, конечно же не подумали что если ресурс не найден то это не ошибка. Это вполне ожидаемое поведение. По нормальному нужно возвращать из метода Optional<Product> , и уже пользователь этого метода должен решать что делать если ресурса нет. А он может не просто бросить исключение а например:
    - использовать значение по умолчанию
    - попробовать выполнить альтернативный сценарий
    В вашем сценарии все прибито гвоздями.

    {
        "statusCode": 404,
        "message": "Product with id 299 nor found"
    }

    Т.е. фронт отправил запрос для получения Product по id=299 и когда ему приходит 404, он ну никак не сможет догадается о том что когда пошел за ресурсом Product по id=299 и получил статус код 404 - что ресурс не найден, обязательно нужно сообщение: "Product with id 299 nor found"?


  1. gwisp
    08.07.2022 09:53

    Дополню ещё, что может захотеться успешные ответы обернуть во что-нибудь.

    Для этого пригодится ResponseBodyAdvice, но он срабатывает и после обработчика ошибок, что надо будет учесть при реализации.