Spring MVC內(nèi)置支持的4種內(nèi)容協(xié)商方式【享學(xué)Spring MVC】

每篇一句

十個(gè)光頭九個(gè)富,最后一個(gè)會(huì)砍樹(shù)

前言

不知你在使用Spring Boot時(shí)是否對(duì)這樣一個(gè)現(xiàn)象"詫異"過(guò):同一個(gè)接口(同一個(gè)URL)在接口報(bào)錯(cuò)情況下,若你用rest訪問(wèn),它返回給你的是一個(gè)json串;但若你用瀏覽器訪問(wèn),它返回給你的是一段html。恰如下面例子(Spring Boot環(huán)境~):

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

使用瀏覽器訪問(wèn):http://localhost:8080/test/error

在這里插入圖片描述

使用Postman訪問(wèn):
在這里插入圖片描述

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

HTTP內(nèi)容協(xié)商

雖然本文主要是想說(shuō)Spring MVC中的內(nèi)容協(xié)商機(jī)制,但是在此之前是很有必要先了解HTTP的內(nèi)容協(xié)商是怎么回事(Spring MVC實(shí)現(xiàn)了它并且擴(kuò)展了它更為強(qiáng)大~)。

定義

一個(gè)URL資源服務(wù)端可以以多種形式進(jìn)行響應(yīng):即MIME(MediaType)媒體類型。但對(duì)于某一個(gè)客戶端(瀏覽器、APP、Excel導(dǎo)出...)來(lái)說(shuō)它只需要一種。so這樣客戶端和服務(wù)端就得有一種機(jī)制來(lái)保證這個(gè)事情,這種機(jī)制就是內(nèi)容協(xié)商機(jī)制。

方式

http的內(nèi)容協(xié)商方式大致有兩種:

  1. 服務(wù)端將可用列表(自己能提供的MIME類型們)發(fā)給客戶端,客戶端選擇后再告訴服務(wù)端。這樣服務(wù)端再按照客戶端告訴的MIME返給它。(缺點(diǎn):多一次網(wǎng)絡(luò)交互,而且使用對(duì)使用者要求高,所以此方式一般不用
  2. 常用)客戶端發(fā)請(qǐng)求時(shí)就指明需要的MIME們(比如Http頭部的:Accept),服務(wù)端根據(jù)客戶端指定的要求返回合適的形式,并且在響應(yīng)頭中做出說(shuō)明(如:Content-Type
    1. 若客戶端要求的MIME類型服務(wù)端提供不了,那就406錯(cuò)誤吧~
常用請(qǐng)求頭、響應(yīng)頭

==請(qǐng)求頭==
Accept:告訴服務(wù)端需要的MIME(一般是多個(gè),比如text/plainapplication/json等。/表示可以是任何MIME資源)
Accept-Language:告訴服務(wù)端需要的語(yǔ)言(在中國(guó)默認(rèn)是中文嘛,但瀏覽器一般都可以選擇N多種語(yǔ)言,但是是否支持要看服務(wù)器是否可以協(xié)商)
Accept-Charset:告訴服務(wù)端需要的字符集
Accept-Encoding:告訴服務(wù)端需要的壓縮方式(gzip,deflate,br)
==響應(yīng)頭==
Content-Type:告訴客戶端響應(yīng)的媒體類型(如application/json、text/html等)
Content-Language:告訴客戶端響應(yīng)的語(yǔ)言
Content-Charset:告訴客戶端響應(yīng)的字符集
Content-Encoding:告訴客戶端響應(yīng)的壓縮方式(gzip)

報(bào)頭AcceptContent-Type的區(qū)別

有很多文章粗暴的解釋:Accept屬于請(qǐng)求頭,Content-Type屬于響應(yīng)頭,其實(shí)這是不準(zhǔn)確的。
在前后端分離開(kāi)發(fā)成為主流的今天,你應(yīng)該不乏見(jiàn)到前端的request請(qǐng)求上大都有Content-Type:application/json;charset=utf-8這個(gè)請(qǐng)求頭,因此可見(jiàn)Content-Type并不僅僅是響應(yīng)頭。

HTTP協(xié)議規(guī)范的格式如下四部分

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

Content-Type請(qǐng)求消息體的數(shù)據(jù)格式,因?yàn)?strong>請(qǐng)求和響應(yīng)中都可以有消息體,所以它即可用在請(qǐng)求頭,亦可用在響應(yīng)頭。
關(guān)于更多Http中的Content-Type的內(nèi)容,我推薦參見(jiàn)此文章:Http請(qǐng)求中的Content-Type


