使用 Spring MVC 進行內容協商

工程 | Paul Chapman | 2013年5月11日 | ...

使用 Spring MVC 生成輸出有兩種方式

  • 你可以使用 RESTful 的 @ResponseBody 方式和 HTTP 訊息轉換器,通常用於返回 JSON 或 XML 等資料格式。程式設計客戶端、移動應用和啟用 AJAX 的瀏覽器是常見的客戶端。
  • 或者,你可以使用 檢視解析。儘管檢視如果需要的話完全能夠生成 JSON 和 XML(更多內容將在我的下一篇文章中介紹),但檢視通常用於為傳統的 Web 應用生成 HTML 等演示格式。
  • 實際上還有第三種可能性——有些應用同時需要這兩種方式,Spring MVC 可以輕鬆支援這種組合。我們將在文章末尾回到這一點。

無論哪種情況,你需要處理由控制器返回的同一資料的多種表示形式(或檢視)。確定要返回哪種資料格式稱為 內容協商

有三種情況下我們需要知道 HTTP 響應中要傳送哪種資料格式

  • HttpMessageConverters: 確定要使用的正確轉換器。
  • Request Mappings: 將傳入的 HTTP 請求對映到返回不同格式的不同方法。
  • View Resolution: 選擇要使用的正確檢視。

確定使用者請求的格式依賴於 ContentNegotationStrategy。開箱即用提供了預設實現,但你也可以根據需要實現自己的策略。

在這篇文章中,我想討論如何配置和使用 Spring 進行內容協商,主要針對使用 HTTP 訊息轉換器的 RESTful Controller。在後來的 文章中,我將展示如何使用 Spring 的 ContentNegotiatingViewResolver 專門針對檢視設定內容協商。

內容協商如何工作?

[caption id="attachment_13288" align="alignleft" width="200" caption="獲取正確的內容"]協商[/caption]

透過 HTTP 傳送請求時,可以透過設定 Accept 頭屬性來指定你想要的響應型別。Web 瀏覽器預設為請求 HTML(以及其他內容)。事實上,如果你仔細檢視,你會發現瀏覽器傳送的 Accept 頭非常混亂,這使得依賴它們變得不切實際。有關此問題的精彩討論,請參閱 http://www.gethifi.com/blog/browser-rest-http-accept-headers。總結來說:Accept 頭很混亂,並且你通常也無法更改它們(除非你使用 JavaScript 和 AJAX)。

因此,對於那些 Accept 頭屬性不適用或不希望使用的情況,Spring 提供了一些替代的約定來使用。(這是 Spring 3.2 中一項很好的改變,它使得靈活的內容選擇策略不僅在使用檢視時可用,而且在整個 Spring MVC 中都可用。) 你可以集中配置一次內容協商策略,它將應用於任何需要確定不同格式(媒體型別)的地方。

在 Spring MVC 中啟用內容協商

Spring 支援幾種用於選擇所需格式的約定:URL 字尾和/或 URL 引數。這些與使用 Accept 頭一起工作。因此,可以透過以下三種方式之一請求內容型別。預設情況下,它們按此順序檢查:

  • 在 URL 中新增路徑擴充套件(字尾)。因此,如果傳入的 URL 是諸如 http://myserver/myapp/accounts/list.html 之類的形式,則需要 HTML。對於電子表格,URL 應為 http://myserver/myapp/accounts/list.xls。字尾到媒體型別的對映是透過 JavaBeans Activation Framework 或 JAF 自動定義的(因此 activation.jar 必須在類路徑上)。
  • 使用這樣的 URL 引數:http://myserver/myapp/accounts/list?format=xls。引數名預設為 format,但這可以更改。預設情況下停用使用引數,但啟用後,它會進行第二次檢查。
  • 最後檢查 Accept HTTP 頭屬性。這是 HTTP 實際定義的工作方式,但是,如前所述,使用它可能會有問題。

設定此功能的 Java 配置如下所示。只需透過其配置器自定義預定義的內容協商管理器。請注意,MediaType 幫助類為大多數已知媒體型別提供了預定義常量。


@Configuration
@EnableWebMvc
public class WebConfig extends WebMvcConfigurerAdapter {

  /**
    * Setup a simple strategy: use all the defaults and return XML by default when not sure. 
    */
  @Override
  public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
    configurer.defaultContentType(MediaType.APPLICATION_XML);
  }
}

使用 XML 配置時,內容協商策略最容易透過 ContentNegotiationManagerFactoryBean 進行設定


   <!--
        Setup a simple strategy: 
           1. Take all the defaults.
           2. Return XML by default when not sure. 
       -->
  <bean id="contentNegotiationManager"
             class="org.springframework.web.accept.ContentNegotiationManagerFactoryBean">
       <property name="defaultContentType" value="application/xml" />
  </bean>

 <!-- Make this available across all of Spring MVC -->
 <mvc:annotation-driven content-negotiation-manager="contentNegotiationManager" />

