☰
Spring MVC配置全解析:核心组件原理与高频问题排查
2026/10/10 4:40:53 网站建设 项目流程

Spring MVC 用了这么多年,几乎每个项目都在重复那几套配置,但真到了线上报问题的时候,最先被怀疑的往往就是配置文件。代码逻辑明明没问题,接口就是 404;本地跑得好好的,部署上去之后静态资源全被拦截;日志里参数已打印,进到 Controller 里偏偏是 null。这几个经典问题,根子基本都埋在 Spring MVC 的配置里。

这篇文章把 Spring MVC 的核心配置从头到尾完整过一遍,从入口 DispatcherServlet 到 HandlerMapping、视图解析器、拦截器、类型转换、消息转换,再到高频问题的排查思路。不写废话,直接上配置、讲原理、填经验坑。适合正在用 Spring MVC 做项目的开发者,也适合准备把 XML 配置迁移到 Java Config、或者排查配置类问题查到头疼的同学。看完之后,你会发现很多"玄学"问题,本质上都是对底层机制不够了解。

1. 从入口说起:DispatcherServlet 的配置与作用域

1.1 web.xml 时代的配置方式

Spring MVC 的核心入口是 DispatcherServlet,它本质上是一个 HttpServlet,所有请求会先经过它,再由它分发到对应的 Controller 方法。传统的 web.xml 配置大概长这样:

<servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>

这一小段配置里有三个关键点。contextConfigLocation 指定 DispatcherServlet 的上下文配置文件路径,注意如果这个 init-param 不写,框架会默认去 /WEB-INF/ 下找名字为 dispatcher-servlet.xml 的配置文件,很多人没约定好文件名,结果项目启动就已经埋下隐患。

load-on-startup 设为 1,意味着应用启动时就初始化这个 Servlet,而不是等第一个请求进来才初始化。我见过有人漏掉这一项,平时测试还好,一到高峰流量就直接卡在第一个请求的初始化上,超时连连。这里的教训是:DispatcherServlet 一定要随应用启动,别偷懒。

