SpringMVC中HttpRequestMethodNotSupportedException时返回中文乱码分析解决

版权声明:转载必须注明本文转自严振杰的博客:http://blog.yanzhenjie.com

最近写服务器接口时遇到一个令我辗转不眠的问题,为了统一解决RestController的全局异常,包括自定义异常和SpringMVC底层抛出来的异常,我用了@ControllerAdvice来拦截异常,出现的问题是:拦截到自定义异常和MissingServletRequestParameterException返回数据没有问题,但是拦截HttpRequestMethodNotSupportedException时返回带中文的JSON时,客户端接受到时乱码。

其实SpringMVC中处理我的需求的方案不少,在我的方案中遇到的这个问题很怪异,当然也可以秒秒钟用很多种办法解决,但是了解我的人就知道,在技术上这种情况我时绝对不能忍的,我本着打破砂锅问到底的精神,还是跟踪了一下源码。

我的代码

轻描淡写的解释一下,在一个类上加上@ControllerAdvice注解表示增强控制器,再加上@RestController注解表示方法返回的内容是ReponseBody,在这个类内部的方法上加上@ExceptionHandler注解表示扑捉什么异常。

:以上几个注解的解释仅限于此用法中,比如@RestController在控制器中还有别的作用和释义,@ControllerAdvice注解的类内部还可以用@InitBinder@ModelAttribute做其他事情等。

@ControllerAdvice
@RestController
public class AppControlAdvice {

    @ExceptionHandler(BaseException.class)
    public String missingParamHandle(request, response, BaseException e) {
        return handleException(request, response, e);
    }

    @ExceptionHandler(MissingServletRequestParameterException.class)
    public String missingParamHandle(request, response, MissingServletRequestParameterException e) {
        return handleException(request, response, new ClientException("缺少必要的参数"));
    }

    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public String methodNotSupportHandle(request, response, HttpRequestMethodNotSupportedException e) {
        return handleException(request, response, new ClientException("不支持的请求方法"));
    }

    public String handleException(request, response, Throwable ex) {
        response.setHeader("Content-Type", "application/json;charset=UTF-8");
        ...; // 组织数据并返回统一的JSON。
    }
}

:在@ExceptionHandler中给Response设置在@RequestMapping中指定的属性是无效的,不要问为什么,看完文章你就知道了。

代码解释:

  • BaseException:自定义异常基类。
  • MissingServletRequestParameterException:Query中缺少必要的参数。
  • HttpRequestMethodNotSupportedException:客户端使用的请求方法不被该接口支持。
  • handleException():统一处理错误,并根据错误类型返回JSON字符串。

为了读者方便阅读本文,我们约定一下,抛出MissingServletRequestParameterException异常时称为X接口抛错,抛出HttpRequestMethodNotSupportedException时称为O接口抛错。

关于@ControllerAdvice@ExceptionHandler的用法不再赘述,Google可以搜出来一大票详解的文章,这里出现的问题是扑捉到BaseExceptionX接口抛错时返回JSON后中文不会乱码,O接口抛错时返回的中文居然乱码了,上面可以清晰的看到我给Response设置了Content-Typeutf-8,在web.xml中也指定了编码过滤器使用utf-8

<filter>
    <filter-name>encodingFilter</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <async-supported>true</async-supported>
    <init-param>
        <param-name>encoding</param-name>
        <param-value>UTF-8</param-value>
    </init-param>
</filter>

<filter-mapping>
    <filter-name>encodingFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

所以我很确定我返回的字符串是utf-8编码的,于是我用浏览器打开了这个接口,但是我看到浏览器接受到的Content-Type却是text/html;charset=iso-8859-1

Response

问题分析

既然我们设置了响应头的Content-Typeapplication/json;charset=utf-8,但是返回给客户端时居然变了,很显然这是SpringMVC内部帮我们改了,于是我想到在拦截器内拦截所有接口的请求方法,判断客户端的请求方法是否被这个接口支持,如果不支持我直接抛出一个自定义异常,这样不就解决了吗?

定义注解:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface SupportMethod {
    RequestMethod[] value();
}

在接口上添加注解:

@RestController
@RequestMapping("/xxx")
public class XXXController {

    @SupportMethod(RequestMethod.POST);
    @RequestMapping(
            value = "/add",
            method = RequestMethod.POST)
    public String add() {
        ...;
    }
}

拦截器拦截:

public class AppInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(request, response, Object handler) throws Exception {
        if (handler != null && handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            SupportMethod supportMethod = handlerMethod.getMethodAnnotation(SupportMethod.class);
            if(method != null) {
                // 拿到客户端请求方法:
                String inMethod = RequestMethodrequest.getMethod();
                RequestMethod clientMethod = RequestMethod.valueOf(inMethod);

                // 接口指定的请求方法:
                List<RequestMethod> methodList = Arrays.asList(supportMethod.value());

                // 判断是否支持:
                if(!methodList.contains(clientMethod)) {
                    throw new ClientException("不支持的请求方法");
                }
            }
        }
        return true;
    }

理论上这段代码是没有问题的,一般情况时,我们拦截登录时就可以这样做,是完全没毛病的。

But,很快我就被啪啪打脸了,O接口抛错前并没有走拦截器,也就是说DispatcherServlet是先判断的请求方法,然后才走到拦截器,于是我放弃这个方案。

介于此我不得不看一下源码来一探究竟了。

排查原因

打开DispatcherServlet后发现内部提供了一个方法,只要我们重写这个方法就可以统一处理异常:

