HttpMessageConverter - 响应中文内容乱码问题

在讲解 @ResponseBody 注解时,我们测试了四种响应数据到浏览器的情况:

  • 超链接发起请求,服务器通过 Servlet API 的 response 对象响应字符串到浏览器
  • 超链接发起请求,服务器使用 @ResponseBody 响应字符串到浏览器
  • 超链接发起请求,服务器使用 @ResponseBody 响应一个 Java 对象到浏览器
  • Axios 发起 Ajax 请求,服务器使用 @ResponseBody 响应字符串到浏览器

当响应数据中包含中文内容时,前两种会出现中文内容乱码现象!

为什么 CharacterEncodingFilter 没有作用?

还记得,在 web.xml 中曾经配置了一个 CharacterEncodingFilter,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<filter>
<filter-name>CharacterEncodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
<init-param>
<param-name>forceResponseEncoding</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CharacterEncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

我们明明将 forceResponseEncoding 属性设为了 true,为什么会出现响应中文乱码呢?

这是因为,forceResponseEncoding=true 时,CharacterEncodingFilter 只是帮我们设置了 response.setCharacterEncoding("UTF-8"),让服务器按 UTF-8 格式来编码响应数据。但是浏览器接收到服务器的响应数据后,它并不知道该响应数据的编码格式是什么?

所以,一般来说,我们会同时在响应头中设置 ContentType 来告诉浏览器,所返回的响应数据的编码是什么,比如 response.setContentType(text/html;charset=utf-8) 就是告诉浏览器,返回的响应体是个 html 页面,且编码是 utf-8

小结一下,响应中文内容乱码问题,原因就是没有设置好 ContentType 响应头!上面提到的前两种情况出现中文乱码,都是这个原因。

标注 @ResponseBody 的控制器方法的返回值,是如何被转换为响应体的?

控制器方法一旦被 @ResponseBody 标注后,RequestResponseBodyMethodProcessor 中的 handleReturnValue 方法会被调用,来完成返回值数据到响应体的转换。

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {

mavContainer.setRequestHandled(true);
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);

// Try even with null return value. ResponseBodyAdvice could get involved.
writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
}

该方法接收的 returnValue 即控制器方法的返回值,returnType 即控制器方法的返回类型。该方法中最关键的是 writeWithMessageConverters

调用 writeWithMessageConverters 方法需要传入 4 个参数:returnValue 返回值,returnType 返回类型,inputMessage 其实就是对请求 request 的一个封装,outputMessage 其实就是对响应 response 的一个封装。

该函数中做了很多重要的工作,我们主要关心其中的:

  • 综合请求头中的 Accept 信息和控制器方法的 returnType 返回类型信息,确定最终的生产的媒体类型,即确定最终响应头的 ContentType 信息。
1
2
3
4
5
// 从请求头 `Accept` 中获取浏览器希望响应的媒体类型
List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
// 对所有已注册的 HttpMessageConverter,逐个检查 converter 是否能够处理 `returnType` 返回类型
// 如果可以,就将该 converter 所支持的媒体类型全部加入列表中
List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);

默认情况下,所有已注册的 HttpMessageConverter 包括 8 个,查看 messageConverters 属性:

1
2
3
4
5
6
7
8
0 = {ByteArrayHttpMessageConverter@17781} 
1 = {StringHttpMessageConverter@17698}
2 = {ResourceHttpMessageConverter@17782}
3 = {ResourceRegionHttpMessageConverter@17783}
4 = {SourceHttpMessageConverter@17661}
5 = {AllEncompassingFormHttpMessageConverter@17784}
6 = {Jaxb2RootElementHttpMessageConverter@17785}
7 = {MappingJackson2HttpMessageConverter@17786}

需要强调的是,这些转换器在列表中的位置代表了其优先级,ByteArrayHttpMessageConverter 最高,MappingJackson2HttpMessageConverter 最低。

另外,如果我又注册了一个 StringHttpMessageConverter,那么最终该属性中将包含 9 个值,其中包含两个 StringHttpMessageConverter 类型的转换器,我自己注册那个优先级最高放在最前面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 进行兼容性匹配,得到既能满足 Accept 又能处理 `returnType` 返回类型的 converter 所支持的媒体类型
List<MediaType> mediaTypesToUse = new ArrayList<>();
for (MediaType requestedType : acceptableTypes) {
for (MediaType producibleType : producibleTypes) {
if (requestedType.isCompatibleWith(producibleType)) {
mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
}
}
}