透過任一設定建立的 ContentNegotiationManagerContentNegotationStrategy 的一個實現,該實現遵循上述的 PPA 策略(路徑擴充套件,然後是引數,然後是 Accept 頭)。

附加配置選項

在 Java 配置中,可以透過配置器上的方法完全自定義策略


@Configuration
@EnableWebMvc
public class WebConfig extends WebMvcConfigurerAdapter {

  /**
    *  Total customization - see below for explanation.
    */
  @Override
  public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
    configurer.favorPathExtension(false).
            favorParameter(true).
            parameterName("mediaType").
            ignoreAcceptHeader(true).
            useJaf(false).
            defaultContentType(MediaType.APPLICATION_JSON).
            mediaType("xml", MediaType.APPLICATION_XML).
            mediaType("json", MediaType.APPLICATION_JSON);
  }
}

在 XML 中,可以透過工廠 Bean 上的方法配置策略


 
  <!-- Total customization - see below for explanation. -->
  <bean id="contentNegotiationManager"
             class="org.springframework.web.accept.ContentNegotiationManagerFactoryBean">
    <property name="favorPathExtension" value="false" />
    <property name="favorParameter" value="true" />
    <property name="parameterName" value="mediaType" />
    <property name="ignoreAcceptHeader" value="true"/>
    <property name="useJaf" value="false"/>
    <property name="defaultContentType" value="application/json" />
 
    <property name="mediaTypes">
        <map>
            <entry key="json" value="application/json" />
            <entry key="xml" value="application/xml" />
       </map>
    </property>
</bean>

我們在兩種情況下所做的

  • 停用了路徑擴充套件。請注意,“偏好”並不意味著優先使用某種方式而不是另一種,它只是啟用或停用該方式。檢查順序始終是路徑擴充套件、引數、Accept 頭。
  • 啟用使用 URL 引數,但不是使用預設引數 format,我們將改用 mediaType
  • 完全忽略 Accept 頭。如果你的大多數客戶端實際上是 Web 瀏覽器(通常透過 AJAX 進行 REST 呼叫),這通常是最好的方法。
  • 不使用 JAF,而是手動指定媒體型別對映——我們只希望支援 JSON 和 XML。

列出使用者賬戶示例

為了演示,我構建了一個簡單的賬戶列表應用程式作為我們的示例——截圖顯示了 HTML 格式的典型賬戶列表。完整程式碼可以在 Github 上找到:https://github.com/paulc4/mvc-content-neg

要以 JSON 或 XML 格式返回賬戶列表,我需要一個這樣的 Controller。我們將暫時忽略生成 HTML 的方法。

 


@Controller
class AccountController {
    @RequestMapping(value="/accounts", method=RequestMethod.GET)
    @ResponseStatus(HttpStatus.OK)
    public @ResponseBody List<Account> list(Model model, Principal principal) {
        return accountManager.getAccounts(principal) );
    }

    // Other methods ...
}

這是內容協商策略的設定


	<!-- Simple strategy: only path extension is taken into account -->
	<bean id="cnManager"
		class="org.springframework.web.accept.ContentNegotiationManagerFactoryBean">
		<property name="favorPathExtension" value="true"/>
		<property name="ignoreAcceptHeader" value="true" />
		<property name="defaultContentType" value="text/html" />
		<property name="useJaf" value="false"/>

		<property name="mediaTypes">
			<map>
				<entry key="html" value="text/html" />
				<entry key="json" value="application/json" />
				<entry key="xml" value="application/xml" />
			</map>
		</property>
	</bean>

或者,使用 Java 配置,程式碼如下所示


	@Override
	public void configureContentNegotiation(
			ContentNegotiationConfigurer configurer) {
		// Simple strategy: only path extension is taken into account
		configurer.favorPathExtension(true).
			ignoreAcceptHeader(true).
			useJaf(false).
			defaultContentType(MediaType.TEXT_HTML).
			mediaType("html", MediaType.TEXT_HTML).
			mediaType("xml", MediaType.APPLICATION_XML).
			mediaType("json", MediaType.APPLICATION_JSON);
	}

假設我的類路徑上有 JAXB2 和 Jackson,Spring MVC 將自動設定必要的 HttpMessageConverters。我的領域類也必須使用 JAXB2 和 Jackson 註解進行標記才能啟用轉換(否則訊息轉換器不知道該怎麼做)。為了回應評論(見下文),帶有註解的 Account 類顯示在下方

這是我們的 Accounts 應用程式的 JSON 輸出(注意 URL 中的路徑擴充套件)。

