1. 程式人生 > >Spring MVC內建支援的4種內容協商方式【享學Spring MVC】

Spring MVC內建支援的4種內容協商方式【享學Spring MVC】

每篇一句

十個光頭九個富,最後一個會砍樹

前言

不知你在使用Spring Boot時是否對這樣一個現象"詫異"過:同一個介面(同一個URL)在介面報錯情況下,若你用rest訪問,它返回給你的是一個json串;但若你用瀏覽器訪問,它返回給你的是一段html。恰如下面例子(Spring Boot環境~):

@RestController
@RequestMapping
public class HelloController {
    @GetMapping("/test/error")
    public Object testError() {
        System.out.println(1 / 0); // 強制丟擲異常
        return "hello world";
    }
}

使用瀏覽器訪問:http://localhost:8080/test/error

使用Postman訪問:

同根不同命有木有。RESTful服務中很重要的一個特性是:同一資源可以有多種表述,這就是我們今天文章的主題:內容協商(ContentNegotiation)。

HTTP內容協商

雖然本文主要是想說Spring MVC中的內容協商機制,但是在此之前是很有必要先了解HTTP的內容協商是怎麼回事(Spring MVC實現了它並且擴充套件了它更為強大~)。

定義

一個URL資源服務端可以以多種形式進行響應:即MIME(MediaType)媒體型別。但對於某一個客戶端(瀏覽器、APP、Excel匯出...)來說它只需要一種。so這樣客戶端和服務端就得有一種機制來保證這個事情,這種機制就是內容協商機制。

方式

