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

每篇一句

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

前言

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

@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實現了它而且擴展了它更爲強大~)。java

定義

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

方式

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

  1. 服務端將可用列表(本身能提供的MIME類型們)發給客戶端,客戶端選擇後再告訴服務端。這樣服務端再按照客戶端告訴的MIME返給它。(缺點:多一次網絡交互,並且使用對使用者要求高,因此此方式通常不用
  2. 經常使用)客戶端發請求時就指明須要的MIME們(好比Http頭部的:Accept),服務端根據客戶端指定的要求返回合適的形式,而且在響應頭中作出說明(如:Content-Typejson

    1. 若客戶端要求的MIME類型服務端提供不了,那就406錯誤吧~
經常使用請求頭、響應頭

==請求頭==
Accept:告訴服務端須要的MIME(通常是多個,好比text/plainapplication/json等。/表示能夠是任何MIME資源)
Accept-Language:告訴服務端須要的語言(在中國默認是中文嘛,但瀏覽器通常均可以選擇N多種語言,可是是否支持要看服務器是否能夠協商)
Accept-Charset:告訴服務端須要的字符集
Accept-Encoding:告訴服務端須要的壓縮方式(gzip,deflate,br)
==響應頭==
Content-Type:告訴客戶端響應的媒體類型(如application/jsontext/html等)
Content-Language:告訴客戶端響應的語言
Content-Charset:告訴客戶端響應的字符集
Content-Encoding:告訴客戶端響應的壓縮方式(gzip)segmentfault

報頭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.xml解析出來的MediaTypeapplication/xml
二、拿着這個MediaType(固然還有URL、請求Method等全部)去匹配HandlerMethod的時候會發現producers匹配不上
三、匹配不上就交給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));
        }
    }

四、拋出異常後最終交給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,手動邀請你入羣一塊兒飛==

相關文章
相關標籤/搜索