Spring RestTemplate 请求乱码的问题

我们知道 Spring 中 HttpClient 请求使用的 RestTemplate 封装的HttpClient;当我在项目中使用 RestTemplate 做 Post 请求时居然出现乱码的情况,既然出现乱码,编码肯定不一样才导致的,接下来我会分析一下出现乱码的请求。

Spring Version

4.2.3.RELEASE

RestTemplate Test 请求
import com.alibaba.fastjson.JSON;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;

@RestController
public class RestfulApi {

    @PostMapping("/post")
    public String api(HttpServletRequest request) {
        Map<String, String[]> parameterMap = request.getParameterMap();
        parameterMap.forEach((k, v) -> {
            System.out.println(String.format("%s - %s", k, StringUtils.join(v, ",")));
        });
        return JSON.toJSONString(parameterMap);
    }

}
Rest 接口
import org.junit.Test;
import org.springframework.core.io.FileSystemResource;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.io.File;
import java.util.HashMap;
import java.util.Map;

public class RestTemplateTest {

    @Test
    public void test() {
        Map<String, Object> param = new HashMap<>();
        param.put("key", "中文");
        param.put("key1", 1);
        RestTemplate restTemplate = new RestTemplate();
        String s = restTemplate.postForObject("http://localhost:8080/post", toMultiValueMap(param), String.class);
        System.out.println(s);
    }

    public static MultiValueMap<String, Object> toMultiValueMap(Map<String, Object> map) {
        MultiValueMap<String, Object> multiValueMap = new LinkedMultiValueMap();
        if (map != null) {
            map.forEach((k, v) -> {
                multiValueMap.add(k, v instanceof File ? new FileSystemResource((File)v) : v);
            });
        }

        return multiValueMap;
    }
}
Post Test 测试
  • 接口接收到的值

    key1 - 1
    key - ??

  • RestTempate 请求返回值

    {"key1":["1"],"key":["??"]}

如果我们换成 GET 请求会是怎么样的?

Get Test 测试
  • 接口接收到的值

    key - 中文
    key1 - 1

  • RestTempate 请求返回值

    {"key":["中文"],"key1":["1"]}

我们看到 Get 请求居然是没问题的,那我们换成 Get 请求不就好了吗?好是好,但是项目的接口有时会限制请求的方式,这种方式行不通,那我们就看看 RestTemplate 做了什么会让 Post 请求出现乱码。

接下来我会用调试的方式一步一步看看 RestTemplate 是如何发出请求的。

Step 1
@Override
    public <T> T postForObject(String url, Object request, Class<T> responseType, Object... uriVariables)
            throws RestClientException {
                
        RequestCallback requestCallback = httpEntityCallback(request, responseType);
        HttpMessageConverterExtractor<T> responseExtractor =
                new HttpMessageConverterExtractor<T>(responseType, getMessageConverters(), logger);
        return execute(url, HttpMethod.POST, requestCallback, responseExtractor, uriVariables);
    }
  • httpEntityCallback(request, responseType);
     protected <T> RequestCallback httpEntityCallback(Object requestBody, Type responseType) {
          return new HttpEntityRequestCallback(requestBody, responseType);
      }
    
    我们在看看HttpEntityRequestCallback实现了什么?
    HttpEntityRequestCallback继承了 AcceptHeaderRequestCallbackAcceptHeaderRequestCallback实现了RequestCallback接口,RequestCallback接口只有一个方法;
    public interface RequestCallback {
      void doWithRequest(ClientHttpRequest request) throws IOException;
    
    }
    
    既然实现了RequestCallback接口,那么就要实现接口中的方法,那我们看 看实现方法中的内容;
    • AcceptHeaderRequestCallback

      Request callback implementation that prepares the request's accept headers.
      设置Request 请求头 accept

    • HttpEntityRequestCallback

      Request callback implementation that writes the given object to the request stream.
      写入Request 对象流

      具体的源码我就不贴出了,大家可以参考着源码来理解。
      这个类中都使用HttpMessageConverter 的实现类来选择具体的Converter canRead canWrite
      那么HttpMessageConverter又是什么?