http的內容協商方式大致有兩種:

  1. 服務端將可用列表(自己能提供的MIME型別們)發給客戶端,客戶端選擇後再告訴服務端。這樣服務端再按照客戶端告訴的MIME返給它。(缺點:多一次網路互動,而且使用對使用者要求高,所以此方式一般不用)
  2. (常用)客戶端發請求時就指明需要的MIME們(比如Http頭部的:Accept),服務端根據客戶端指定的要求返回合適的形式,並且在響應頭中做出說明(如:Content-Type
    1. 若客戶端要求的MIME型別服務端提供不了,那就406錯誤吧~

    常用請求頭、響應頭

    ==請求頭==
    Accept:告訴服務端需要的MIME(一般是多個,比如text/plain

    application/json等。/表示可以是任何MIME資源)
    Accept-Language:告訴服務端需要的語言(在中國預設是中文嘛,但瀏覽器一般都可以選擇N多種語言,但是是否支援要看伺服器是否可以協商)
    Accept-Charset:告訴服務端需要的字符集
    Accept-Encoding:告訴服務端需要的壓縮方式(gzip,deflate,br)
    ==響應頭==
    Content-Type:告訴客戶端響應的媒體型別(如application/jsontext/html等)
    Content-Language:告訴客戶端響應的語言
    Content-Charset:告訴客戶端響應的字符集
    Content-Encoding:告訴客戶端響應的壓縮方式(gzip)

    報頭AcceptContent-Type的區別

    有很多文章粗暴的解釋:Accept屬於請求頭,Content-Type屬於響應頭,其實這是不準確的。
    在前後端分離開發成為主流的今天,你應該不乏見到前端的request請求上大都有Content-Type:application/json;charset=utf-8這個請求頭,因此可見Content-Type並不僅僅是響應頭。

HTTP協議規範的格式如下四部分:

  1. <request-line>(請求訊息行)
  2. <headers>(請求訊息頭)
  3. <blank line>(請求空白行)
  4. <request-body>(請求訊息體)

Content-Type指請求訊息體的資料格式,因為請求和響應中都可以有訊息體,所以它即可用在請求頭,亦可用在響應頭。
關於更多Http中的Content-Type的內容,我推薦參見此文章:Http請求中的Content-Type


Spring MVC內容協商

Spring MVC實現了HTTP內容協商的同時,又進行了擴充套件。它支援4種協商方式:

  1. HTTPAccept
  2. 副檔名
  3. 請求引數
  4. 固定型別(producers)

說明:以下示例基於Spring進行演示,而非Spring Boot

方式一:HTTP頭Accept

@RestController
@RequestMapping
public class HelloController {
    @ResponseBody
    @GetMapping("/test/{id}")
    public Person test(@PathVariable(required = false) String id) {
        System.out.println("id的值為:" + id);
        Person person = new Person();
        person.setName("fsx");
        person.setAge(18);
        return person;
    }
}

如果預設就這樣,不管瀏覽器訪問還是Postman訪問,得到的都是json串。

但若你僅僅只需在pom加入如下兩個包:

<!-- 此處需要匯入databind包即可, jackson-annotations、jackson-core都不需要顯示自己的匯入了-->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.9.8</version>
</dependency>
<!-- jackson預設只會支援的json。若要xml的支援,需要額外匯入如下包 -->
<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
    <version>2.9.8</version>
</dependency>

再用瀏覽器/Postman訪問,得到結果就是xml了,形如這樣:

有的文章說:瀏覽器是xml,postman是json。本人親試:都是xml。

但若我們postman手動指定這個頭:Accept:application/json,返回就和瀏覽器有差異了(若不手動指定,Accept預設值是*/*):

並且我們可以看到response的頭資訊對比如下:
手動指定了Accept:application/json

木有指定Accept(預設*/*):

原因簡析

Chrome瀏覽器請求預設發出的Accept是:Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
由於我例子使用的是@ResponseBody,因此它不會返回一個view:交給訊息轉換器處理,因此這就和MediaType以及權重有關了。

訊息最終都會交給AbstractMessageConverterMethodProcessor.writeWithMessageConverters()方法:

// @since 3.1
AbstractMessageConverterMethodProcessor:
    protected <T> void writeWithMessageConverters( ... ) {
        Object body;
        Class<?> valueType;
        Type targetType;
        ...
        HttpServletRequest request = inputMessage.getServletRequest();
        // 這裡交給contentNegotiationManager.resolveMediaTypes()  找出客戶端可以接受的MediaType們~~~
        // 此處是已經排序好的(根據Q值等等)
        List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
        // 這是服務端它所能提供出的MediaType們
        List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
    
        // 協商。 經過一定的排序、匹配  最終匹配出一個合適的MediaType
        ...
        // 把待使用的們再次排序,
        MediaType.sortBySpecificityAndQuality(mediaTypesToUse);

        // 最終找出一個最合適的、最終使用的:selectedMediaType 
            for (MediaType mediaType : mediaTypesToUse) {
                if (mediaType.isConcrete()) {
                    selectedMediaType = mediaType;
                    break;
                } else if (mediaType.isPresentIn(ALL_APPLICATION_MEDIA_TYPES)) {
                    selectedMediaType = MediaType.APPLICATION_OCTET_STREAM;
                    break;
                }
            }
    }

acceptableTypes是客戶端通過Accept告知的。
producibleTypes代表著服務端所能提供的型別們。參考這個getProducibleMediaTypes()方法:

AbstractMessageConverterMethodProcessor:

    protected List<MediaType> getProducibleMediaTypes( ... ) {
        // 它設值的地方唯一在於:@RequestMapping.producers屬性
        // 大多數情況下:我們一般都不會給此屬性賦值吧~~~
        Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
        if (!CollectionUtils.isEmpty(mediaTypes)) {
            return new ArrayList<>(mediaTypes);
        }
        // 大多數情況下:都會走進這個邏輯 --> 從訊息轉換器中匹配一個合適的出來
        else if (!this.allSupportedMediaTypes.isEmpty()) {
            List<MediaType> result = new ArrayList<>();
            // 從所有的訊息轉換器中  匹配出一個/多個List<MediaType> result出來
            // 這就代表著:我服務端所能支援的所有的List<MediaType>們了
            for (HttpMessageConverter<?> converter : this.messageConverters) {
                if (converter instanceof GenericHttpMessageConverter && targetType != null) {
                    if (((GenericHttpMessageConverter<?>) converter).canWrite(targetType, valueClass, null)) {
                        result.addAll(converter.getSupportedMediaTypes());
                    }
                }
                else if (converter.canWrite(valueClass, null)) {
                    result.addAll(converter.getSupportedMediaTypes());
                }
            }
            return result;
        } else { 
            return Collections.singletonList(MediaType.ALL);
        }
    }

可以看到服務端最終能夠提供哪些MediaType,來源於訊息轉換器HttpMessageConverter對型別的支援。
本例的現象:起初返回的是json串,僅僅只需要匯入jackson-dataformat-xml後就返回xml了。原因是因為加入MappingJackson2XmlHttpMessageConverter都有這個判斷:

    private static final boolean jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader);
    
        if (jackson2XmlPresent) {
            addPartConverter(new MappingJackson2XmlHttpMessageConverter());
        }