// 对所有兼容媒体类型进行排序,选取最优媒体类型
MediaType.sortBySpecificityAndQuality(mediaTypesToUse);
for (MediaType mediaType : mediaTypesToUse) {
if (mediaType.isConcrete()) {
selectedMediaType = mediaType;
break;
}
else if (mediaType.isPresentIn(ALL_APPLICATION_MEDIA_TYPES)) {
selectedMediaType = MediaType.APPLICATION_OCTET_STREAM;
break;
}
}

以上步骤完成后,selectedMediaType 即为最终选择的要放到响应头中的 ContentType

  • 根据之前最终选择的 ContentType,以及返回值类型 returnType,选取最合适的 HttpMessageConverter,然后执行 returnValue 到响应体的转换。
1
2
3
4
5
6
7
8
9
// 遍历所有转换器,排前面的先被遍历,所以说优先级高
for (HttpMessageConverter<?> converter : this.messageConverters) {
// 如果该 converter 可以处理指定的 `ContentType` 和返回值类型 `returnType`
// 就让该 converter 执行转换处理:将返回值转换为响应体
if (converter.canWrite(valueType, selectedMediaType)) {
((HttpMessageConverter) converter).write(body, selectedMediaType, outputMessage);
return;
}
}

converter.write 方法底层会将 selectedMediaType 设置到响应头 ContentType 中。

第一种情况:超链接请求 + Servlet response 响应字符串

1
<a th:href="@{/testResponse}">通过原生 Servlet API 的 response 对象响应数据到浏览器</a><br>
1
2
3
4
@RequestMapping("/testResponse")
public void testResponse(HttpServletResponse response) throws IOException {
response.getWriter().print("hello, response 张三");
}

乱码原因:响应头信息中没有设置 ContentType,浏览器不知道响应体的具体编码是什么,从而产生乱码。

解决方案:直接通过 response 对象设置响应头 ContentType

1
2
3
4
5
@RequestMapping("/testResponse")
public void testResponse(HttpServletResponse response) throws IOException {
response.setContentType("text/html;charset=utf-8");
response.getWriter().print("hello, response 张三");
}

第二种情况:超链接请求 + @ResponseBody 响应字符串

1
<a th:href="@{/testResponseBody}">通过 @ResponseBody 响应数据到浏览器</a><br>
1
2
3
4
5
@RequestMapping("/testResponseBody")
@ResponseBody
public String testResponseBody() {
return "hello, @ResponseBody 张三";
}

乱码原因:响应头信息中虽然设置了 ContentType,但是编码不是 UTF-8,而是 ISO-8859-1,从而导致乱码。

综合超链接请求的 Accept 内容,以及 testResponseBody 返回值类型是 String,最终确定的媒体类型是 text/html,且最终将会选择 StringHttpMessageConverter 来进行响应报文信息的转换。

StringHttpMessageConverter 在处理 text/htmlapplication/json 数据时有不同的处理方式:

  • text/html 类型:将 text/html 和默认编码一并设置到响应头的 ContentType
  • application/json 类型:直接将 application/json 设置到响应头的 ContentType 中,直接忽略默认编码,因为服务器会自动为该类型添加 UTF-8 编码。

StringHttpMessageConverter 的默认编码是 ISO-8859-1,因此处理 text/html 类型时,最终响应头中的 ContentType=text/html/charset=iso-8859-1,产生乱码。

1
public static final Charset DEFAULT_CHARSET = StandardCharsets.ISO_8859_1;

解决方案一:在 RequestMapping 中增加 produces 属性,明确指定 ContentType

1
2
3
4
5
@RequestMapping(value = "/testResponseBody", produces = "text/html;charset=utf-8")
@ResponseBody
public String testResponseBody() {
return "hello, @ResponseBody 张三";
}

注意,对于第一种情况,也可以采用这种解决方案。

解决方案二:配置 <mvc:message-converters>,注册一个新的 StringHttpMessageConverter ,并使其默认编码为 UTF-8

