SPRING · SPRING MVC · SPRING BOOT · JDK 8 / 17

一个请求,要过几道关?

把 Web 项目想成一栋办公楼。先认识三个角色,再跟着一个请求走一趟。

门卫Filter

站在大楼门口。谁进楼都先过他。他还能给你换身衣服(包装 request)。

归 Servlet 规范 · Tomcat 调用
秘书Interceptor

坐在公司里面。知道你要找哪位办事员。进门前、办完后、送走后,各看你一眼。

归 Spring MVC
装修清单WebMvcConfigurer

开张前读一遍。决定秘书站哪个门、外人能不能来(跨域)、仓库在哪(静态资源)。

归 Spring MVC · 只在启动时用
配角 大楼 Tomcat,Servlet 容器 前台 DispatcherServlet,Spring MVC 总入口 办事员 Controller,真正干活 拎包入住 Spring Boot,替你装好大楼、前台和常用门卫

跟着请求走一趟

选一个剧情,点「下一步」。看请求此刻在谁手里,谁被跳过。

浏览器
TOMCAT 大楼(Servlet 容器)
门卫Filter ×N
SPRING MVC 公司
前台Dispatcher­Servlet
秘书Interceptor
办事员Controller

装修清单 WebMvcConfigurer:开张时读一次,把秘书安排在 /api/** 门口,放过 /api/login。请求来时它不出场。

1/10
浏览器 · GET /api/orders/42
我要查 42 号订单。

    什么时候上班,什么时候下班

    开张(启动)、营业(处理请求)、打烊(关闭)。看谁在哪个时刻出场。

    角色
    开张启动时
    营业每来一个请求
    请求 A请求 B请求 C
    打烊关闭时
    门卫FilterTomcat 调用
    init()只建 1 个对象
    doFilter()doFilter()doFilter()
    destroy()
    秘书InterceptorSpring Bean
    创建 Bean等清单登记
    prepostafterprepostafterprepostafter
    随容器销毁
    装修清单WebMvcConfigurerSpring 配置类
    addXxx()全部回调 1 次
    不出场
    —
    前台(配角)DispatcherServletTomcat 调用
    还没上岗
    init()doDispatch()doDispatch()doDispatch()
    destroy()

    pre / post / after = preHandle / postHandle / afterCompletion。addXxx() = addInterceptors、addCorsMappings、addResourceHandlers……多个 WebMvcConfigurer 会全部被调用、合并生效。

    线程 1 线程 2 线程 3

    一个门卫,同时接待所有人

    门卫、秘书、清单都是单例,所有请求线程共用同一个对象。别在字段里存“当前用户”。

    第 1 个请求

    前台等第一个客人才上岗

    默认第一次请求才 init(),日志里会出现 Initializing Servlet 'dispatcherServlet'。想开张就上岗:spring.mvc.servlet.load-on-startup=1。

    一张表看区别

    前两位是关卡,第三位是配置入口。

    对比项 门卫Filter 秘书Interceptor 装修清单WebMvcConfigurer
    出身Servlet 规范,Tomcat 调用Spring MVCSpring MVC
    是不是关卡是是不是,只是配置入口
    站在哪DispatcherServlet 外面DispatcherServlet 里面不站岗
    管多宽进 Tomcat 的所有请求,按 URL 挑进了 DispatcherServlet 并找到处理者的请求(包括静态资源、/error),按路径挑—
    知道要调哪个方法不知道知道,看 handler 参数—
    能把 request 换掉能,往下传一个 Wrapper不能—
    怎么拦下不调用 chain.doFilter()preHandle 返回 false—
    抛的异常进 @ControllerAdvice不会会(afterCompletion 里抛的除外)—
    调用次数init ×1,doFilter 每次,destroy ×1每次请求 pre / post / after启动时 ×1
    多个时的顺序@Order 或 setOrder(),数字小的在外层按注册顺序(或 .order());pre 正序,post、after 倒序全部生效,合并
    接口jakarta.servlet.Filter
    JDK 8 / Boot 2:javax.servlet.Filter
    HandlerInterceptorWebMvcConfigurer

    项目里的活儿派给谁

    按“要不要知道目标方法”“要不要换 request”来分,基本不会派错。

    门卫
    登录认证、鉴权Spring Security 本身就是一串 Filter
    全链路 traceIdMDC.put() → finally MDC.clear()
    解密、防 XSS、body 重复读HttpServletRequestWrapper
    秘书
    注解式权限 @RequireRoleHandlerMethod.hasMethodAnnotation()
    接口耗时、操作日志preHandle 记开始,afterCompletion 算耗时
    当前用户上下文preHandle 放 ThreadLocal,afterCompletion remove()
    装修清单
    跨域 CORSaddCorsMappings();用了 Security 再开 http.cors()
    本地图片 /files/**addResourceHandlers()
    参数自动注入 @CurrentUseraddArgumentResolvers()
    其他帮手
    统一异常返回@RestControllerAdvice
    统一包装响应体ResponseBodyAdvice
    Service 层日志、缓存AOP @Aspect

    JDK 8 还是 JDK 17:只换了门牌

    Java EE 交给 Eclipse 基金会后改名 Jakarta EE,包名跟着换。角色、顺序、生命周期一点没变。

    javax.servlet jakarta.servlet

    SSM 老项目

    JDK 8 常见
    大楼
    外置 Tomcat,靠 web.xml 登记
    门卫
    web.xml 的 <filter>;要用 Spring Bean 就配 DelegatingFilterProxy
    秘书
    <mvc:interceptors>,或 @EnableWebMvc + WebMvcConfigurer
    包名
    javax.servlet

    Spring Boot 2.x

    JDK 8+
    大楼
    内嵌 Tomcat 9,Spring 5
    门卫
    @Component、FilterRegistrationBean,或 @WebFilter + @ServletComponentScan
    秘书
    WebMvcConfigurer 的 addInterceptors()
    包名
    javax.servlet

    Spring Boot 3.x / 4.x

    JDK 17+
    大楼
    内嵌 Tomcat 10.1 / 11,Spring 6 / 7
    写法
    和 Boot 2 一样
    包名
    jakarta.servlet
    退休
    WebMvcConfigurerAdapter、HandlerInterceptorAdapter 已删除。JDK 8 起接口有 default 方法,直接实现接口即可
    三段代码搭起一栋楼 · 包名:
    import jakarta.servlet.*;          // ← 只有包名随版本变
    import jakarta.servlet.http.*;
    
    // ① 门卫:每个进楼的请求都经过(OncePerRequestFilter 保证同一请求只过一次)
    @Component
    @Order(1)
    public class TraceFilter extends OncePerRequestFilter {
        @Override
        protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                        FilterChain chain) throws ServletException, IOException {
            MDC.put("traceId", UUID.randomUUID().toString());   // 放行前
            try {
                chain.doFilter(req, res);                       // 放行:后面的所有人
            } finally {
                MDC.clear();                                    // 放行后
            }
        }
    }
    
    // ② 秘书:只管进了 DispatcherServlet 的请求
    @Component
    public class LoginInterceptor implements HandlerInterceptor {
        @Override
        public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
            if (req.getHeader("Authorization") != null) return true;   // 放行
            res.setStatus(401);
            return false;                                                // 拦下
        }
    }
    
    // ③ 装修清单:启动时调用一次,把秘书安排到门口
    @Configuration
    public class WebConfig implements WebMvcConfigurer {
        private final LoginInterceptor loginInterceptor;
    
        public WebConfig(LoginInterceptor loginInterceptor) {   // 注入 Bean,别 new
            this.loginInterceptor = loginInterceptor;
        }
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(loginInterceptor)
                    .addPathPatterns("/api/**")
                    .excludePathPatterns("/api/login", "/error");
        }
    }

    常踩的坑

    每条都是真实项目里反复出现的。

    门卫抛的异常,@RestControllerAdvice 接不住

    门卫在公司外面。在 Filter 里自己写好错误响应。

    postHandle 改不了 @ResponseBody 的结果

    那时响应已经写出去了。改响应体、加响应头,用 ResponseBodyAdvice。

    秘书也会拦到静态资源和 /error

    Spring Boot 里它们都走 DispatcherServlet。用 excludePathPatterns 放过。

    Boot 项目里别随手加 @EnableWebMvc

    加了它,Boot 的 MVC 自动配置整体退场,静态资源映射、spring.jackson.* 设置都可能失效。实现 WebMvcConfigurer 就够了。

    new 出来的秘书,@Autowired 是 null

    addInterceptor(new X()) 里的 X 不是 Bean。把它声明成 Bean 再注入。

    @Component 门卫默认拦 /*

    要指定 URL,用 FilterRegistrationBean;要指定先后,用 @Order 或 setOrder()。

    请求体只能读一次

    门卫可以先用 HttpServletRequestWrapper 缓存下来再放行。秘书换不了 request,做不到。

    ThreadLocal 用完不清,串号

    Tomcat 线程池会复用线程。在 afterCompletion 或 finally 里 remove()。