Step 2
   public RestTemplate() {
        this.messageConverters.add(new ByteArrayHttpMessageConverter());
        this.messageConverters.add(new StringHttpMessageConverter());
        this.messageConverters.add(new ResourceHttpMessageConverter());
        this.messageConverters.add(new SourceHttpMessageConverter<Source>());
        this.messageConverters.add(new AllEncompassingFormHttpMessageConverter());

        if (romePresent) {
            this.messageConverters.add(new AtomFeedHttpMessageConverter());
            this.messageConverters.add(new RssChannelHttpMessageConverter());
        }

        if (jackson2XmlPresent) {
            this.messageConverters.add(new MappingJackson2XmlHttpMessageConverter());
        }
        else if (jaxb2Present) {
            this.messageConverters.add(new Jaxb2RootElementHttpMessageConverter());
        }

        if (jackson2Present) {
            this.messageConverters.add(new MappingJackson2HttpMessageConverter());
        }
        else if (gsonPresent) {
            this.messageConverters.add(new GsonHttpMessageConverter());
        }
    }

这是RestTemplate 实例化的时候加载的HttpMessageConverter,converter都有哪些方法呢?

  • canRead(Class<?> clazz, MediaType mediaType)

    判断当前的Converter是否支持读

  • canWrite(Class<?> clazz, MediaType mediaType)

    判断当前的Converter是否支持写入

  • read(Class<? extends T> clazz, HttpInputMessage inputMessage)
  • write(T t, MediaType contentType, HttpOutputMessage outputMessage)
  • getSupportedMediaTypes()
Step 3

上面讲了那么多,无法想给你一个概念,RestTemplate是通过HttpMessageConverter对请求写入流;对返回进行读,解析成你指定的返回类型;如何判断写和读,来选择一个合适的c的呢?是不是通过参数类型和请求类型(Content-Type),我的想法对不对呢?我们来验证一下;

  • 参数类型

    对应HttpMessageConverter#canRead或者HttpMessageConverter#canWrite的第一个参数

  • 请求类型

    对应HttpMessageConverter#canRead或者HttpMessageConverter#canWrite的第二个参数

Step 4

我们只做第一步还没进行下去的debug;

执行

执行前的步骤我省略了,因为哪些方法不重要。

这里总共有5步,其中,第2部是我们要关心的;在Step 1 中我们介绍了RequestCallback的用法了,在发出请求前(第三步),使用合适的HttpMessageConverter对写入参数。

具体怎么实现的?

我们进入方法RequestCallback#doWithRequest

实现

我们注意三个地方:

  1. requestType

变量requestType是我们设置的MultiValueMap参数,如果忘了可以往前找找

  1. requestContentType

变量requestContentType是请求头,这个我们可以自定义,由于我们没有定义,所有这里是空

  1. messageConverter.canWrite(requestType, requestContentType)

看我猜想的不错,HttpMessageConverter就是通过判断参数类型和请求类型来选择一个合适的Converter的;

往下执行,能支持写的HttpMessageConverter是AllEncompassingFormHttpMessageConverter

Step 5

我们发现了合适的HttpMessageConverter,我们就看这个Converter如何写的;

Write

@Override
@SuppressWarnings("unchecked")
public void write(MultiValueMap<String, ?> map, MediaType contentType, HttpOutputMessage outputMessage)
        throws IOException, HttpMessageNotWritableException {

    // 如果ContentType 不是 FormData 或者 map 中参数的值全是String,会使用 application/x-www-form-urlencoded 
    // 否则使用 multipart/form-data
    if (!isMultipart(map, contentType)) {
        writeForm((MultiValueMap<String, String>) map, contentType, outputMessage);
    }
    else {
        writeMultipart((MultiValueMap<String, Object>) map, outputMessage);
    }
}