Spring MVC內(nèi)容協(xié)商

Spring MVC實(shí)現(xiàn)了HTTP內(nèi)容協(xié)商的同時(shí),又進(jìn)行了擴(kuò)展。它支持4種協(xié)商方式:

  1. HTTPAccept
  2. 擴(kuò)展名
  3. 請(qǐng)求參數(shù)
  4. 固定類型(producers)

說(shuō)明:以下示例基于Spring進(jìn)行演示,而非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;
    }
}

如果默認(rèn)就這樣,不管瀏覽器訪問(wèn)還是Postman訪問(wèn),得到的都是json串。

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

<!-- 此處需要導(dǎo)入databind包即可, jackson-annotations、jackson-core都不需要顯示自己的導(dǎo)入了-->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.9.8</version>
</dependency>
<!-- jackson默認(rèn)只會(huì)支持的json。若要xml的支持,需要額外導(dǎo)入如下包 -->
<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
    <version>2.9.8</version>
</dependency>

再用瀏覽器/Postman訪問(wèn),得到結(jié)果就是xml了,形如這樣:

在這里插入圖片描述

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

但若我們postman手動(dòng)指定這個(gè)頭:Accept:application/json,返回就和瀏覽器有差異了(若不手動(dòng)指定,Accept默認(rèn)值是*/*):

在這里插入圖片描述

并且我們可以看到response的頭信息對(duì)比如下:
手動(dòng)指定了Accept:application/json
在這里插入圖片描述

木有指定Accept(默認(rèn)*/*):
在這里插入圖片描述

原因簡(jiǎn)析

Chrome瀏覽器請(qǐng)求默認(rèn)發(fā)出的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,因此它不會(huì)返回一個(gè)view:交給消息轉(zhuǎn)換器處理,因此這就和MediaType以及權(quán)重有關(guān)了。

消息最終都會(huì)交給AbstractMessageConverterMethodProcessor.writeWithMessageConverters()方法:

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

        // 最終找出一個(gè)最合適的、最終使用的: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是客戶端通過(guò)Accept告知的。
producibleTypes代表著服務(wù)端所能提供的類型們。參考這個(gè)getProducibleMediaTypes()方法:

AbstractMessageConverterMethodProcessor:

    protected List<MediaType> getProducibleMediaTypes( ... ) {
        // 它設(shè)值的地方唯一在于:@RequestMapping.producers屬性
        // 大多數(shù)情況下:我們一般都不會(huì)給此屬性賦值吧~~~
        Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
        if (!CollectionUtils.isEmpty(mediaTypes)) {
            return new ArrayList<>(mediaTypes);
        }
        // 大多數(shù)情況下:都會(huì)走進(jìn)這個(gè)邏輯 --> 從消息轉(zhuǎn)換器中匹配一個(gè)合適的出來(lái)
        else if (!this.allSupportedMediaTypes.isEmpty()) {
            List<MediaType> result = new ArrayList<>();
            // 從所有的消息轉(zhuǎn)換器中  匹配出一個(gè)/多個(gè)List<MediaType> result出來(lái)
            // 這就代表著:我服務(wù)端所能支持的所有的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);
        }
    }

可以看到服務(wù)端最終能夠提供哪些MediaType,來(lái)源于消息轉(zhuǎn)換器HttpMessageConverter對(duì)類型的支持。
本例的現(xiàn)象:起初返回的是json串,僅僅只需要導(dǎo)入jackson-dataformat-xml后就返回xml了。原因是因?yàn)榧尤?code>MappingJackson2XmlHttpMessageConverter都有這個(gè)判斷:

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

所以默認(rèn)情況下Spring MVC并不支持application/xml這種媒體格式,所以若不導(dǎo)包協(xié)商出來(lái)的結(jié)果是:application/json。

默認(rèn)情況下優(yōu)先級(jí)是xml高于json。當(dāng)然一般都木有xml包,所以才輪到j(luò)son的。