打开 SpringMVC 的配置文件,在开启 mvc 注解驱动的标签内部添加配置:

1
2
3
4
5
6
7
8
<mvc:annotation-driven>
<mvc:message-converters>
<!-- 处理响应中文内容乱码 -->
<bean class="org.springframework.http.converter.StringHttpMessageConverter">
<property name="defaultCharset" value="UTF-8" />
</bean>
</mvc:message-converters>
</mvc:annotation-driven>

新注册的 StringHttpMessageConverter 优先级最高,因此会选择该默认编码为 UTF-8 的新转换器进行转换。

PS:SpringBoot 中自动配置了两个 StringHttpMessageConverter,优先级较高的那个其默认编码为 UTF-8,优先级较低的那个默认编码为 ISO-8859-1

UTF-8 编码的 StringHttpMessageConverter 相关的自动配置,详情看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// HttpMessageConvertersAutoConfiguration.java
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(StringHttpMessageConverter.class)
protected static class StringHttpMessageConverterConfiguration {

@Bean
@ConditionalOnMissingBean
public StringHttpMessageConverter stringHttpMessageConverter(Environment environment) {
// 绑定 server.servlet.encoding 为空,因此会调用无参构造创建 Encoding 对象返回,而 Encoding 默认编码就是 UTF-8 编码!
Encoding encoding = Binder.get(environment).bindOrCreate("server.servlet.encoding", Encoding.class);
// 使用 Encoding 的默认编码 UTF-8 来创建 StringHttpMessageConverter 对象
StringHttpMessageConverter converter = new StringHttpMessageConverter(encoding.getCharset());
converter.setWriteAcceptCharset(false);
return converter;
}

}

// Encoding.java
public class Encoding {
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
// 默认编码就是 UTF-8 编码
private Charset charset = DEFAULT_CHARSET;
public Charset getCharset() {
return this.charset;
}
}

第三种情况:超链接请求 + @ResponseBody 响应 Java 对象

1
<a th:href="@{/testResponseUser}">通过 @ResponseBody 响应 User 对象到浏览器</a><br>
1
2
3
4
5
@RequestMapping("/testResponseUser")
@ResponseBody
public User testResponseUser() {
return new User("张三", "123456");
}

无乱码原因:

综合超链接请求的 Accept 内容,以及 testResponseUser 返回值类型是 User,最终确定的媒体类型是 application/json,且最终将会选择 MappingJackson2HttpMessageConverter 来进行响应报文信息的转换。

MappingJackson2HttpMessageConverter 无默认编码,因此只将 application/json 设置到响应头中。

对于 application/json 类型的响应体,服务器默认通知浏览器采用 UTF-8 格式进行编码,因此最终响应头依然会是 ContentType=application/json;charset=UTF-8

第四种情况:Ajax 请求 + @ResponseBody 响应字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<div id="app">
<a @click="testAxios" th:href="@{/testAxios}">SpringMVC 处理 Ajax</a><br>
</div>
<script type="text/javascript" th:src="@{/static/js/vue.js}"></script>
<script type="text/javascript" th:src="@{/static/js/axios.min.js}"></script>
<script type="text/javascript">
new Vue({
el: "#app",
methods: {
testAxios: function (event) {
axios({
method: "post",
url: event.target.href,
params: {
"username": "root",
"password": "123456"
}
}).then(function (response) {
alert(response.data);
});
event.preventDefault();
},
}
});
</script>
1
2
3
4
5
6
@RequestMapping("/testAxios")
@ResponseBody
public String testAxios(String username, String password) {
System.out.println(username + ", " + password);
return "hello, axios 张三";
}

无乱码原因:

综合 Ajax 请求的 Accept 内容,以及 testAxios 返回值类型是 String,最终确定的媒体类型是 application/json,且最终将会选择 StringHttpMessageConverter 来进行响应报文信息的转换。

对于 application/json 类型,StringHttpMessageConverter 直接将 application/json 设置到响应头的 ContentType 中,直接忽略默认编码。

对于 application/json 类型的响应体,服务器默认通知浏览器采用 UTF-8 格式进行编码,因此最终响应头依然会是 ContentType=application/json;charset=UTF-8