所以預設情況下Spring MVC並不支援application/xml這種媒體格式,所以若不導包協商出來的結果是:application/json

預設情況下優先順序是xml高於json。當然一般都木有xml包,所以才輪到json的。

另外還需要注意一點:有的小夥伴說通過在請求頭裡指定Content-Type:application/json來達到效果。現在你應該知道,這樣做顯然是沒用的(至於為何沒用,希望讀者做到了心知肚明),只能使用Accept這個頭來指定~~~

第一種協商方式是Spring MVC完全基於HTTP Accept首部的方式了。該種方式Spring MVC預設支援且預設已開啟。
優缺點:

  • 優點:理想的標準方式
  • 缺點:由於瀏覽器的差異,導致傳送的Accept Header頭可能會不一樣,從而得到的結果不具備瀏覽器相容性

    方式二:(變數)副檔名

    基於上面例子:若我訪問/test/1.xml返回的是xml,若訪問/test/1.json返回的是json;完美~

這種方式使用起來非常的便捷,並且還不依賴於瀏覽器。但我總結了如下幾點使時的注意事項:

  1. 副檔名必須是變數的副檔名。比如上例若訪問test.json / test.xml就404~
  2. @PathVariable的引數型別只能使用通用型別(String/Object),因為接收過來的value值就是1.json/1.xml,所以若用Integer接收將報錯型別轉換錯誤~
    1. 小技巧:我個人建議是這部分不接收(這部分不使用@PathVariable接收),拿出來只為內容協商使用
  3. 副檔名優先順序比Accept要高(並且和使用神馬瀏覽器無關)

優缺點:

  • 優點:靈活,不受瀏覽器約束
  • 缺點:喪失了同一URL的多種展現方式。在實際環境中使用還是較多的,因為這種方式更符合程式設計師的習慣

    方式三:請求引數

    這種協商方式Spring MVC支援,但預設是關閉的,需要顯示的開啟:
@Configuration
@EnableWebMvc
public class WebMvcConfig extends WebMvcConfigurerAdapter {
    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        // 支援請求引數協商
        configurer.favorParameter(true);
    }
}

請求URL:/test/1?format=xml返回xml;/test/1?format=json返回json。同樣的我總結如下幾點注意事項:

  1. 前兩種方式預設是開啟的,但此種方式需要手動顯示開啟
  2. 此方式優先順序低於副檔名(因此你測試時若想它生效,請去掉url的字尾)

優缺點:

  • 優點:不受瀏覽器約束
  • 缺點:需要額外的傳遞format引數,URL變得冗餘繁瑣,缺少了REST的簡潔風範。還有個缺點便是:還需手動顯示開啟。

    方式四:固定型別(produces)
    它就是利用@RequestMapping註解屬性produces(可能你平時也在用,但並不知道原因):
