Перейти к содержанию

Filters, Interceptors и исключения

Три механизма вокруг контроллера, в порядке пути запроса: servlet-фильтры работают до DispatcherServlet, interceptors - внутри MVC вокруг метода контроллера, обработчики исключений - когда что-то упало.

TCP-соединение
    └─► Servlet Container (Tomcat)
            └─► Filter Chain (Servlet API)
                    └─► DispatcherServlet          ← здесь начинается Spring MVC
                            └─► HandlerInterceptors
                            └─► Controller
                            └─► HandlerExceptionResolver, если исключение

Filters

Filter (jakarta.servlet.Filter) - компонент Servlet API, который выполняется вокруг сервлета. Работает на уровне servlet-контейнера, до DispatcherServlet, и не знает, какой контроллер будет вызван.

@Component
public class RequestLoggingFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        // до сервлета
        chain.doFilter(request, response);   // следующий фильтр или сервлет
        // после сервлета
    }
}

Фильтр, объявленный Spring-бином (@Component или FilterRegistrationBean), Spring Boot сам регистрирует в servlet-контейнере, и в нём доступны другие бины.

Типовые фильтры:

Filter Назначение
CharacterEncodingFilter Кодировка UTF-8
CorsFilter CORS-заголовки
OncePerRequestFilter Базовый класс: гарантирует один вызов на запрос
Цепочка Spring Security Вся защита (см. Spring Security)
CommonsRequestLoggingFilter Логирование запросов
ShallowEtagHeaderFilter ETag для кеширования

Interceptors

HandlerInterceptor - компонент Spring MVC, который встраивается в обработку запроса внутри DispatcherServlet. В отличие от фильтра знает handler (метод контроллера) и видит ModelAndView. Вызывается в составе HandlerExecutionChain.

preHandle - до вызова контроллера. Вернул false - обработка прерывается, ответ к этому моменту уже должен быть записан.

@Override
public boolean preHandle(HttpServletRequest request,
                         HttpServletResponse response,
                         Object handler) throws Exception {
    request.setAttribute("startTime", System.currentTimeMillis());
    log.info("Request: {} {}", request.getMethod(), request.getRequestURI());
    return true;
}

postHandle - после контроллера, но до рендеринга view.

  • не вызывается, если контроллер выбросил исключение
  • при @ResponseBody и @RestController вызывается, но ModelAndView обычно null: ответ уже записал HttpMessageConverter
@Override
public void postHandle(HttpServletRequest request,
                       HttpServletResponse response,
                       Object handler,
                       ModelAndView modelAndView) throws Exception {
    if (modelAndView != null) {
        modelAndView.addObject("globalData", globalService.getData());   // общие данные во все view
    }
}

afterCompletion - после завершения запроса, для interceptor'ов, чей preHandle вернул true. По смыслу как finally: вызывается и при успехе, и при исключении.

@Override
public void afterCompletion(HttpServletRequest request,
                            HttpServletResponse response,
                            Object handler,
                            Exception ex) throws Exception {
    long startTime = (Long) request.getAttribute("startTime");
    log.info("Request completed in {}ms, exception: {}", System.currentTimeMillis() - startTime, ex);
    // здесь же чистят ThreadLocal, MDC
}

Для чего используют:

  • время выполнения запроса
  • лёгкая проверка токена или сессии без Spring Security
  • установка Locale
  • общие данные во view через postHandle
  • аудит
  • лёгкий rate limiting
  • MDC и трейсинг: correlation ID в ThreadLocal

