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
的內容協商方式大致有兩種:
- 服務端將可用列表(自己能提供的MIME型別們)發給客戶端,客戶端選擇後再告訴服務端。這樣服務端再按照客戶端告訴的MIME返給它。(缺點:多一次網路互動,而且使用對使用者要求高,所以此方式一般不用)
(常用)客戶端發請求時就指明需要的
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/json
、text/html
等)
Content-Language
:告訴客戶端響應的語言
Content-Charset
:告訴客戶端響應的字符集
Content-Encoding
:告訴客戶端響應的壓縮方式(gzip)報頭
Accept
與Content-Type
的區別有很多文章粗暴的解釋:
Accept
屬於請求頭,Content-Type
屬於響應頭,其實這是不準確的。
在前後端分離開發成為主流的今天,你應該不乏見到前端的request請求上大都有Content-Type:application/json;charset=utf-8
這個請求頭,因此可見Content-Type
並不僅僅是響應頭。
HTTP協議規範的格式如下四部分:
- <request-line>(請求訊息行)
- <headers>(請求訊息頭)
- <blank line>(請求空白行)
- <request-body>(請求訊息體)
Content-Type
指請求訊息體的資料格式,因為請求和響應中都可以有訊息體,所以它即可用在請求頭,亦可用在響應頭。
關於更多Http中的Content-Type
的內容,我推薦參見此文章:Http請求中的Content-Type
Spring MVC內容協商
Spring MVC
實現了HTTP
內容協商的同時,又進行了擴充套件。它支援4種協商方式:
HTTP
頭Accept
- 副檔名
- 請求引數
- 固定型別(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;完美~
這種方式使用起來非常的便捷,並且還不依賴於瀏覽器。但我總結了如下幾點使時的注意事項:
- 副檔名必須是變數的副檔名。比如上例若訪問
test.json / test.xml
就404~ @PathVariable
的引數型別只能使用通用型別(String/Object
),因為接收過來的value值就是1.json/1.xml
,所以若用Integer
接收將報錯型別轉換錯誤~
1. 小技巧:我個人建議是這部分不接收(這部分不使用@PathVariable
接收),拿出來只為內容協商使用- 副檔名優先順序比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。同樣的我總結如下幾點注意事項:
- 前兩種方式預設是開啟的,但此種方式需要手動顯示開啟
- 此方式優先順序
低於
副檔名(因此你測試時若想它生效,請去掉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
解析出來的MediaType
是application/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),但在實際應用中其實很少用它,因為不同的瀏覽器可能導致不同的行為(比如Chrome
和Firefox
就很不一樣),所以為了保證“穩定性”一般都選擇使用方案二或方案三(比如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,手動邀請你入群一起