另外還需要注意一點(diǎn):有的小伙伴說(shuō)通過(guò)在請(qǐng)求頭里指定Content-Type:application/json來(lái)達(dá)到效果?,F(xiàn)在你應(yīng)該知道,這樣做顯然是沒(méi)用的(至于為何沒(méi)用,希望讀者做到了心知肚明),只能使用Accept這個(gè)頭來(lái)指定~~~

第一種協(xié)商方式是Spring MVC完全基于HTTP Accept首部的方式了。該種方式Spring MVC默認(rèn)支持且默認(rèn)已開(kāi)啟。
優(yōu)缺點(diǎn):

  • 優(yōu)點(diǎn):理想的標(biāo)準(zhǔn)方式
  • 缺點(diǎn):由于瀏覽器的差異,導(dǎo)致發(fā)送的Accept Header頭可能會(huì)不一樣,從而得到的結(jié)果不具備瀏覽器兼容性

方式二:(變量)擴(kuò)展名

基于上面例子:若我訪問(wèn)/test/1.xml返回的是xml,若訪問(wèn)/test/1.json返回的是json;完美~

這種方式使用起來(lái)非常的便捷,并且還不依賴于瀏覽器。但我總結(jié)了如下幾點(diǎn)使時(shí)的注意事項(xiàng):

  1. 擴(kuò)展名必須是變量的擴(kuò)展名。比如上例若訪問(wèn)test.json / test.xml就404~
  2. @PathVariable的參數(shù)類型只能使用通用類型(String/Object),因?yàn)榻邮者^(guò)來(lái)的value值就是1.json/1.xml,所以若用Integer接收將報(bào)錯(cuò)類型轉(zhuǎn)換錯(cuò)誤~
    1. 小技巧:我個(gè)人建議是這部分不接收(這部分不使用@PathVariable接收),拿出來(lái)只為內(nèi)容協(xié)商使用
  3. 擴(kuò)展名優(yōu)先級(jí)比Accept要高(并且和使用神馬瀏覽器無(wú)關(guān))

優(yōu)缺點(diǎn):

  • 優(yōu)點(diǎn):靈活,不受瀏覽器約束
  • 缺點(diǎn):?jiǎn)适Я送籙RL的多種展現(xiàn)方式。在實(shí)際環(huán)境中使用還是較多的,因?yàn)檫@種方式更符合程序員的習(xí)慣

方式三:請(qǐng)求參數(shù)

這種協(xié)商方式Spring MVC支持,但默認(rèn)是關(guān)閉的,需要顯示的打開(kāi):

@Configuration
@EnableWebMvc
public class WebMvcConfig extends WebMvcConfigurerAdapter {
    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        // 支持請(qǐng)求參數(shù)協(xié)商
        configurer.favorParameter(true);
    }
}

請(qǐng)求URL:/test/1?format=xml返回xml;/test/1?format=json返回json。同樣的我總結(jié)如下幾點(diǎn)注意事項(xiàng):

  1. 前兩種方式默認(rèn)是開(kāi)啟的,但此種方式需要手動(dòng)顯示開(kāi)啟
  2. 此方式優(yōu)先級(jí)低于擴(kuò)展名(因此你測(cè)試時(shí)若想它生效,請(qǐng)去掉url的后綴)

優(yōu)缺點(diǎn):

  • 優(yōu)點(diǎn):不受瀏覽器約束
  • 缺點(diǎn):需要額外的傳遞format參數(shù),URL變得冗余繁瑣,缺少了REST的簡(jiǎn)潔風(fēng)范。還有個(gè)缺點(diǎn)便是:還需手動(dòng)顯示開(kāi)啟。
方式四:固定類型(produces)

它就是利用@RequestMapping注解屬性produces(可能你平時(shí)也在用,但并不知道原因):