Регистрация:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoggingInterceptor())
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/public/**");
    }
}

Filter и Interceptor

Servlet Filter HandlerInterceptor
Спецификация Servlet API Spring MVC
Где До и вокруг DispatcherServlet Внутри MVC, после DispatcherServlet
Знает handler Нет Да, знает метод контроллера
Видит ModelAndView Нет Да, в postHandle
Spring-бины Да, если фильтр зарегистрирован как бин (@Component, FilterRegistrationBean, security chain) Да, сам бин
Может читать и менять тело Да, через обёртку запроса или ответа Не напрямую
Для чего CORS, GZIP, безопасность, кодировка Логирование, лёгкая авторизация, locale, метрики

Итого: нужно до Spring MVC или с телом запроса - фильтр. Нужно знать, какой метод контроллера вызывается, или трогать модель - interceptor. Ошибки контроллеров - @ControllerAdvice (ниже).


Обработка исключений

Исключения возникают при binding'е и валидации, в контроллере, в сервисном слое, при сериализации ответа. DispatcherServlet перехватывает их и передаёт в HandlerExceptionResolver (см. резолверы).

Note

Исключения из servlet-фильтров, в том числе из Spring Security, сюда не попадают: фильтры работают до DispatcherServlet. Их переводит в ответ ExceptionTranslationFilter (см. Spring Security).


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

@Controller
public class UserController {

    @GetMapping("/users/{id}")
    public UserDto getUser(@PathVariable Long id) {
        return userService.findById(id);   // бросает UserNotFoundException
    }

    @ExceptionHandler(UserNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(UserNotFoundException ex) {
        return new ErrorResponse(ex.getMessage());
    }
}

Метод может вернуть:

  • имя view или ModelAndView - для HTML
  • DTO или ProblemDetail - для REST
  • ResponseEntity<?> - если нужно управлять статусом и заголовками

@ControllerAdvice - глобальный advice для всех контроллеров: обработчики исключений, общие атрибуты модели (@ModelAttribute), настройка binder'ов (@InitBinder).

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public String handleNotFound(UserNotFoundException ex, Model model) {
        model.addAttribute("error", ex.getMessage());
        return "error/404";
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public String handleGeneral(Exception ex) {
        return "error/500";
    }
}

Область можно сузить: @ControllerAdvice("com.example.controller") или @ControllerAdvice(assignableTypes = UserController.class).

@RestControllerAdvice - @ControllerAdvice + @ResponseBody: все методы возвращают тело ответа, а не имя view.

@RestControllerAdvice
public class GlobalRestExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(UserNotFoundException ex) {
        return new ErrorResponse("NOT_FOUND", ex.getMessage());
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleGeneral(Exception ex) {
        return new ErrorResponse("INTERNAL_ERROR", "Unexpected error occurred");   // детали наружу не отдаём
    }
}

Обработка ошибок валидации (MethodArgumentNotValidException, HandlerMethodValidationException) - в Валидации.


Аннотации и классы для ошибок:

Аннотация или класс Где Что делает
@ExceptionHandler Метод Обрабатывает исключения указанного типа
@ControllerAdvice Класс Глобальный advice: исключения, общие атрибуты модели, binder'ы
@RestControllerAdvice Класс @ControllerAdvice + @ResponseBody, для JSON API
@ResponseStatus Метод или класс исключения Задаёт статус. Для сложных REST-ошибок лучше ResponseEntity или ProblemDetail
ResponseStatusException throw из кода Быстро вернуть статус и причину без своего класса исключения
ProblemDetail Результат или тело Стандартный формат ошибок HTTP API, application/problem+json (RFC 9457)
ResponseEntityExceptionHandler Базовый класс для advice Готовые обработчики стандартных исключений Spring MVC, которые можно переопределить

Ошибки в HTML

@ControllerAdvice
public class HtmlExceptionHandler {
    @ExceptionHandler(AccessDeniedException.class)
    public ModelAndView handleAccessDenied(AccessDeniedException ex) {
        ModelAndView mav = new ModelAndView("error/403");
        mav.addObject("message", ex.getMessage());
        return mav;
    }
}

Ошибки в JSON

Подход для REST API:

  1. Иерархия своих исключений: BusinessException, NotFoundException, ConflictException.
  2. Один @RestControllerAdvice на всё приложение.
  3. Единый формат ошибок: свой DTO или стандартный ProblemDetail (см. Единый формат ошибок).

Свой DTO:

public record ErrorResponse(
    String code,
    String message,
    List<String> details,
    Instant timestamp
) {}

@RestControllerAdvice
public class JsonExceptionHandler {
    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", ex.getMessage(), List.of(), Instant.now()));
    }
}

ProblemDetail:

@RestControllerAdvice
public class ApiExceptionHandler {
    @ExceptionHandler(UserNotFoundException.class)
    public ProblemDetail handleNotFound(UserNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        problem.setTitle("User not found");
        problem.setDetail(ex.getMessage());
        problem.setProperty("code", "USER_NOT_FOUND");   // своё поле в ответе
        return problem;
    }
}

Note

В исходной версии на @RestControllerAdvice стояло @RequestMapping(produces = APPLICATION_JSON_VALUE). На advice-классе эта аннотация ничего не делает: тип ответа определяет @ResponseBody и заголовок Accept.