«1. Обзор

В этом руководстве показано, как реализовать обработку исключений с помощью Spring для REST API. Мы также получим небольшой исторический обзор и посмотрим, какие новые опции были представлены в разных версиях.

До Spring 3.2 двумя основными подходами к обработке исключений в приложении Spring MVC были HandlerExceptionResolver или аннотация @ExceptionHandler. У обоих есть явные недостатки.

Начиная с версии 3.2, у нас есть аннотация @ControllerAdvice для устранения ограничений предыдущих двух решений и продвижения единой обработки исключений во всем приложении.

Теперь в Spring 5 представлен класс ResponseStatusException — быстрый способ базовой обработки ошибок в наших REST API.

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

Наконец, мы увидим, что предлагает Spring Boot и как мы можем настроить его в соответствии с нашими потребностями.

2. Решение 1: @ExceptionHandler уровня контроллера

Первое решение работает на уровне @Controller. Мы определим метод для обработки исключений и аннотируем его с помощью @ExceptionHandler:

public class FooController{
    
    //...
    @ExceptionHandler({ CustomException1.class, CustomException2.class })
    public void handleException() {
        //
    }
}

Этот подход имеет большой недостаток: аннотированный метод @ExceptionHandler активен только для этого конкретного контроллера, а не глобально для всего приложения. Конечно, добавление этого к каждому контроллеру делает его не очень подходящим для общего механизма обработки исключений.

Мы можем обойти это ограничение, если все контроллеры расширяют класс базового контроллера.

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

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

3. Решение 2: HandlerExceptionResolver

Второе решение заключается в определении HandlerExceptionResolver. Это разрешит любое исключение, созданное приложением. Это также позволит нам реализовать единый механизм обработки исключений в нашем REST API.

Прежде чем переходить к пользовательскому преобразователю, давайте рассмотрим существующие реализации.

3.1. ExceptionHandlerExceptionResolver

Этот преобразователь был введен в Spring 3.1 и включен по умолчанию в DispatcherServlet. На самом деле это основной компонент того, как работает механизм @ExceptionHandler, представленный ранее.

3.2. DefaultHandlerExceptionResolver

Этот преобразователь был представлен в Spring 3.0 и включен по умолчанию в DispatcherServlet.

Он используется для разрешения стандартных исключений Spring для соответствующих кодов состояния HTTP, а именно кодов состояния ошибки клиента 4xx и ошибки сервера 5xx. Вот полный список исключений Spring, которые он обрабатывает, и то, как они сопоставляются с кодами состояния.

Хотя он правильно устанавливает код состояния ответа, одним ограничением является то, что он ничего не устанавливает в теле ответа. А для REST API — кода состояния на самом деле недостаточно информации для предоставления клиенту — ответ также должен иметь тело, чтобы приложение могло предоставить дополнительную информацию о сбое.

Это можно решить, настроив разрешение представления и отрисовав содержимое ошибки через ModelAndView, но решение явно не оптимальное. Вот почему Spring 3.2 представил лучший вариант, который мы обсудим в следующем разделе.

3.3. ResponseStatusExceptionResolver

Этот преобразователь также был представлен в Spring 3.0 и включен по умолчанию в DispatcherServlet.

Его основная обязанность — использовать аннотацию @ResponseStatus, доступную для пользовательских исключений, и сопоставлять эти исключения с кодами состояния HTTP.

Такое пользовательское исключение может выглядеть так:

@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class MyResourceNotFoundException extends RuntimeException {
    public MyResourceNotFoundException() {
        super();
    }
    public MyResourceNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
    public MyResourceNotFoundException(String message) {
        super(message);
    }
    public MyResourceNotFoundException(Throwable cause) {
        super(cause);
    }
}

«

«Так же, как и DefaultHandlerExceptionResolver, этот преобразователь ограничен в том, как он работает с телом ответа — он сопоставляет код состояния с ответом, но тело по-прежнему имеет значение null.

3.4. SimpleMappingExceptionResolver и AnnotationMethodHandlerExceptionResolver