@ResponseBody
@GetMapping(value = {"/test/{id}", "/test"}, produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public Person test() { ... }

訪問(wèn):/test/1返回的就是json;即使你已經(jīng)導(dǎo)入了jackson的xml包,返回的依舊還是json。

它也有它很很很重要的一個(gè)注意事項(xiàng):produces指定的MediaType類型不能和后綴、請(qǐng)求參數(shù)、Accept沖突。例如本利這里指定了json格式,如果你這么訪問(wèn)/test/1.xml,或者format=xml,或者Accept不是application/json或者*/* 將無(wú)法完成內(nèi)容協(xié)商:http狀態(tài)碼為406,報(bào)錯(cuò)如下:

在這里插入圖片描述

produces使用固然也比較簡(jiǎn)單,針對(duì)上面報(bào)錯(cuò)406的原因,我簡(jiǎn)單解釋如下。

原因:

1、先解析請(qǐng)求的媒體類型:1.xml解析出來(lái)的MediaTypeapplication/xml
2、拿著這個(gè)MediaType(當(dāng)然還有URL、請(qǐng)求Method等所有)去匹配HandlerMethod的時(shí)候會(huì)發(fā)現(xiàn)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()去處理這個(gè)異常,轉(zhuǎn)換到Http狀態(tài)碼

會(huì)調(diào)用所有的handlerExceptionResolvers來(lái)處理這個(gè)異常,本處會(huì)被DefaultHandlerExceptionResolver最終處理。最終處理代碼如下(406狀態(tài)碼):

    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默認(rèn)注冊(cè)的異常處理器是如下3個(gè):

在這里插入圖片描述

原理

有了關(guān)于Accept的原理描述,理解它就非常簡(jiǎn)單了。因?yàn)橹付?code>produces屬性,所以getProducibleMediaTypes()方法在拿服務(wù)端支持的媒體類型時(shí):

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

因?yàn)樵O(shè)置了producers,所以代碼第一句就能拿到值了(后面的協(xié)商機(jī)制完全同上)。

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

優(yōu)缺點(diǎn):

  • 優(yōu)點(diǎn):使用簡(jiǎn)單,天然支持
  • 缺點(diǎn):讓HandlerMethod處理器缺失靈活性
Spring Boot默認(rèn)異常消息處理

再回到開(kāi)頭的Spring Boot為何對(duì)異常消息,瀏覽器和postman的展示不一樣。這就是Spring Boot默認(rèn)的對(duì)異常處理方式:它使用的就是基于 固定類型(produces)實(shí)現(xiàn)的內(nèi)容協(xié)商。

Spirng Boot出現(xiàn)異常信息時(shí)候,會(huì)默認(rèn)訪問(wèn)/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);
    }
    ...
}

有了上面的解釋,對(duì)這塊代碼的理解應(yīng)該就沒(méi)有盲點(diǎn)了~

總結(jié)

內(nèi)容協(xié)商在RESTful流行的今天還是非常重要的一塊內(nèi)容,它對(duì)于提升用戶體驗(yàn),提升效率和降低維護(hù)成本都有不可忽視的作用,注意它三的優(yōu)先級(jí)為:后綴 > 請(qǐng)求參數(shù) > HTTP首部Accept

一般情況下,我們?yōu)榱送ㄓ枚紩?huì)使用基于Http的內(nèi)容協(xié)商(Accept),但在實(shí)際應(yīng)用中其實(shí)很少用它,因?yàn)椴煌臑g覽器可能導(dǎo)致不同的行為(比如ChromeFirefox就很不一樣),所以為了保證“穩(wěn)定性”一般都選擇使用方案二或方案三(比如Spring的官方doc)。

相關(guān)閱讀

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

ContentNegotiation內(nèi)容協(xié)商機(jī)制(一)---Spring MVC內(nèi)置支持的4種內(nèi)容協(xié)商方式【享學(xué)Spring MVC】
ContentNegotiation內(nèi)容協(xié)商機(jī)制(二)---Spring MVC內(nèi)容協(xié)商實(shí)現(xiàn)原理及自定義配置【享學(xué)Spring MVC】
ContentNegotiation內(nèi)容協(xié)商機(jī)制(三)---在視圖View上的應(yīng)用:ContentNegotiatingViewResolver深度解析【享學(xué)Spring MVC】

知識(shí)交流

==The last:如果覺(jué)得本文對(duì)你有幫助,不妨點(diǎn)個(gè)贊唄。當(dāng)然分享到你的朋友圈讓更多小伙伴看到也是被作者本人許可的~==

若對(duì)技術(shù)內(nèi)容感興趣可以加入wx群交流:Java高工、架構(gòu)師3群
若群二維碼失效,請(qǐng)加wx號(hào):fsx641385712(或者掃描下方wx二維碼)。并且備注:"java入群" 字樣,會(huì)手動(dòng)邀請(qǐng)入群

==若對(duì)Spring、SpringBoot、MyBatis等源碼分析感興趣,可加我wx:fsx641385712,手動(dòng)邀請(qǐng)你入群一起飛==

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容