private boolean isMultipart(MultiValueMap<String, ?> map, MediaType contentType) {
    if (contentType != null) {
        return MediaType.MULTIPART_FORM_DATA.includes(contentType);
    }
    for (String name : map.keySet()) {
        for (Object value : map.get(name)) {
            if (value != null && !(value instanceof String)) {
                return true;
            }
        }
    }
    return false;
}
  • application/x-www-form-urlencoded

    该方式使用参数拼接,并且使用的是默认的UTF-8编码,转换的字节;
    final byte[] bytes = builder.toString().getBytes(charset.name());
    charset 是默认的编码
    使用 UTF-8 编码,接收方使用的也是UTF-8,就不会出现乱码的请求;
    我们的测试不是使用的这种方式,因为我们的参数中有基本类型;
    但是这种方式既然没有问题,我们可以把参数全部设置成String类型的,就不会出现POST请求出现乱码的请情况;但是实际的项目中参数类型不可能全是String的。

  • multipart/form-data

    这种方式我们发现它自己内部加载了其它的HttpMessageConverter,通过判断参数的类型,来选择具体的Converter;

看看都加载了哪些HttpMessageConverter

  • ByteArrayHttpMessageConverter
  • StringHttpMessageConverter
  • ResourceHttpMessageConverter
  • SourceHttpMessageConverter
  • MappingJackson2HttpMessageConverter
  • MappingJackson2XmlHttpMessageConverter

出现乱码的请求出现在中文中,中文又是一个String类型的,从上面六个HttpMessageConverter类名上,可以看出StringHttpMessageConverter是来处理中文的;出现乱码的情况肯定是编码出现了不一致,我们看看StringHttpMessageConverter的编码是什么?

public static final Charset DEFAULT_CHARSET = Charset.forName("ISO-8859-1");

居然是ISO-8859-1,终于找到了罪魁祸首;

编码不一致导致乱码的情况。

Step 6

导致乱码的问题我们找打了,但是如何规避或者解决呢?
下面我给出几个解决方案

  1. 把所有参数都换成String类型
  2. 因为AllEncompassingFormHttpMessageConverter没有提供入口可以替换本类中的HttpMessageConverter,不像RestTemplate提供了一个可以获取所有HttpMessageConverter的方法,所有如果参数中有其它类型时,这就无解了。
  3. 第2中情况其实是框架级别的bug了,既然大家都在使用 Spring 框架,就不可能没有人没有遇到这种情况,Spring 就不可能不会注意到这bug;想到这我们升级一下 Spring 版本,看看 Spring 会在哪个版本解决这个问题;
  4. 升级到 版本
    • 4.2.4.RELEASE

      没解决

    • 4.2.5.RELEASE

      没解决

    • 4.2.6.RELEASE

      没解决

    • 4.2.7.RELEASE

      没解决

    • 4.2.8.RELEASE

      没解决

    • 4.2.9.RELEASE

      没解决

    • 4.3.0.RELEASE +

      解决,问题到这个版本才解决,解决的办法就是在FormHttpMessageConverter 构造函数中加了一个applyDefaultCharset方法,对每个HttpMessageConverter重新设置默认编码 UTF-8

public FormHttpMessageConverter() {
    this.supportedMediaTypes.add(MediaType.APPLICATION_FORM_URLENCODED);
    this.supportedMediaTypes.add(MediaType.MULTIPART_FORM_DATA);

    this.partConverters.add(new ByteArrayHttpMessageConverter());
    StringHttpMessageConverter stringHttpMessageConverter = new StringHttpMessageConverter();
    stringHttpMessageConverter.setWriteAcceptCharset(false);
    this.partConverters.add(stringHttpMessageConverter);
    this.partConverters.add(new ResourceHttpMessageConverter());

    applyDefaultCharset();
}

private void applyDefaultCharset() {
    for (HttpMessageConverter<?> candidate : this.partConverters) {
        if (candidate instanceof AbstractHttpMessageConverter) {
            AbstractHttpMessageConverter<?> converter = (AbstractHttpMessageConverter<?>) candidate;
            // Only override default charset if the converter operates with a charset to begin with...
            if (converter.getDefaultCharset() != null) {
                converter.setDefaultCharset(this.charset);
            }
        }
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,928评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,192评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,468评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,186评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,295评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,374评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,403评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,186评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,610评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,906评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,075评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,755评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,393评论 3 320
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,079评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,313评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,934评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,963评论 2 351

推荐阅读更多精彩内容