SimpleMappingExceptionResolver существует уже довольно давно. Он исходит из старой модели Spring MVC и не очень актуален для службы REST. В основном мы используем его для сопоставления имен классов исключений с именами представлений.

AnnotationMethodHandlerExceptionResolver был введен в Spring 3.0 для обработки исключений с помощью аннотации @ExceptionHandler, но ExceptionHandlerExceptionResolver устарел, начиная с Spring 3.2.

3.5. Пользовательский HandlerExceptionResolver

Комбинация DefaultHandlerExceptionResolver и ResponseStatusExceptionResolver имеет большое значение для обеспечения хорошего механизма обработки ошибок для службы Spring RESTful. Недостатком является, как упоминалось ранее, отсутствие контроля над телом ответа.

В идеале мы хотели бы иметь возможность выводить либо JSON, либо XML, в зависимости от того, какой формат запросил клиент (через заголовок Accept).

@Component
public class RestResponseStatusExceptionResolver extends AbstractHandlerExceptionResolver {

    @Override
    protected ModelAndView doResolveException(
      HttpServletRequest request, 
      HttpServletResponse response, 
      Object handler, 
      Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) {
                return handleIllegalArgument(
                  (IllegalArgumentException) ex, response, handler);
            }
            ...
        } catch (Exception handlerException) {
            logger.warn("Handling of [" + ex.getClass().getName() + "] 
              resulted in Exception", handlerException);
        }
        return null;
    }

    private ModelAndView 
      handleIllegalArgument(IllegalArgumentException ex, HttpServletResponse response) 
      throws IOException {
        response.sendError(HttpServletResponse.SC_CONFLICT);
        String accept = request.getHeader(HttpHeaders.ACCEPT);
        ...
        return new ModelAndView();
    }
}

Уже одно это оправдывает создание нового пользовательского преобразователя исключений:

Здесь следует отметить одну деталь: у нас есть доступ к самому запросу, поэтому мы можем учитывать значение заголовка Accept, отправленного клиентом.

Например, если клиент запрашивает application/json, то в случае ошибки мы хотели бы убедиться, что возвращаем тело ответа, закодированное с помощью application/json.

Другая важная деталь реализации заключается в том, что мы возвращаем ModelAndView — это тело ответа, и оно позволит нам установить в нем все, что необходимо.

Этот подход представляет собой согласованный и легко настраиваемый механизм обработки ошибок службы Spring REST.

Однако у него есть ограничения: он взаимодействует с низкоуровневым HtttpServletResponse и вписывается в старую модель MVC, использующую ModelAndView, так что еще есть возможности для улучшения.

4. Решение 3: @ControllerAdvice

Spring 3.2 поддерживает глобальный @ExceptionHandler с аннотацией @ControllerAdvice.

@ControllerAdvice
public class RestResponseEntityExceptionHandler 
  extends ResponseEntityExceptionHandler {

    @ExceptionHandler(value 
      = { IllegalArgumentException.class, IllegalStateException.class })
    protected ResponseEntity<Object> handleConflict(
      RuntimeException ex, WebRequest request) {
        String bodyOfResponse = "This should be application specific";
        return handleExceptionInternal(ex, bodyOfResponse, 
          new HttpHeaders(), HttpStatus.CONFLICT, request);
    }
}

Это включает механизм, который отходит от старой модели MVC и использует ResponseEntity наряду с безопасностью типов и гибкостью @ExceptionHandler:

Аннотация @ControllerAdvice позволяет нам объединить наши многочисленные разбросанные @ ExceptionHandlers из предыдущего в единый глобальный компонент обработки ошибок.

    Фактический механизм чрезвычайно прост, но также и очень гибок:

Он дает нам полный контроль над телом ответа, а также над кодом состояния. Он обеспечивает сопоставление нескольких исключений с одним и тем же методом для совместной обработки. Он хорошо использует новый ответ RESTful ResposeEntity.

Здесь следует помнить одну вещь: исключения, объявленные с помощью @ExceptionHandler, должны сопоставляться с исключением, используемым в качестве аргумента метода.