最坑的是<url-pattern>/</url-pattern>和/*的区别。很多新手写成/*,结果所有的请求包括 JSP、静态资源全部被 DispatcherServlet 拦截,页面直接 404 或者白屏。/会匹配除了 JSP 之外的所有请求,把静态资源的处理交给框架层面的一个默认处理器,/*则是一揽子全接走。这一点在排查静态资源问题时算是第一嫌疑。

1.2 Java Config 与启动器类配置

Servlet 3.0+ 之后,很多项目不再写 web.xml,改用编码方式注册。最常见的是继承 AbstractAnnotationConfigDispatcherServletInitializer,这是 Spring 官方提供的启动器基类,作用就是替你做 web.xml 里那套注册动作:

public class WebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @Override protected Class<?>[] getRootConfigClasses() { return new Class<?>[]{RootConfig.class}; } @Override protected Class<?>[] getServletConfigClasses() { return new Class<?>[]{WebConfig.class}; } @Override protected String[] getServletMappings() { return new String[]{"/"}; } }

这段代码的语义要弄清楚:getRootConfigClasses 返回的是根容器配置,通常放数据源、Service、事务这些业务层的 Bean;getServletConfigClasses 返回的是 Web 容器配置,只放 Controller、视图解析器、拦截器等表现层的东西。两个容器是父子关系,子容器能拿父容器的 Bean,反过来不行。

我建议新手先从这套 Java Config 入手,因为它的层次比 XML 更清晰。而且 Spring Boot 里面你看到的自动配置,其实就是在这个基础之上做了一层更高级的封装,理解了这层关系,后面看 Spring Boot 的源码会顺很多。

1.3 父子容器与请求作用域的关系

父子容器是整个 Spring MVC 配置中最容易让人懵的机制,但它直接决定了你能拿到哪些 Bean。

根容器 WebApplicationContext 由 ContextLoaderListener 加载,负责装配 Service、Dao、数据源等核心业务组件。DispatcherServlet 自己还会创建一个子容器,负责 Controller、HandlerMapping、视图解析器这些 Web 组件。请求进来的时候,DispatcherServlet 先从子容器找 Bean,找不到再去父容器找。

这个设置好处是 Web 层和业务层之间保持解耦,Service 不依赖 Web 组件,方便在非 Web 场景下复用业务逻辑。但也带来一个经典坑:如果你在 Web 层配置里扫描了 @Service,又让根容器也去扫描 @Controller,两边 Bean 重复创建,事务控制就很容易失灵。我实际排查过不少这种问题,症状是事务偶尔不生效,原因就是 Controller 在子容器里找到的是一个没有事务代理的实例。

所以配置组件扫描的时候,根容器只扫 @Service、@Repository、@Component,Web 容器只扫 @Controller。别图省事搞全包扫描,后面调试起来真的心累。

2. 请求分发与适配:HandlerMapping 和 HandlerAdapter

2.1 注解驱动开关背后的注册

很多人以为 Spring MVC 只需要加一个@EnableWebMvc就完事了,但这个注解到底做了些什么,其实值得琢磨清楚。@EnableWebMvc的核心作用是往容器里导入了一个 DelegatingWebMvcConfiguration,它继承自 WebMvcConfigurationSupport,默认帮你注册了一堆关键组件:

  • RequestMappingHandlerMapping:维护 URL 与 Controller 方法的映射关系
  • RequestMappingHandlerAdapter:参数解析、返回值处理、调用 Controller 方法
  • ExceptionHandlerExceptionResolver:处理 @ExceptionHandler 注解的异常
  • ContentNegotiationManager:内容协商管理

这三个组件的关系用一个生活类比来解释:HandlerMapping 是"快递分拣台",根据地址(URL)找到对应的处理单元;HandlerAdapter 是"具体操作员",把快递包裹里的东西分类取出(参数绑定),再按照快递单要求完成处理(调用方法返回结果);异常解析器则是"售后客服",出了问题按预案处理。

如果不用@EnableWebMvc或者 XML 里的<mvc:annotation-driven>,这些核心组件不会有默认注册,或者仅仅注册了较低版本的支持。我见过最典型的现象是:纯手工配置了一堆 HandlerMapping 的 Bean,结果 @PathVariable 解析不出来、@RequestBody 直接报错,原因就是适配器类型对不上。记住这句话:注解驱动的开关,是 Web 配置的第一块基石,能开就开,别自己重复造轮子。

2.2 参数解析器和返回值处理器的扩展

RequestMappingHandlerAdapter 内部有两个很重要的扩展点:HandlerMethodArgumentResolver(参数解析器)和 HandlerMethodReturnValueHandler(返回值处理器)。

@RequestBody 能把你提交的 JSON 变成对象,是 HttpMessageConverter 在起作用;但你如果自定义了一个注解用来解析当前登录用户,那么就得实现 HandlerMethodArgumentResolver:

public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(CurrentUser.class); } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); // 从 token 或 session 中解析用户并返回 return request.getAttribute("LOGIN_USER"); } }

注册这类解析器,需要实现 WebMvcConfigurer 里的 addArgumentResolvers 方法:

@Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(new CurrentUserArgumentResolver()); }

这里有个容易忽略的细节:一旦你手动添加了自定义解析器,Spring 内置的解析器依然生效,因为框架是先把内置解析器列表加进来,再把自定义的追加进去。所以不用担心覆盖掉默认能力,但需要注意的是不同解析器的 order 顺序。如果你的自定义解析器想优先于某些默认解析器执行,可以通过 Ordered 接口调整顺序。

2.3 视图控制器与配置简化

配置里经常会有一种"不需要走 Controller 逻辑"的页面跳转需求,比如跳转到登录页或首页。传统方式是写一个方法返回字符串,纯属浪费。Spring MVC 提供了 ViewControllerRegistry 专门干这个:

@Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/").setViewName("index"); registry.addViewController("/login").setViewName("login"); }

这个配置的底层会注册一个 ParameterizableViewController,它就是一个固定返回视图名的控制器。这么做既有集中管理 URL 的好处,又省掉了无意义的 Controller 类,代码更干净。我用这个配置处理过不少纯静态页面的跳转,效果很好。

同时它和实际开发中的 RESTful 风格也兼容,需要做鉴权的接口走 Controller,纯页面跳转走 ViewController,职责分离明显。有些项目里还配合拦截器白名单来做统一登录校验,拦截路径只管 /api/**,页面跳转交给 ViewController,配置逻辑一下子通透多了。

3. 视图解析器配置:从 JSP 到模板引擎

3.1 内部资源视图解析器的配置

视图解析器的职责是拿到 Controller 方法返回的字符串,把它拼接成一个真正的 View 对象。传统的 JSP 项目里基本都是 InternalResourceViewResolver:

@Bean public ViewResolver viewResolver() { InternalResourceViewResolver resolver = new InternalResourceViewResolver(); resolver.setPrefix("/WEB-INF/views/"); resolver.setSuffix(".jsp"); return resolver; }

Controller 里返回"user/list",实际匹配到的资源是/WEB-INF/views/user/list.jsp。把 JSP 放在 /WEB-INF/ 下是一个值得坚持的习惯,因为该目录对外部请求不可见,用户无法直接通过 URL 访问 JSP 原始内容,只能经由控制器跳转,安全性提升了不少。

InternalResourceViewResolver 本质上是把请求转发到某个 JSP 资源,所以它和 JSP 是强绑定关系。如果你的项目换成了 Thymeleaf、Freemarker,这个解析器就不适用了,得换对应的 ThymeleafViewResolver 和 FreeMarkerViewResolver。

这里有一个非常容易踩的坑:Controller 方法返回的字符串带 forward 或 redirect 前缀时,解析器是需要特殊处理的。比如返回"redirect:/user/list",框架会忽略前后缀拼接,直接用 RedirectView 发起重定向。你要是写成了"/redirect:/user/list",那就会找到一个不存在的资源路径,直接报错。

3.2 多视图解析器的组合

很多中型项目会同时使用 JSP 和某种模板引擎来满足不同页面的需要,比如某些模块用 Thymeleaf 渲染,后台管理页面沿用 JSP。这时候需要注册多个 ViewResolver,并通过 order 属性控制优先级,数值越小优先级越高。

@Bean public ViewResolver thymeleafViewResolver() { ThymeleafViewResolver resolver = new ThymeleafViewResolver(); resolver.setTemplateEngine(templateEngine()); resolver.setCharacterEncoding("UTF-8"); resolver.setOrder(0); return resolver; } @Bean public ViewResolver internalResourceViewResolver() { InternalResourceViewResolver resolver = new InternalResourceViewResolver(); resolver.setPrefix("/WEB-INF/jsp/"); resolver.setSuffix(".jsp"); resolver.setOrder(1); return resolver; }

配置了 order 之后,框架会按优先级逐个尝试解析,解析失败再降级到下一个。但这里的"失败"有个细节:InternalResourceViewResolver 在逻辑上永远"成功",只要设置了前缀和后缀,它不关心资源文件存不存在,因为真正的资源查找发生在视图渲染阶段。所以如果你把 InternalResourceViewResolver 的 order 排在前面,会直接导致 Thymeleaf 解析器永远没有机会执行。我把这个规则总结成一句话:InternalResourceViewResolver 永远放最后。

3.3 转发与重定向的前缀

返回字符串的书写规则直接影响客户端行为,这一点太容易踩坑了:

  • return "user/list":内部转发,地址栏不变
  • return "forward:/user/list":显式转发,等价于内部转发,但可以指定任意 RequestMapping 的 URL
  • return "redirect:/user/list":重定向,地址栏变为 /user/list,产生一次二次请求

重定向场景下有个经典问题:访问 /user/open 后重定向到 /user/list,刚才请求域里放入的数据会全部丢失,因为这是两个完全独立的请求。解决办法是使用 Flash 属性:

public String handle(Model model, RedirectAttributes redirectAttributes) { redirectAttributes.addFlashAttribute("message", "操作成功"); return "redirect:/user/list"; }

Flash 属性会被存储到 FlashMap 中,重定向之后的新请求里还能再取出来读取一次。这个机制在处理"提交后跳转并提示"的业务场景里非常好用,避免了把状态塞到 URL 参数造成的信息泄漏,也省去了 Session 里写一遍清一遍的麻烦。

4. 拦截器、静态资源与跨域配置

4.1 拦截器注册与执行顺序

拦截器的地位等同于是 Controller 层的前后守卫。Spring MVC 中的 HandlerInterceptor 接口包含三个默认方法:

  • preHandle:请求到达 Controller 之前执行,返回 false 即终止后续链路
  • postHandle:Controller 执行完、视图渲染前执行
  • afterCompletion:整个请求结束之后执行,常用于释放资源、记录日志

注册拦截器实现 WebMvcConfigurer 的 addInterceptors:

@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/css/**", "/js/**", "/error"); }

拦截器的执行顺序是按照注册顺序来的,多个拦截器组成了一个责任链。这里我分享一个容易踩坑的点:postHandle 方法里可以对 ModelAndView 进行修改,但如果你的 Controller 是 @ResponseBody 或 @RestController,那返回的 mav 是 null,因为数据已经通过消息转换器写进响应流了。有些同学想在这里统一给返回结果加字段,发现一直改不动,原因就在这里。

拦截器和 AOP 的职责容易混淆。如果你要做的是操作日志记录、权限校验、请求耗时统计,拦截器是最合适的位置,因为它天然能拿到 HttpServletRequest 和 HttpServletResponse。而如果你要对 Service 层方法做重试、事务增强、数据隔离,那就用 AOP。注意别把业务逻辑强塞到拦截器里,拦截器被设计为横切关注点,不适合承载太重的主业务流程。

4.2 静态资源处理方案

前面说过<url-pattern>/</url-pattern>会拦截所有请求,但静态资源问题在 Spring MVC 里的解决方式其实很成熟。最直接的方式是注册资源处理器:

@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/static/**") .addResourceLocations("classpath:/static/") .setCachePeriod(3600); }

这段配置把 /static/** 请求映射到 classpath 下的 /static/ 目录。setCachePeriod 设置的是浏览器缓存过期秒数,生产环境一般可以开得很大,因为通常有版本号的 hash 文件来保证更新。

如果你的项目里存在大量过滤条件复杂的静态资源,也可以考虑直接启用容器的默认 Servlet 处理静态资源:

<mvc:default-servlet-handler />

这个配置是把 /static/** 之外没有被 DispatcherServlet 匹配到的请求,都交给容器默认 Servlet 处理。它的优先级最低,适合作为一种兜底方案。但我个人建议还是用 ResourceHandlerRegistry 显式声明,至少在排查问题的时候,你能一眼看出来资源映射到了哪个目录。

还有一个容易混淆的概念:ResourceHandler 与 ResourceResolver。前者负责 URL 映射,后者负责实际解析资源的位置和版本。Spring Boot 里的 webjars 定位、版本化资源文件,都是依赖这个机制。如果你的项目上线时出现静态资源有缓存、更新不明显的情况,记得检查两个地方:Cache-Control 响应头以及资源 URL 是否带上了版本号参数。

4.3 跨域配置的细节

前后端分离的大潮之下,跨域配置几乎每个项目都会用到。最简单的方案是使用 @CrossOrigin 注解到了 Controller 类或方法上:

@CrossOrigin(origins = "https://allowed-domain.com", maxAge = 3600) @RestController @RequestMapping("/api") public class UserController { // ... }

但全局性跨域策略更适合放在配置类里集中管:

@Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("https://allowed-domain.com") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); }

有几个跨域配置的细节必须留意。allowCredentials(true) 和 allowedOrigins("*") 不能同时使用,因为携带凭证的请求无法使用通配符来源,否则浏览器直接拒绝响应。这不是框架的限制,而是浏览器安全策略的约束,如果你非要允许所有来源又要带 Cookie,只能把 allowedOrigins 显式写出来,或者用 OriginFilter 动态处理。

另外注意拦截器与 CORS 的执行顺序。跨域预检请求 OPTIONS 本身不会携带业务信息,如果你的拦截器把它拦了,前端会报一个特别难看的 CORS 错误。正确做法是在拦截器对 OPTIONS 方法直接放行,或在配置拦截器时将相关路径排除。

5. 类型转换与消息转换配置

5.1 日期类型转换的经典问题

给前端传数据的时候,最常见的抱怨就是日期格式。默认情况下,Spring MVC 对 Date/LocalDateTime 使用的是 ISO 标准格式,很多前端框架并不认这套格式,导致页面显示一排"2019-03-04T12:00:00"的字符串,看着极其难受。

先分清两个层面:接收参数时,字符串转日期用的是 WebDataBinder 的转换机制;返回值序列化时,日期转 JSON 字符串用的是 HttpMessageConverter 的序列化规则。两个层面要分别处理。

接收参数局部处理最简单直接:

@InitBinder public void initBinder(WebDataBinder binder) { binder.addCustomFormatter(new DateFormatter("yyyy-MM-dd")); }

返回值的日期全局格式化则可以用 Jackson 的配置:

@Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; }

我个人的观点是:如果是新项目或者可以整体控制接口的数据格式,尽量统一采用全局配置方式,别让每个 Controller 各自写 @JsonFormat,因为一旦有人忘了加,测试环境就出现了格式不一致的隐性 bug。通过 Spring Boot 配置spring.jackson.date-format或者上面的 Jackson2ObjectMapperBuilderCustomizer 来做全局统一,一劳永逸。

5.2 ConversionService 与自定义格式化器

Spring MVC 默认的 ConversionService 已经支持基础类型互相转换,但项目中总有一些自定义对象需要在参数绑定阶段就完成转换。比如 URL 里传递的是用户 ID 的字符串,但 Controller 方法参数直接声明为 User 对象,这时候就需要自定义转换器。

实现 Converter 接口是最直接的方式:

public class StringToUserConverter implements Converter<String, User> { @Override public User convert(String source) { User user = new User(); user.setId(Long.parseLong(source)); return user; } }

然后注册进 WebMvcConfigurer 的 addFormatters 方法:

@Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(new StringToUserConverter()); }

Formatter 和 Converter 的区别在于:Converter 是通用的双向转换组件,而 Formatter 专注于字符串与其他类型的互相转换,这在 Web 层语义上更贴切。实现 Formatter 之后,默认有 locale 参数,可以做区域化的格式化处理,比较适合金额、日期这类强区域相关的字段。

这个自定义转换的使用场景并不局限于普通请求参数,路径变量一样生效。比如/user/{userId}映射到一个参数类型为 User 的方法时,框架也会调用这个转换器。这种机制善用之后,Controller 方法签名会干净很多,不用每个方法都写一遍"解析 ID、查询用户"的样板代码。

5.3 JSON 消息转换器的配置

@ResponseBody 之所以能把对象变成 JSON,靠的是 MappingJackson2HttpMessageConverter 背后的 ObjectMapper。对于普通项目,框架默认的 ObjectMapper 已经够用,但涉及一些定制需求时还是要显式配置。

比较典型的场景是:某个接口返回的数据里有一堆 null 字段,前端希望直接不输出这些字段,节省流量:

@Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); return mapper; }

在 Spring Boot 项目里,更稳妥的方法是自定义 Jackson2ObjectMapperBuilderCustomizer Bean,而不是直接覆盖 ObjectMapper,因为直接覆盖会让 Boot 内部很多自动配置失效。这一点很多人不知道,在 Boot 场景下自定义 Bean 要优先考虑"定制器",而不是"替换器"。

同样道理适用于自定义消息转换器。有的项目需要兼容某种特殊协议的报文格式,那就实现一个 HttpMessageConverter 然后注册到消息转换器列表中。注册方法是重写 WebMvcConfigurer 的 extendMessageConverters:

@Override public void extendMessageConverters(List<HttpMessageConverter<?>> converters) { converters.add(new CustomXmlMessageConverter()); }

我特别提醒一句:很多教程建议用 configureMessageConverters 来"增加"转换器,但这个方法实际语义是"覆盖",如果你在这个方法里只放了自己的转换器,框架默认的 Jackson 转换器全部失效,返回 JSON 就会异常。需要追加的时候,用 extendMessageConverters 才是安全姿势。这个坑我实打实踩过,值得特别强调。

6. 高发配置问题排查实录

6.1 接口 404 的排查步骤

接口 404 的高频原因其实就几种:映射没被发现、HandlerMapping 没注册、路径写错、上下文路径差异。我一般按下面的顺序排查,效率很高。

第一,先确认 DispatcherServlet 的映射路径和 Java 配置的 servletMappings 是否一致。如果在 web.xml 里定义了 /api/*,又在 Controller 里写 @RequestMapping("/api/user"),那实际访问路径是 /api/api/user,必 404。

第二,确认组件扫描范围是否覆盖到 Controller 包。常见的情况是扫描配置写成了@ComponentScan(basePackages = {"com.example.service"}),Controller 所在包没被扫描到。这时通过启动日志里的 RequestMappingHandlerMapping 去验证,看它到底注册了哪些映射。没有注册就没有访问入口。

第三,检查 @EnableWebMvc 是否手写了。加了它之后,Boot 自动配置的 WebMvc 相关配置会被覆盖,如果你没实现 WebMvcConfigurer 的某些方法,默认静态资源处理、消息转换器等可能被重置。Spring Boot 项目里,如果你不是真的要完全接管 MVC 配置,一般不要加 @EnableWebMvc。

排查完这三步,绝大多数 404 都能定位。剩下的是路由里变量类型不匹配、正则写错的问题,这种通过启动日志也能看到映射关系的生成结果。

6.2 请求参数与返回值异常

Controller 方法里参数组装得不对,是仅次于 404 的高频问题。最常见的症状:前端明明传了 user 参数,后端却是 null。

这种问题的第一步总是先看请求,而不是直接怀疑代码。打开浏览器调试工具,先看清请求是 query string(URL 参数)还是 request body。如果请求体是 JSON,后端只写了方法参数 User user,而没加 @RequestBody,那框架拿不到 JSON 内容,user 就是空对象。反过来说,如果请求体是表单格式,方法参数却加了 @RequestBody,框架就会报一个"不支持的内容类型"错误,因为 HttpMessageConverter 无法把 form 数据转换成对象。

这个问题的本质是参数解析器的分工:query string 交给 RequestParamMethodArgumentResolver,form body 同样处理,但 JSON body 必须由 RequestResponseBodyMethodProcessor 配合 HttpMessageConverter 来解析。所以后端方法签名必须和前端 Content-Type 对齐。

返回值方面,我发现比较常见的是返回时间戳或 ID 超精度丢失。当一个 Long 类型 ID 数值很大时(比如雪花算法生成的 ID),前端 JS 的 Number 会丢失精度。解决方案是把相关字段用 String 序列化,比如用 Jackson 的注解或者全局配置 Long 转 String。这类问题在大型分布式项目中尤其常见,值得在架构初期就考虑好 ID 的返回类型。

6.3 中文乱码与编码配置

中文乱码在 Spring MVC 里大多数情况是"两边对齐"的问题:响应编码、请求编码和文件编码三方不一致。

请求方面,一个经典场景是 POST 表单提交的中文乱码。早期项目要在 web.xml 里配置 CharacterEncodingFilter,并设置参数 encoding=UTF-8、forceEncoding=true。这个过滤器要注册在所有拦截器之前,因为它会直接设置 request 和 response 的编码。

Spring Boot 项目里,server.servlet.encoding.charset=UTF-8和enabled=true已经做了类似的事情,但如果你用的容器版本较新,默认已经是 UTF-8。问题往往出在响应体编码上:Thymeleaf 模板引擎如果没设置 characterEncoding,页面一渲染就会出现乱码,因为引擎默认的输出编码可能是平台默认编码。JSP 项目中同样要确保 pageEncoding 和 contentType 都为 UTF-8。

JSON 接口的中文乱码是一个特殊场景。虽然 MessageConverter 默认使用 UTF-8,但如果你自定义了 HttpMessageConverter 又没指定字符编码,很可能会回落到 ISO-8859-1,前端看到的就全是问号。排查思路是抓包看 Content-Type 响应头,如果里面没有 charset=utf-8,沿着转换器的编码设置就能找到根因。

我自己在 Mac 和 Windows 环境的同事之间也遇到过"本地不乱码、部署后乱码"的情况,最后定位到项目源码文件本身的编码不是 UTF-8,到 Linux 服务器上编译时,javac 用了平台默认编码,所有中文字符串全成了乱码。这个教训导致我后来在任何项目的编译配置里都会显式加上 UTF-8 参数,不依赖编辑器默认值。

另外再补充一个高发问题:页面报 406 Not Acceptable。这个错误一般是 Controller 返回的视图类型和客户端 Accept 头不匹配导致的,比如只配置了 JSP 视图解析器,但请求头 Accept 是 application/json,框架找不到合适的返回到 JSON 的转换器。解决思路要么调整 Accept 头,要么配置内容协商策略和对应的消息转换器。

配置类问题排查到最后,你会发现大部分坑都来自两个根源:对默认机制的不理解,以及复制粘贴时没有注意到组件组合的优先级。写配置不是背模板,而是理解每个组件存在的原因。比如看懂 HandlerMapping 和 HandlerAdapter 之后,你就不会在自定义参数解析器时乱搞顺序;看懂视图解析器 order 机制之后,你也不会因为 InternalResourceViewResolver 的位置问题浪费一下午的时间。

最后分享一个我个人的习惯:每次新建一个 Spring MVC 项目,我会在启动日志里屏住呼吸看一遍 RequestMappingHandlerMapping 注册的所有映射,确认要暴露的接口和静态资源路由都准确无误。这个方法成本极低,却能提前发现很多运行时才会暴露的问题。另外,把配置类当成代码来对待,写清楚注释、理顺层次、不做无意义的注解覆盖,比事后排查问题要划算得多。Spring MVC 的配置终究是那几张牌,真正拉开差距的,是你对每一张牌的理解深度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询