SpringMVC處理請求頭、響應頭、編碼行為

wastonl發表於2024-08-17

基本知識

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方法內部呼叫。

  1. 如果透過HttpEntity(或者ResponseEntity)設定了Content-Type響應頭,並且要是具體的,沒有帶萬用字元,那麼直接結束就是它了。

  2. 獲取請求頭Accept的值,返回一個MediaType列表,記為acceptableTypes

  3. 獲取服務端可以產生的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是否滿足條件。

  4. 使用acceptableTypes中的值來對producibleMediaTypes進行過濾,最後排序找到一個最佳的MediaType。

  5. 如果這個MediaType中沒有charset部分,使用最終處理的HttpMessageConverter裡的charset組成一個新的MediaType。

MediaType finalMediaType = new MediaType(mediaType, charset);
  1. 終於找到合適的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);
}

相關文章