protected ModelAndView processHandlerException(request, response, Object handler, Exception ex);

很惭愧,要是不看源码我之前确实不知道这个方法,这算是其中一个解决方案,但这不是我的根本目的。

根据上面的分析,我要先找到时哪里修改我在Response中设置的Content-Type请求,从DispatcherServlet分发请求的方法开始debug走起:

protected void doDispatch(request, response) throws Exception {
    try {
        Exception exception = null;
        ...;
A.      mappedHandler = getHandler(request);
        ...;
B.      HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
C.      mv = ha.handle(request, response, mappedHandler.getHandler());
        ...;
D.      applyDefaultViewName(request, mv);
E.      mappedHandler.applyPostHandle(request, response, mv);
    } catch (Exception ex) {
        exception = ex;
    }
F.  processDispatchResult(..., exception);
}

这里省略了一部分代码,剩下的是主要的代码,也是可能出现问题的代码。为了方便读者结合博客阅读代码,我给代码加上了伪行号。

我先在A处代码(获取RequestHandler)打上了断点,分别扑捉X接口抛错和O接口抛错并观察两个异常的时候,代码都是如何走的。实验结果发现:X接口抛错时在执行C(执行Handler)处代码时抛出异常,O接口抛错时在执行A处代码(获取RequestHandler)时抛出异常,那么唯一的区别就是X接口抛错时执行了B处代码(获取Handler的属性)处代码,这里望文生义,getHandlerAdapter的意思不就是拿到Handler的适配器,也就是Controller中方法的@RequestMapping等相关属性了。

于是我猜测是不是这里设置的ResponseContent-Type是无效的,还是会被Handler@RequestMapping覆盖了。然后我又做了个测试,把A接口抛错时返回数据的Conent-Type中的编码改成iso-8859-1看看效果:

@RequestMapping(
    value = "/xxx",
    method = {RequestMethod.GET},
    produces = "application/json;charset=iso-8859-1")

果不其然的印证了我的猜想,客户端果然乱码了,也就是说我返回的数据是utf-8编码的,但是我却告诉客户端我的数据是ios-8859-1的,也就时说在文章开头的这个方法中设置的Content-Type是无效的:

public String handleException(request, response, Throwable ex) {
    response.setHeader("Content-Type", "application/json;charset=UTF-8");
    ...; // 组织数据并返回统一的JSON。
}

特别声明:在@ExceptionHandler中给Response设置在@RequestMapping中指定的属性是无效的。

原因印证

如果上面的测试不够具有说服力,那么下面我带读者们来跟踪下代码。

还是刚才的A处代码(获取RequestHandler)断点,分别扑捉A接口抛错和B接口抛错并观察两个异常的时候,各个对象有什么不同,因为必定是某个对象的某个属性影响了结果。实验结果发现,A接口抛错在走完A处代码(获取RequestHandler)后,RequestAttribute多了如下的属性:

org.springframework.web.servlet.HandlerMapping.producibleMediaTypes: application/json;charset=utf-8

B接口抛错却没有这个属性,我猜想在抛出HttpRequestMethodNotSupportedException异常时Request少了上述属性,这个属性就是为了保存接口@RequestMapping注解的produces属性。那我们一步步跟踪一下我们猜的对不对:

第一步,哪里添加的属性

先从DispatcherServlet#getHandler()下手(大概是940行代码处),:

mappedHandler = getHandler(processedRequest);

跟着代码来到了RequestMappingInfoHandlerMapping#handleMatch()(大概是117行左右),发现了玄机:

if (!info.getProducesCondition().getProducibleMediaTypes().isEmpty()) {
    Set<MediaType> mediaTypes = info.getProducesCondition().getProducibleMediaTypes();
    request.setAttribute(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mediaTypes);
}

果然是这里添加了接口的属性到Request的属性中,但是这还是不能够证明它影响了返回结果啊。

第二步,哪里验证了属性

这里要麻烦读者朋友回到上面标有伪行号ABCDE那里看看代码,其中有一行F:processDispatchResult(..., exception);,这里是处理了当前请求的结果,无论异常还是正常。

然后跟着processDispatchResult()来到了AbstractMessageConverterMethodProcessor#getProducibleMediaTypes()(大概是304行):

Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
if (!CollectionUtils.isEmpty(mediaTypes)) {
    return new ArrayList<MediaType>(mediaTypes);
}

这下就完全印证了我的猜想,望文生义,getProducibleMediaTypes()就是获取返回ResponseBodyproduces

结论@ControllerAdvice@ExceptionHandler配合使用时,SpringMvc覆盖了我们设置的ResponseContent-Type

解决方案

  1. SpringMVC提交PR,优化这个问题。
  2. 在进入Controller的方法之前SpringMVC抛出的HttpRequestMethodNotSupportedException异常处理返回数据时不要使用中文。
  3. 在扑捉到HttpRequestMethodNotSupportedException异常时自己给Request添加HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE属性,代码如下:
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public String methodNotSupportHandle(request, response, HttpRequestMethodNotSupportedException e) {
    Set<MediaType> mediaTypeSet = new HashSet<>();
    MediaType mediaType = new MediaType("application", "json", Charset.forName("utf-8"));
    mediaTypeSet.add(mediaType);
    request.setAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mediaTypeSet);
    return handleException(request, response, new ClientException("不支持的请求方法"));
}

SpringMVC提交PR是我后面要做的事,现在工作生活都比较忙,没时间看的更深入更理解,所以不敢擅自提交PR。


版权声明:转载必须注明本文转自严振杰的博客:http://blog.yanzhenjie.com

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值