SpringMVC处理请求头、响应头、编码行为
基本知识
http协议中,请求行、请求头部分都是采用的ascii编码,是不支持中文的,若携带不支持的字符,需要使用进行编码,比如常见的urlEncode。而请求体是支持任意编码的,通过Content-Type请求头的charset部分来告知服务端请求体使用何种方式编码。
响应行、响应头、响应体亦如是。
Content-Type格式
text/html;charset=utf-8
单个头部如果有多个值时,某些头部可以使用逗号进行分割,如Cache-Control、Accept、Content-Type,在Http1.1中,使用多个同名头字段来表示,任意头部都可。
如何获取请求头
第一种方式,使用servlet中的api
@GetMapping("/receiveHeader")
public void receiveHeader(HttpServletRequest request) {
// 获取第一个值
// String value = request.getHeader("myHead");
// 获取所有值
Enumeration<String> myHead = request.getHeaders("myHead");
while (myHead.hasMoreElements()) {
System.out.println(myHead.nextElement());
}
}
第二种方式,使用SpringMVC提供的@RequestHeader注解
/**
* 功能比较强大,支持类型转换
* 接收多个值可使用容器类型、数组类型接收。
* 当然了这个方式也是封装了下servlet的原生api实现的
*/
@GetMapping("/receiveHeader")
public void receiveHeader(@RequestHeader(name="myHead") List<Integer> myHeads) {
System.out.println(myHeads);
}
如何设置响应头
第一种方式,使用servlet中的api
@GetMapping("/setHeader")
public String setHeader(HttpServletResponse response) {
// 覆盖
response.setHeader("myHead1", "value");
// 添加一个同名头字段
response.addHeader("myHead2", "value1");
response.addHeader("myHead2", "value2")
// 特殊响应头, 有些响应头比较重要,直接作为response的属性
// 当调用setHeader或者addHeader方法时,内部直接调用对应的setXxx方法
response.setContentType("text/html;charset=utf-8");
return "success";
}
第二种方式,使用SpringMVC提供的HttpEntity、ResponseEntity(继承了HttpEntity,多了响应码)
@GetMapping("/setHeader1")
public HttpEntity<String> setHeader1() {
HttpHeaders headers = new HttpHeaders();
// add方法, 添加一个同名头字段
headers.add("myHead1", "value1");
headers.add("myHead1", "value2");
headers.addAll("myHead2", List.of("value1", "value2"));
// set方法
headers.set("myHead3", "value");
// 特殊响应头
headers.setContentType(MediaType.TEXT_HTML);
return new HttpEntity<>("success", headers);
}
SpringMVC提供的这种方式肯定也是封装了servlet的api实现的,它在调用响应流写响应体时会先把设置的请求头调用servlet的api放到response中。
// ServletServerHttpResponse.java
@Override
public OutputStream getBody() throws IOException {
this.bodyUsed = true;
// 写响应头到HttpServletResponse
writeHeaders();
// 调用HttpServletResponse api返回响应输出流
return this.servletResponse.getOutputStream();
}
Content-Type响应头以及编码
Content-Type响应头非常重要,告知了客户端响应体数据类型以及编码方式,在SpringMVC开发模式中正常情况下是不用开发者来设置的,但是如果通过Servlet API来设置这个响应头会出现一些诡异的现象而掉进坑里。
因此如果不是自己直接使用HttpServletResponse的输出流来输出数据,那么务必使用HttpEntity方式来设置该请求头。
原生Servlet写法
先看原生servlet写法,直接使用HttpServletResponse的响应流输出数据
/**
* 输出响应(字节流方式)
* 字节流方式,由自己控制编码,必须保证和Content-Type中的charset保持一致,否则乱码
*/
@GetMapping("/printBody")
public void printBody(HttpServletResponse response) throws IOException {
// text/html;charset=UTF-8
response.setContentType(new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8).toString());
response.getOutputStream().write("中文字符".getBytes(StandardCharsets.UTF_8));
response.getOutputStream().flush();
//response.getWriter().write("中文字符");
}
/**
* 输出响应(字符流方式)
* response内部写字符流时最终都会变成写字节流,会使用Content-Type中指定的编码,这样就不会乱码了
*/
@GetMapping("/printBody")
public void printBody(HttpServletResponse response) throws IOException {
// text/html;charset=UTF-8
response.setContentType(new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8).toString());
response.getWriter().write("中文字符");
response.getWriter().flush();
}
注1:response对象还有一个setCharacterEncoding("utf-8"),效果是一样的,也是设置编码,只不过setContentType方法同时设置了contentType、charset两个属性,response对象是有连个字段来存储的,contentType属性存储不带charset的部分。
注2:由于这种方式不会经过SpringMVC的handleReturnValue逻辑,因此直接通过response的api设置不会出现问题。
注3:什么情况下,不会经过SpringMVC的handleReturnValue逻辑呢?
handleReturnValue主要逻辑就是根据Hander(Controller中的映射方法)的返回值,去找最匹配的HttpMessageConverter,将这个数据调用ServletHttpResponse的输出流将数据响应给客户端,同时会设置对应的Content-Type。
/**
* 关键代码
* ServletInvocableHandlerMethod.java
*/
public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
// 执行hander方法获取返回值
Object returnValue = invokeForRequest(webRequest, mavContainer, providedArgs);
setResponseStatus(webRequest);
// 如何返回值为null(前提条件)
if (returnValue == null) {
// 存在响应状态码或者mavContainer.isRequestHandled()
if (isRequestNotModified(webRequest) || getResponseStatus() != null || mavContainer.isRequestHandled()) {
disableContentCachingIfNecessary(webRequest);
mavContainer.setRequestHandled(true);
return;
}
}
else if (StringUtils.hasText(getResponseStatusReason())) {
mavContainer.setRequestHandled(true);
return;
}
mavContainer.setRequestHandled(false);
Assert.state(this.returnValueHandlers != null, "No return value handlers");
try {
// 处理返回值,响应数据给客户端
this.returnValueHandlers.handleReturnValue(
returnValue, getReturnValueType(returnValue), mavContainer, webRequest);
}
catch (Exception ex) {
if (logger.isTraceEnabled()) {
logger.trace(formatErrorForReturnValue(returnValue), ex);
}
throw ex;
}
}
当Controller方法返回null时(或者方法返回类型为void也是返回null),此为前提条件。
第一种情况,响应状态码有值,即存在@ResponseStatus注解
@GetMapping("/printBody")
@ResponseStatus(HttpStatus.OK)
public String printBody1() throws IOException {
return null;
}
@GetMapping("/printBody")
@ResponseStatus(HttpStatus.OK)
public void printBody1() throws IOException {
}
第二种情况,mavContainer.isRequestHandled() = true
当Controller方法参数中存在HttpServletResponse参数时,会将requestHandlerd设置成true
@GetMapping("/printBody")
public void printBody(HttpServletResponse response) throws IOException {
}
其它情况不去细究了。
SpringMVC处理响应体
现在大部分情况下写法都是由SpringMVC自己来帮我们处理响应体的,尤其是application/json形式,默认编码就是utf-8。目前想到的就是下载情况了,由我们设置Content-Type,调用响应流来输出数据,即上面那种写法。
那么handleReturnValue
方法内部逻辑是如何来寻找最合适的Content-Type(MediaType)
具体逻辑位于AbstractMessageConverterMethodProcessor.writeWithMessageConverters
,由handleReturnValue
方法内部调用。
-
如果通过HttpEntity(或者ResponseEntity)设置了Content-Type响应头,并且要是具体的,没有带通配符,那么直接结束就是它了。
-
获取请求头Accept的值,返回一个MediaType列表,记为acceptableTypes
-
获取服务端可以产生的MediaType列表,记为producibleMediaTypes
3.1 从HttpServletRequest获取,如获取到则producibleMediaTypes就是它了
/** * 由Controller方法上的@RequestMapping中的produces属性指定 * 如下面例子 * @GetMapping(value = "/", produces = {MediaType.APPLICATION_JSON_VALUE, "text/html;charset=UTF-8"}) */ Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
3.2 根据响应体的类型,从一系列HttpMessageConverter中获取MediaType列表,根据canWrite方法来判断该HttpMessageConverter是否满足条件。
-
使用acceptableTypes中的值来对producibleMediaTypes进行过滤,最后排序找到一个最佳的MediaType。
-
如果这个MediaType中没有charset部分,使用最终处理的HttpMessageConverter里的charset组成一个新的MediaType。
MediaType finalMediaType = new MediaType(mediaType, charset);
- 终于找到合适的mediaType了,也就是Content-Type,然后设置Content-Type响应头,这一步会覆盖掉我们自己在HttpServletResponse中设置的Content-Type以及Charset。
@Override
public final void write(final T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
final HttpHeaders headers = outputMessage.getHeaders();
/**
* 添加Content-Type响应头
* 这里只是添加到SpringMVC自己的对象中,真正添加到HttpServletResponse中是真正写响应体时
* 调用outputMessage.getBody方法触发
*/
addDefaultHeaders(headers, t, contentType);
// 省略分支逻辑
// 真正写数据到输出流中
writeInternal(t, outputMessage);
outputMessage.getBody().flush();
}
protected void addDefaultHeaders(HttpHeaders headers, T t, @Nullable MediaType contentType) throws IOException {
// 响应头中Content-Type为空,使用mediaType给它赋值
if (headers.getContentType() == null) {
MediaType contentTypeToUse = contentType;
if (contentType == null || !contentType.isConcrete()) {
contentTypeToUse = getDefaultContentType(t);
}
else if (MediaType.APPLICATION_OCTET_STREAM.equals(contentType)) {
MediaType mediaType = getDefaultContentType(t);
contentTypeToUse = (mediaType != null ? mediaType : contentTypeToUse);
}
if (contentTypeToUse != null) {
// mediaType中charset为null,使用该HttpMessageConverter中的charset
if (contentTypeToUse.getCharset() == null) {
Charset defaultCharset = getDefaultCharset();
if (defaultCharset != null) {
contentTypeToUse = new MediaType(contentTypeToUse, defaultCharset);
}
}
// 设置Content-Type
headers.setContentType(contentTypeToUse);
}
}
if (headers.getContentLength() < 0 && !headers.containsKey(HttpHeaders.TRANSFER_ENCODING)) {
Long contentLength = getContentLength(t, headers.getContentType());
if (contentLength != null) {
headers.setContentLength(contentLength);
}
}
}
将SpringMVC自己的响应头同步到HttpServletResponse中
响应数据肯定要获取到响应输出流,通过HttpOutputMessage的getBody来获取
// ServletServerHttpResponse.java
@Override
public OutputStream getBody() throws IOException {
this.bodyUsed = true;
// 写响应头到HttpServletResponse
writeHeaders();
// 调用HttpServletResponse api返回响应输出流
return this.servletResponse.getOutputStream();
}
/**
* 可以看到会直接覆盖Content-Type以及charset,也就是自己设置的没有用
*/
private void writeHeaders() {
if (!this.headersWritten) {
getHeaders().forEach((headerName, headerValues) -> {
for (String headerValue : headerValues) {
this.servletResponse.addHeader(headerName, headerValue);
}
});
// HttpServletResponse exposes some headers as properties: we should include those if not already present
if (this.servletResponse.getContentType() == null && this.headers.getContentType() != null) {
this.servletResponse.setContentType(this.headers.getContentType().toString());
}
if (this.servletResponse.getCharacterEncoding() == null && this.headers.getContentType() != null &&
this.headers.getContentType().getCharset() != null) {
this.servletResponse.setCharacterEncoding(this.headers.getContentType().getCharset().name());
}
long contentLength = getHeaders().getContentLength();
if (contentLength != -1) {
this.servletResponse.setContentLengthLong(contentLength);
}
this.headersWritten = true;
}
}
因此如果我们响应字符串时,想要调整编码方式时,一定要使用HttpEntity方式或者指定@RequestMapping注解的produces属性。
看一些例子:
@GetMapping("/charsetTest")
public String charsetTest(HttpServletResponse response) {
/**
* 响应头设置无效
* SpringMVC最终找到最符合的是MediaType.TEXT_HTML,且它没有charset部分
* 而在SpringBoot中内置的StringHttpMessageConverter使用utf-8编码,因此不会乱码
* 实际返回的Content-Type是text/html;charset=UTF-8
*/
response.setContentType(MediaType.TEXT_HTML_VALUE);
response.setCharacterEncoding(StandardCharsets.ISO_8859_1.name());
return "hello,中国";
}
/**
* 乱码
* 实际返回的Content-Type是text/html;charset=ISO-8859-1
* ISO-8859-1不支持中文
*/
@GetMapping(value = "/charsetTest", produces = {"text/html;charset=ISO-8859-1"})
public String charsetTest() {
return "hello,中国";
}
/**
* 乱码
* 实际返回的Content-Type是text/html;charset=ISO-8859-1
* ISO-8859-1不支持中文
*/
@GetMapping(value = "/charsetTest")
public HttpEntity<String> charsetTest() {
HttpHeaders headers = new HttpHeaders();
// 设置响应头, 且带编码
headers.setContentType(new MediaType(MediaType.TEXT_HTML, StandardCharsets.ISO_8859_1));
return new HttpEntity<>("hello,中国", headers);
}