Если они не совпадают, компилятор не будет жаловаться — нет причин, по которым он должен — и Spring тоже не будет жаловаться.

java.lang.IllegalStateException: No suitable resolver for argument [0] [type=...]
HandlerMethod details: ...

Однако, когда исключение фактически генерируется во время выполнения, механизм разрешения исключений дает сбой:

5. Решение 4: ResponseStatusException (Spring 5 и выше)

Spring 5 представил класс ResponseStatusException.

@GetMapping(value = "/{id}")
public Foo findById(@PathVariable("id") Long id, HttpServletResponse response) {
    try {
        Foo resourceById = RestPreconditions.checkFound(service.findOne(id));

        eventPublisher.publishEvent(new SingleResourceRetrievedEvent(this, response));
        return resourceById;
     }
    catch (MyResourceNotFoundException exc) {
         throw new ResponseStatusException(
           HttpStatus.NOT_FOUND, "Foo Not Found", exc);
    }
}

Мы можем создать его экземпляр, предоставляющий HttpStatus и, возможно, причину и причину:

    Каковы преимущества использования ResponseStatusException?

Отлично подходит для прототипирования: мы можем довольно быстро реализовать базовое решение. Один тип, несколько кодов состояния: один тип исключения может привести к нескольким различным ответам. Это уменьшает тесную связь по сравнению с @ExceptionHandler. Нам не нужно будет создавать столько настраиваемых классов исключений. У нас больше контроля над обработкой исключений, поскольку исключения можно создавать программно.

    А как насчет компромиссов?

«Единого способа обработки исключений не существует: обеспечить соблюдение некоторых соглашений для всего приложения сложнее, чем @ControllerAdvice, который обеспечивает глобальный подход. Дублирование кода: мы можем обнаружить, что дублируем код на нескольких контроллерах.

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

Например, мы можем реализовать @ControllerAdvice глобально, а также локально ResponseStatusExceptions.

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

Дополнительные сведения и дополнительные примеры см. в нашем руководстве по ResponseStatusException.

6. Обработка отказа в доступе в Spring Security

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

6.1. MVC — пользовательская страница ошибок

Во-первых, давайте рассмотрим стиль решения MVC и посмотрим, как настроить страницу ошибки для отказа в доступе.

<http>
    <intercept-url pattern="/admin/*" access="hasAnyRole('ROLE_ADMIN')"/>   
    ... 
    <access-denied-handler error-page="/my-error-page" />
</http>

Конфигурация XML:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .antMatchers("/admin/*").hasAnyRole("ROLE_ADMIN")
        ...
        .and()
        .exceptionHandling().accessDeniedPage("/my-error-page");
}

И конфигурация Java:

Когда пользователи пытаются получить доступ к ресурсу, не имея достаточных прав, они будут перенаправлены на «/my-error-page». € .

6.2. Пользовательский AccessDeniedHandler

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle
      (HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) 
      throws IOException, ServletException {
        response.sendRedirect("/my-error-page");
    }
}

Далее давайте посмотрим, как написать наш собственный AccessDeniedHandler:

<http>
    <intercept-url pattern="/admin/*" access="hasAnyRole('ROLE_ADMIN')"/> 
    ...
    <access-denied-handler ref="customAccessDeniedHandler" />
</http>

А теперь давайте настроим его, используя конфигурацию XML:

@Autowired
private CustomAccessDeniedHandler accessDeniedHandler;

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .antMatchers("/admin/*").hasAnyRole("ROLE_ADMIN")
        ...
        .and()
        .exceptionHandling().accessDeniedHandler(accessDeniedHandler)
}

0r, используя конфигурацию Java:

Обратите внимание, как в нашем CustomAccessDeniedHandler мы можем настроить ответ по своему усмотрению, перенаправив или отобразив собственное сообщение об ошибке.

6.3. REST и безопасность на уровне методов

Наконец, давайте посмотрим, как обрабатывать безопасность на уровне методов @PreAuthorize, @PostAuthorize и @Secure Access Denied.

