基本知識
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);
}