@ResponseBody
@GetMapping(value = {"/test/{id}", "/test"}, produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public Person test() { ... }

訪問:/test/1返回的就是json;即使你已經匯入了jackson的xml包,返回的依舊還是json。

它也有它很很很重要的一個注意事項:produces指定的MediaType型別不能和字尾、請求引數、Accept衝突。例如本利這裡指定了json格式,如果你這麼訪問/test/1.xml,或者format=xml,或者Accept不是application/json或者*/* 將無法完成內容協商:http狀態碼為406,報錯如下:

produces使用固然也比較簡單,針對上面報錯406的原因,我簡單解釋如下。

原因:

1、先解析請求的媒體型別:1.xml解析出來的MediaTypeapplication/xml
2、拿著這個MediaType(當然還有URL、請求Method等所有)去匹配HandlerMethod的時候會發現producers匹配不上
3、匹配不上就交給RequestMappingInfoHandlerMapping.handleNoMatch()處理:

RequestMappingInfoHandlerMapping:

    @Override
    protected HandlerMethod handleNoMatch(...) {
        if (helper.hasConsumesMismatch()) {
            ...
            throw new HttpMediaTypeNotSupportedException(contentType, new ArrayList<>(mediaTypes));
        }
        // 丟擲異常:HttpMediaTypeNotAcceptableException
        if (helper.hasProducesMismatch()) {
            Set<MediaType> mediaTypes = helper.getProducibleMediaTypes();
            throw new HttpMediaTypeNotAcceptableException(new ArrayList<>(mediaTypes));
        }
    }   

4、丟擲異常後最終交給DispatcherServlet.processHandlerException()去處理這個異常,轉換到Http狀態碼

會呼叫所有的handlerExceptionResolvers來處理這個異常,本處會被DefaultHandlerExceptionResolver最終處理。最終處理程式碼如下(406狀態碼):

    protected ModelAndView handleHttpMediaTypeNotAcceptable(HttpMediaTypeNotAcceptableException ex,
            HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {

        response.sendError(HttpServletResponse.SC_NOT_ACCEPTABLE);
        return new ModelAndView();
    }

Spring MVC預設註冊的異常處理器是如下3個:

原理

有了關於Accept的原理描述,理解它就非常簡單了。因為指定了produces屬性,所以getProducibleMediaTypes()方法在拿服務端支援的媒體型別時:

protected List<MediaType> getProducibleMediaTypes( ... ){
    Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
    if (!CollectionUtils.isEmpty(mediaTypes)) {
        return new ArrayList<>(mediaTypes);
    }
    ...
}

因為設定了producers,所以程式碼第一句就能拿到值了(後面的協商機制完全同上)。

備註:若produces屬性你要指定的非常多,建議可以使用!xxx語法,它是支援這種語法(排除語法)的~

優缺點:

  • 優點:使用簡單,天然支援
  • 缺點:讓HandlerMethod處理器缺失靈活性

    Spring Boot預設異常訊息處理

    再回到開頭的Spring Boot為何對異常訊息,瀏覽器和postman的展示不一樣。這就是Spring Boot預設的對異常處理方式:它使用的就是基於 固定型別(produces)實現的內容協商。

Spirng Boot出現異常資訊時候,會預設訪問/error,它的處理類是:BasicErrorController

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
    ...
    // 處理類瀏覽器
    @RequestMapping(produces = "text/html")
    public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
        ... 
        return (modelAndView != null ? modelAndView : new ModelAndView("error", model));
    }

    // 處理restful/json方式
    @RequestMapping
    @ResponseBody
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
        Map<String, Object> body = getErrorAttributes(request, isIncludeStackTrace(request, MediaType.ALL));
        HttpStatus status = getStatus(request);
        return new ResponseEntity<Map<String, Object>>(body, status);
    }
    ...
}

有了上面的解釋,對這塊程式碼的理解應該就沒有盲點了~

總結

內容協商在RESTful流行的今天還是非常重要的一塊內容,它對於提升使用者體驗,提升效率和降低維護成本都有不可忽視的作用,注意它三的優先順序為:字尾 > 請求引數 > HTTP首部Accept

一般情況下,我們為了通用都會使用基於Http的內容協商(Accept),但在實際應用中其實很少用它,因為不同的瀏覽器可能導致不同的行為(比如ChromeFirefox就很不一樣),所以為了保證“穩定性”一般都選擇使用方案二或方案三(比如Spring的官方doc)。

相關閱讀

【小家Spring】Spring MVC容器的web九大元件之---HandlerMapping原始碼詳解(二)---RequestMappingHandlerMapping系列

ContentNegotiation內容協商機制(一)---Spring MVC內建支援的4種內容協商方式【享學Spring MVC】
ContentNegotiation內容協商機制(二)---Spring MVC內容協商實現原理及自定義配置【享學Spring MVC】
ContentNegotiation內容協商機制(三)---在檢視View上的應用:ContentNegotiatingViewResolver深度解析【享學Spring MVC】

知識交流

==The last:如果覺得本文對你有幫助,不妨點個讚唄。當然分享到你的朋友圈讓更多小夥伴看到也是被作者本人許可的~==

若對技術內容感興趣可以加入wx群交流:Java高工、架構師3群
若群二維碼失效,請加wx號:fsx641385712(或者掃描下方wx二維碼)。並且備註:"java入群" 字樣,會手動邀請入群
==若對Spring、SpringBoot、MyBatis等原始碼分析感興趣,可加我wx:fsx641385712,手動邀請你入群一起