@ControllerAdvice
public class RestResponseEntityExceptionHandler 
  extends ResponseEntityExceptionHandler {

    @ExceptionHandler({ AccessDeniedException.class })
    public ResponseEntity<Object> handleAccessDeniedException(
      Exception ex, WebRequest request) {
        return new ResponseEntity<Object>(
          "Access denied message here", new HttpHeaders(), HttpStatus.FORBIDDEN);
    }
    
    ...
}

Конечно, мы будем использовать механизм глобальной обработки исключений, который обсуждался ранее, для обработки AccessDeniedException:

7. Поддержка Spring Boot

Spring Boot предоставляет реализацию ErrorController для обработки ошибок в разумный способ.

{
    "timestamp": "2019-01-17T16:12:45.977+0000",
    "status": 500,
    "error": "Internal Server Error",
    "message": "Error processing the request!",
    "path": "/my-endpoint-with-exceptions"
}

В двух словах, он служит резервной страницей ошибок для браузеров (также известной как страница ошибок Whitelabel) и ответом JSON для запросов RESTful, отличных от HTML:

    Как обычно, Spring Boot позволяет настраивать эти функции с помощью свойства:

server.error.whitelabel.enabled: может использоваться для отключения страницы ошибок Whitelabel и полагаться на контейнер сервлета для предоставления HTML-сообщения об ошибке server.error.include-stacktrace: со значением always; включает трассировку стека как в HTML, так и в ответ JSON по умолчанию server.error.include-message: начиная с версии 2.3, Spring Boot скрывает поле сообщения в ответе, чтобы избежать утечки конфиденциальной информации; мы можем использовать это свойство со значением always, чтобы включить его

Помимо этих свойств, мы можем предоставить собственное сопоставление представления и разрешения для /error, переопределяющее страницу Whitelabel.

@Component
public class MyCustomErrorAttributes extends DefaultErrorAttributes {

    @Override
    public Map<String, Object> getErrorAttributes(
      WebRequest webRequest, ErrorAttributeOptions options) {
        Map<String, Object> errorAttributes = 
          super.getErrorAttributes(webRequest, options);
        errorAttributes.put("locale", webRequest.getLocale()
            .toString());
        errorAttributes.remove("error");

        //...

        return errorAttributes;
    }
}

Мы также можем настроить атрибуты, которые мы хотим показать в ответе, включив в контекст bean-компонент ErrorAttributes. Мы можем расширить класс DefaultErrorAttributes, предоставляемый Spring Boot, чтобы упростить задачу:

Если мы хотим пойти дальше и определить (или переопределить), как приложение будет обрабатывать ошибки для определенного типа контента, мы можем зарегистрировать ErrorController. боб.

Опять же, мы можем использовать BasicErrorController по умолчанию, предоставленный Spring Boot, чтобы помочь нам.

@Component
public class MyErrorController extends BasicErrorController {

    public MyErrorController(
      ErrorAttributes errorAttributes, ServerProperties serverProperties) {
        super(errorAttributes, serverProperties.getError());
    }

    @RequestMapping(produces = MediaType.APPLICATION_XML_VALUE)
    public ResponseEntity<Map<String, Object>> xmlError(HttpServletRequest request) {
        
    // ...

    }
}

Например, представьте, что мы хотим настроить, как наше приложение обрабатывает ошибки, вызванные в конечных точках XML. Все, что нам нужно сделать, это определить общедоступный метод с помощью @RequestMapping и указать, что он создает тип носителя application/xml:

Примечание: здесь мы все еще полагаемся на свойства server.error.* Boot, которые мы могли бы были определены в нашем проекте, которые привязаны к bean-компоненту ServerProperties.

8. Заключение

«В этой статье обсуждалось несколько способов реализации механизма обработки исключений для REST API в Spring, начиная с более старого механизма и продолжая поддержкой Spring 3.2 и переходя в версии 4.x и 5.x.

Как всегда, код, представленный в этой статье, доступен на GitHub.