系統如何知道是轉換為 XML 還是 JSON?這是因為內容協商——根據 ContentNegotiationManager 的配置方式,將使用上面討論的三種(PPA 策略)選項中的任何一種。在這種情況下,URL 以 accounts.json 結尾,因為路徑擴充套件是唯一啟用的策略。

在示例程式碼中,你可以透過在 web.xml 中設定活動 profile 來在 MVC 的 XML 或 Java 配置之間切換。profile 分別是 “xml” 和 “javaconfig”。

結合資料格式和演示格式

Spring MVC 的 REST 支援基於現有的 MVC Controller 框架。因此,同一個 Web 應用程式可以同時以原始資料(如 JSON)和使用演示格式(如 HTML)返回資訊。

這兩種技術可以很容易地在同一個 Controller 中並行使用,如下所示


@Controller
class AccountController {
    // RESTful method
    @RequestMapping(value="/accounts", produces={"application/xml", "application/json"})
    @ResponseStatus(HttpStatus.OK)
    public @ResponseBody List<Account> listWithMarshalling(Principal principal) {
        return accountManager.getAccounts(principal);
    }

    // View-based method
    @RequestMapping("/accounts")
    public String listWithView(Model model, Principal principal) {
        // Call RESTful method to avoid repeating account lookup logic
        model.addAttribute( listWithMarshalling(principal) );

        // Return the view to use for rendering the response
        return ¨accounts/list¨;
    }
}

這裡有一個簡單的模式:@ResponseBody 方法處理所有資料訪問以及與底層服務層的整合(即 AccountManager)。第二個方法呼叫第一個方法,並在 Model 中設定響應供 View 使用。這避免了重複的邏輯。

為了確定選擇兩個 @RequestMapping 方法中的哪一個,我們再次使用了我們的 PPA 內容協商策略。它使得 produces 選項能夠工作。以 accounts.xmlaccounts.json 結尾的 URL 對映到第一個方法,以 accounts.anything 結尾的任何其他 URL 對映到第二個方法。

另一種方法

或者,如果我們使用檢視來生成所有可能的內容型別,我們可以只用一個方法來完成整個事情。這就是 ContentNegotiatingViewResolver 發揮作用的地方,那將是我下一篇文章的主題。

致謝

我想感謝 Rossen Stoyanchev 在撰寫本文方面給予的幫助。任何錯誤均由我本人負責。

附錄:帶有註解的 Account 類

添加於 2013年6月2日.

由於有一些關於如何為 JAXB 註解類的問題,這裡是 Account 類的一部分。為簡潔起見,我省略了資料成員以及除帶註解的 getter 方法之外的所有方法。如果願意,我可以直接註解資料成員(實際上就像 JPA 註解一樣)。請記住,Jackson 可以使用這些相同的註解將物件編組為 JSON。


/**
 * Represents an account for a member of a financial institution. An account has
 * zero or more {@link Transaction}s and belongs to a {@link Customer}. An aggregate entity.
 */
@Entity
@Table(name = "T_ACCOUNT")
@XmlRootElement
public class Account {

	// data-members omitted ...

	public Account(Customer owner, String number, String type) {
		this.owner = owner;
		this.number = number;
		this.type = type;
	}

	/**
	 * Returns the number used to uniquely identify this account.
	 */
	@XmlAttribute
	public String getNumber() {
		return number;
	}

	/**
	 * Get the account type.
	 * 
	 * @return One of "CREDIT", "SAVINGS", "CHECK".
	 */
	@XmlAttribute
	public String getType() {
		return type;
	}

	/**
	 * Get the credit-card, if any, associated with this account.
	 * 
	 * @return The credit-card number or null if there isn't one.
	 */
	@XmlAttribute
	public String getCreditCardNumber() {
		return StringUtils.hasText(creditCardNumber) ? creditCardNumber : null;
	}

	/**
	 * Get the balance of this account in local currency.
	 * 
	 * @return Current account balance.
	 */
	@XmlAttribute
	public MonetaryAmount getBalance() {
		return balance;
	}


	/**
	 * Returns a single account transaction. Callers should not attempt to hold
	 * on or modify the returned object. This method should only be used
	 * transitively; for example, called to facilitate reporting or testing.
	 * 
	 * @param name
	 *            the name of the transaction account e.g "Fred Smith"
	 * @return the beneficiary object
	 */
	@XmlElement   // Make these a nested <transactions> element
	public Set<Transaction> getTransactions() {
		return transactions;
	}

    // Setters and other methods ...

}

獲取 Spring 新聞通訊

訂閱 Spring 新聞通訊,保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助你加速進步。

瞭解更多

獲取支援

Tanzu Spring 只需一份簡單的訂閱,即可為 OpenJDK™、Spring 和 Apache Tomcat® 提供支援和二進位制檔案。

瞭解更多

即將舉辦的活動

檢視 Spring 社群即將舉辦的所有活動。

檢視全部