使用視圖的內容協商

工程 | Paul Chapman | 2013年06月03日 | ...

在我之前的文章中,我介紹了內容協商的概念以及 Spring MVC 用於判斷所請求內容的三種策略。

在這篇文章中,我想將這個概念特別擴展到支援使用 ContentNegotiatingViewResolver(或 CNVR)針對不同內容類型的多個視圖。

快速總覽

由於我們已經從之前的文章中了解如何設定內容協商,因此使用它在多個視圖之間進行選擇非常簡單。只需像這樣定義一個 CNVR


    <!--
      // View resolver that delegates to other view resolvers based on the
      // content type
      -->
    <bean class="org.springframework.web.servlet.view.
                                           ContentNegotiatingViewResolver">
       <!-- All configuration now done by manager - since Spring V3.2 -->
       <property name="contentNegotiationManager" ref="cnManager"/>
    </bean>
    
    <!--
      // Setup a simple strategy:
      //  1. Only path extension is taken into account, Accept headers
      //      are ignored.
      //  2. Return HTML by default when not sure.
      -->
    <bean id="cnManager" class="org.springframework.web.accept.
                                   ContentNegotiationManagerFactoryBean">
        <property name="ignoreAcceptHeader" value="true"/>        
        <property name="defaultContentType" value="text/html" />
    </bean>

對於每個請求,@Controller 通常會返回一個邏輯視圖名稱(或者 Spring MVC 會根據傳入的 URL 依慣例決定一個)。CNVR 將查詢配置中定義的所有其他視圖解析器,以查看 1) 它是否具有具有正確名稱的視圖,以及 2) 它是否具有也產生正確內容的視圖 - 所有視圖都「知道」它們返回的內容類型。所需的內容類型以與先前文章中討論的完全相同的方式確定。

有關等效的 Java 配置,請參閱這裡。有關擴展配置,請參閱這裡。Github 上有一個示範應用程式:https://github.com/paulc4/mvc-content-neg-views

對於那些趕時間的人來說,這就是重點。

對於其餘的人,這篇文章展示了我們是如何達到這個目標的。它討論了 Spring MVC 中多個視圖的概念,並在此基礎上定義了 CNVR 是什麼、如何使用它以及它是如何運作的。它採用了先前文章中的相同 Accounts 應用程式,並將其建構成以 HTML、試算表、JSON 和 XML 格式傳回帳戶資訊。全部都使用視圖。

為何需要多個視圖?

MVC 模式的優勢之一是能夠為相同的資料提供多個視圖。在 Spring MVC 中,我們使用「內容協商」來實現這一點。我之前的文章一般性地討論了內容協商,並展示了使用 HTTP 訊息轉換器的 RESTful 控制器的範例。但內容協商也可以與視圖一起使用。

例如,假設我希望不僅以網頁形式顯示帳戶資訊,而且還使其以試算表的形式提供。我可以為每個使用不同的 URL,在我的 Spring 控制器上放置兩個方法,並讓每個方法都傳回正確的視圖類型。(順便說一句,如果您不確定 Spring 如何建立試算表,稍後我會向您展示)。


@Controller
class AccountController {
    @RequestMapping("/accounts.htm")
    public String listAsHtml(Model model, Principal principal) {
        // Duplicated logic
        model.addAttribute( accountManager.getAccounts(principal) );
        return ¨accounts/list¨;         // View determined by view-resolution
    }

    @RequestMapping("/accounts.xls")
    public AccountsExcelView listAsXls(Model model, Principal principal) {
        // Duplicated logic
        model.addAttribute( accountManager.getAccounts(principal) );
        return new AccountsExcelView();  // Return view explicitly
    }
}

使用多個方法是不優雅的,破壞了 MVC 模式,而且如果我也想支援其他資料格式(例如 PDF、CSV ...)會變得更醜陋。如果您還記得在之前的文章中,我們遇到了一個類似的問題,希望單一方法傳回 JSON 或 XML(我們透過傳回單一 @RequestBody 物件並選擇正確的 HTTP 訊息轉換器來解決這個問題)。

[caption id="attachment_13458" align="alignleft" width="380" caption="透過內容協商選擇正確的視圖。"][/caption]

現在我們需要一個「智慧型」視圖解析器,它可以從多個可能的視圖中選擇正確的視圖。

Spring MVC 長期以來一直支援多個視圖解析器,並依次訪問每個視圖解析器以尋找視圖。儘管可以指定諮詢視圖解析器的順序,但 Spring MVC 始終選擇第一個提供的視圖。「內容協商視圖解析器」(CNVR)在所有視圖解析器之間進行協商,以找到最適合所需格式的最佳匹配 - 這我們的「智慧型」視圖解析器。

列出使用者帳戶範例

這是一個簡單的帳戶列表應用程式,我們將使用它作為我們的實作範例,以 HTML、試算表以及(稍後)JSON 和 XML 格式列出帳戶 - 僅使用視圖。

完整的程式碼可以在 Github 上找到:https://github.com/paulc4/mvc-content-neg-views。這是我上次向您展示的應用程式的變體,它使用視圖來產生輸出。注意:為了使下面的範例保持簡單,我直接使用了 JSP 和 InternalResourceViewResolver。Github 專案使用 Tiles 和 JSP,因為它比原始 JSP 更容易。

帳戶列表 HTML 頁面的螢幕截圖顯示了目前登入使用者的所有帳戶。您稍後將看到試算表和 JSON 輸出的螢幕截圖。

產生我們頁面的 Spring MVC 控制器如下所示。請注意,HTML 輸出由邏輯視圖 accounts/list 產生。


@Controller
class AccountController {
    @RequestMapping("/accounts")
    public String list(Model model, Principal principal) {
        model.addAttribute( accountManager.getAccounts(principal) );
        return ¨accounts/list¨;
    }
}

為了顯示兩種視圖類型,我們需要兩種視圖解析器 - 一種用於 HTML,另一種用於試算表(為了簡單起見,我將使用 JSP 作為 HTML 視圖)。這是 Java 配置


@Configuration
@EnableWebMvc
public class MvcConfiguration extends WebMvcConfigurerAdapter {

    @Autowired
    ServletContext servletContext;

    // Will map to bean called "accounts/list" in "spreadsheet-views.xml"
    @Bean(name="excelViewResolver")
    public ViewResolver getXmlViewResolver() {
        XmlViewResolver resolver = new XmlViewResolver();
        resolver.setLocation(new ServletContextResource(servletContext,
                    "/WEB-INF/spring/spreadsheet-views.xml"));
        resolver.setOrder(1);
        return resolver;
    }

    // Will map to the JSP page: "WEB-INF/views/accounts/list.jsp"
    @Bean(name="jspViewResolver")
    public ViewResolver getJspViewResolver() {
        InternalResourceViewResolver resolver =
                            new InternalResourceViewResolver();
        resolver.setPrefix("WEB-INF/views");
        resolver.setSuffix(".jsp");
        resolver.setOrder(2);
        return resolver;
    }
}

或在 XML 中


  <!-- Maps to a bean called "accounts/list" in "spreadsheet-views.xml" -->
  <bean class="org.springframework.web.servlet.view.XmlViewResolver">
    <property name="order" value="1"/>
    <property name="location" value="WEB-INF/spring/spreadsheet-views.xml"/>
  </bean>

  <!-- Maps to "WEB-INF/views/accounts/list.jsp" -->
  <bean class="org.springframework.web.servlet.view.
                                        InternalResourceViewResolver">
    <property name="order" value="2"/>
    <property name="prefix" value="WEB-INF/views"/>
    <property name="suffix" value=".jsp"/>
  </bean>

WEB-INF/spring/spreadsheet-beans.xml 中,您會找到

  <bean id="accounts/list" class="rewardsonline.accounts.AccountExcelView"/>

產生的試算表如下所示

以下是如何使用視圖建立試算表(這是一個簡化版本,完整的實作要長得多,但您會了解其概念)

class AccountExcelView extends AbstractExcelView {
    @Override
    protected void buildExcelDocument(Map<String, Object> model,
            HSSFWorkbook workbook, HttpServletRequest request,
            HttpServletResponse response) throws Exception {
        List<Account> accounts = (List<Account>) model.get("accountList");
        HSSFCellStyle dateStyle = workbook.createCellStyle();
        dateStyle.setDataFormat(HSSFDataFormat.getBuiltinFormat("m/d/yy"));
        HSSFSheet sheet = workbook.createSheet();
    
        for (short i = 0; i < accounts.size(); i++) {
            Account account = accounts.get(i);
            HSSFRow row = sheet.createRow(i);
            addStringCell(row, 0, account.getName());
            addStringCell(row, 1, account.getNumber());
            addDateCell(row, 2, account.getDateOfBirth(), dateStyle);
        }   
    }   
    
    private HSSFCell addStringCell(HSSFRow row, int index, String value) {
        HSSFCell cell = row.createCell((short) index);
        cell.setCellValue(new HSSFRichTextString(value));
        return cell;
    }   
    
    private HSSFCell addDateCell(HSSFRow row, int index, Date date,
        HSSFCellStyle dateStyle) {
        HSSFCell cell = row.createCell((short) index);
        cell.setCellValue(date);
        cell.setCellStyle(dateStyle);
        return cell;
    }   
} 

新增內容協商

就目前情況而言,此設定將始終傳回試算表,因為首先諮詢了 XmlViewResolver(其 order 屬性為 1),並且它始終傳回 AccountExcelViewInternalResourceViewResolver 永遠不會被諮詢(其 order 為 2,我們永遠不會到達那一步)。

這就是 CNVR 的用武之地。讓我們快速回顧一下我們在之前的文章中討論的內容選擇策略。請求的內容類型是透過依此順序檢查來確定的

  • URL 後綴(路徑擴展名)- 例如 http://...accounts.json 以指示 JSON 格式。
  • 或者可以使用 URL 參數。預設情況下,它被命名為 format,例如 http://...accounts?format=json
  • 或者將使用 HTTP Accept 標頭屬性(這實際上是 HTTP 定義的工作方式,但並不總是方便使用 - 特別是當用戶端是瀏覽器時)。

在前兩種情況下,後綴或參數值(xmljson ...)必須對應到正確的 mime 類型。可以使用 JavaBeans Activation Framework,也可以明確指定對應關係。對於 Accept 標頭屬性,其值 mine 類型。

內容協商視圖解析器

這是一個特殊的視圖解析器,我們的策略已插入其中。這是 Java 配置


@Configuration
@EnableWebMvc
public class MvcConfiguration extends WebMvcConfigurerAdapter {
 
  /**
    * Setup a simple strategy:
    *  1. Only path extension taken into account, Accept headers ignored.
    *  2. Return HTML by default when not sure.
    */
  @Override
  public void configureContentNegotiation
                          (ContentNegotiationConfigurer configurer) {
      configurer.ignoreAcceptHeader(true)
                .defaultContentType(MediaType.TEXT_HTML);
  }

  /**
    * Create the CNVR. Get Spring to inject the ContentNegotiationManager
    * created by the configurer (see previous method).
    */
  @Bean
  public ViewResolver contentNegotiatingViewResolver(
                             ContentNegotiationManager manager) {
    ContentNegotiatingViewResolver resolver =
                            new ContentNegotiatingViewResolver();
    resolver.setContentNegotiationManager(manager);
    return resolver;
  }
}

或在 XML 中


    <!--
      // View resolver that delegates to other view resolvers based on the
      // content type
      -->
    <bean class="org.springframework.web.servlet.view.
                                      ContentNegotiatingViewResolver">
       <!-- All configuration now done by manager - since Spring V3.2 -->
       <property name="contentNegotiationManager" ref="cnManager"/>
    </bean>
    
    <!--
      // Setup a simple strategy:
      //  1. Only path extension taken into account, Accept headers ignored.
      //  2. Return HTML by default when not sure.
      -->
    <bean id="cnManager" class="org.springframework.web.accept.
                                  ContentNegotiationManagerFactoryBean">
        <property name="ignoreAcceptHeader" value="true"/>        
        <property name="defaultContentType" value="text/html" />
    </bean>

ContentNegotiationManager 與我在之前的文章中討論的 bean 完全相同。

CNVR 自動轉到 Spring 定義的每個其他視圖解析器 bean,並要求它提供與控制器傳回的視圖名稱相對應的 View 實例 - 在這種情況下為 accounts/list。每個 View 都「知道」它可以產生哪種內容,因為它上面有一個 getContentType() 方法(從 View 介面繼承)。JSP 頁面由 JstlView(由 InternalResourceViewResolver 傳回)呈現,其內容類型為 text/html,而 AccountExcelView 產生 application/vnd.ms-excel

CNVR 的實際配置方式委託給 ContentNegotiationManager,後者又透過配置器(Java 配置)或 Spring 的許多工廠 bean(XML)之一建立。

最後一個謎題是:CNVR 如何知道請求了什麼內容類型?因為內容協商策略告訴它該怎麼做:要麼識別 URL 後綴,要麼識別 URL 參數或 Accept 標頭。與之前的文章中描述的策略設定完全相同,CNVR 重複使用。

請注意,當 Spring 3.0 引入內容協商策略時,它們僅適用於選擇視圖。自 3.2 以來,此功能已在所有領域可用(根據我之前的文章)。這篇文章中的範例使用 Spring 3.2,可能與您以前看過的舊範例不同。特別是,用於配置內容協商策略的大多數屬性現在位於 ContentNegotiationManagerFactoryBean 上,而不是位於 ContentNegotiatingViewResolver 上。CNVR 上的屬性現在已被棄用,以支持管理器上的屬性,但 CNVR 本身的工作方式與以往完全相同。

配置內容協商視圖解析器

預設情況下,CNVR 自動偵測到 Spring 定義的所有 ViewResolvers,並在它們之間進行協商。如果您願意,CNVR 本身具有 viewResolvers 屬性,因此您可以明確告訴它要使用哪些視圖解析器。這清楚地表明 CNVR 是主要解析器,而其他解析器是它的附屬。請注意,不再需要 order 屬性。


@Configuration
@EnableWebMvc
public class MvcConfiguration extends WebMvcConfigurerAdapter {
 
  // .. Other methods/declarations

  /**
    * Create the CNVR.  Specify the view resolvers to use explicitly.
    * Get Spring to inject the ContentNegotiationManager created by the
    * configurer (see previous method).
    */
  @Bean
  public ViewResolver contentNegotiatingViewResolver(
                        ContentNegotiationManager manager) {
    // Define the view resolvers
    List<ViewResolver> resolvers = new ArrayList<ViewResolver>();

    XmlViewResolver r1 = new XmlViewResolver();
    resolver.setLocation(new ServletContextResource(servletContext,
            "/WEB-INF/spring/spreadsheet-views.xml"));
    resolvers.add(r1);

    InternalResourceViewResolver r2 = new InternalResourceViewResolver();
    r2.setPrefix("WEB-INF/views");
    r2.setSuffix(".jsp");
    resolvers.add(r2);

    // Create CNVR plugging in the resolvers & content-negotiation manager
    ContentNegotiatingViewResolver resolver =
                        new ContentNegotiatingViewResolver();
    resolver.setViewResolvers(resolvers);
    resolver.setContentNegotiationManager(manager);
    return resolver;
  }
}

或在 XML 中


  <bean class="org.springframework.web.servlet.view.
                                ContentNegotiatingViewResolver">
    <property name="contentNegotiationManager" ref="cnManager"/>

    <!-- Define the view resolvers explicitly -->
    <property name="viewResolvers">
      <list>
        <bean class="org.springframework.web.servlet.view.XmlViewResolver">
          <property name="location" value="spreadsheet-views.xml"/>
        </bean>
    
        <bean class="org.springframework.web.servlet.view.
                                InternalResourceViewResolver">
          <property name="prefix" value="WEB-INF/views"/>
          <property name="suffix" value=".jsp"/>
        </bean>
      </list>
    </property>
  </bean>

Github 示範專案使用 2 組 Spring 設定檔。在 web.xml 中,您可以為 XML 或 Java 配置分別指定 xmljavaconfig。對於它們中的任何一個,指定 separatecombinedseparate 設定檔將所有視圖解析器定義為頂層 bean,並讓 CNVR 掃描內容以找到它們(如上一節所述)。在 combined 設定檔中,視圖解析器被明確定義,而不是作為 Spring bean,並透過其 viewResolvers 屬性傳遞給 CNVR(如本節所示)。

JSON 支援

Spring 提供了一個 MappingJacksonJsonView,它支援使用 Jackson Object to JSON 對應程式庫從 Java 物件產生 JSON 資料。MappingJacksonJsonView 自動將模型中找到的所有屬性轉換為 JSON。唯一的例外是它會忽略 BindingResult 物件,因為這些物件是 Spring MVC 表單處理的內部物件,不需要。

需要一個合適的視圖解析器,而 Spring 沒有提供一個。幸運的是,編寫您自己的視圖解析器非常簡單


public class JsonViewResolver implements ViewResolver {
    /**
     * Get the view to use.
     *
     * @return Always returns an instance of {@link MappingJacksonJsonView}.
     */
    @Override
    public View resolveViewName(String viewName, Locale locale)
                                                 throws Exception {
        MappingJacksonJsonView view = new MappingJacksonJsonView();
        view.setPrettyPrint(true);   // Lay JSON out to be nicely readable 
        return view;
    }
}

只需將此視圖解析器宣告為 Spring bean,就表示可以傳回 JSON 格式的資料。JAF 已經將 json 對應到 application/json,因此我們完成了。現在,像 http://myserver/myapp/accounts/list.json 這樣的 URL 可以傳回 JSON 格式的帳戶資訊。這是我們 Accounts 應用程式的輸出

有關此視圖的更多資訊,請參閱 Spring Javadoc

XML 支援

有一個類似的類別用於產生 XML 輸出 - MarshallingView。它採用模型中可以封送處理的第一個物件並處理它。您可以選擇性地配置視圖,方法是告訴它要選擇哪個模型屬性(鍵)- 請參閱 setModelKey()

同樣,我們需要一個視圖解析器來處理它。Spring 透過 Spring 的 Object to XML Marshalling (OXM) 抽象支援多種封送處理技術。讓我們只使用 JAXB2,因為它內建於 JDK 中(自 JDK 6 以來)。這是解析器


/**
 * View resolver for returning XML in a view-based system.
 */
public class MarshallingXmlViewResolver implements ViewResolver {

    private Marshaller marshaller;

    @Autowired
    public MarshallingXmlViewResolver(Marshaller marshaller) {
        this.marshaller = marshaller;
    }

    /**
     * Get the view to use.
     * 
     * @return Always returns an instance of {@link MappingJacksonJsonView}.
     */
    @Override
    public View resolveViewName(String viewName, Locale locale)
                                                 throws Exception {
        MarshallingView view = new MarshallingView();
        view.setMarshaller(marshaller);
        return view;
    }
}

同樣,我的類別需要註解才能與 JAXB 一起使用(為了回應評論,我在之前的文章的末尾新增了一個範例)。

使用 Java 配置將新的解析器配置為 Spring bean


  @Bean(name = "marshallingXmlViewResolver")
  public ViewResolver getMarshallingXmlViewResolver() {
      Jaxb2Marshaller marshaller = new Jaxb2Marshaller();

      // Define the classes to be marshalled - these must have
      // @Xml... annotations on them
      marshaller.setClassesToBeBound(Account.class,
                               Transaction.class, Customer.class);
      return new MarshallingXmlViewResolver(marshaller);
  }

或者我們可以在 XML 中做同樣的事情 - 請注意 oxm 命名空間的使用

<oxm:jaxb2-marshaller id="marshaller" >
    <oxm:class-to-be-bound name="rewardsonline.accounts.Account"/>
    <oxm:class-to-be-bound name="rewardsonline.accounts.Customer"/>
    <oxm:class-to-be-bound name="rewardsonline.accounts.Transaction"/>
</oxm:jaxb2-marshaller>

<!-- View resolver that returns an XML Marshalling view. -->
<bean class="rewardsonline.accounts.MarshallingXmlViewResolver" >
    <constructor-arg ref="marshaller"/>
</bean>

這是我們完成的系統

Full system with CNVR and 4 view-resolvers

比較 RESTful 方法

使用 @ResponseBody@ResponseStatus 和其他 REST 相關的 MVC 註解,可以完全支援 MVC 的 RESTful 方法。類似於這樣


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

為了為我們的 @RequestMapping 方法啟用相同的內容協商,我們必須重複使用我們的內容協商管理器(這允許 produces 選項工作)。


<mvc:annotation-driven
          content-negotiation-manager="contentNegotiationManager" />

然而,這產生了不同風格的控制器方法,優點是它也更強大。那麼該怎麼辦:視圖還是 @ResponseBody

對於已經使用 Spring MVC 和視圖的現有網站,MappingJacksonJsonViewMarshallingView 提供了一種簡單的方法來擴展 Web 應用程式,以同時傳回 JSON 和/或 XML。在許多情況下,這些是您唯一需要的資料格式,並且是一種簡單的方法來支援唯讀行動應用程式和/或 AJAX 啟用的網頁,其中 RESTful 請求僅用於 GET 資料。

完全支援 REST,包括修改資料的能力,涉及結合 HTTP 訊息轉換器使用帶註解的控制器方法。在這種情況下使用視圖沒有意義,只需傳回 @ResponseBody 物件並讓轉換器完成工作即可。

然而,如 http://blog.springsource.org/2013/05/11/content-negotiation-using-spring-mvc/#combined-controller"">here 在我之前的文章中所示,控制器完全可以在同一時間使用這兩種方法。現在,同一個控制器可以同時支援傳統的 Web 應用程式和實作完整的 RESTful 介面,從而增強可能已經建立和開發多年的 Web 應用程式。

Spring 一直以來都非常重視為開發人員提供彈性和選擇。這也不例外。

取得 Spring 電子報

隨時掌握 Spring 電子報的最新資訊

訂閱

搶先一步

VMware 提供訓練和認證,以加速您的進展。

了解更多

取得支援

Tanzu Spring 在一個簡單的訂閱中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位檔案。

了解更多

即將到來的活動

查看 Spring 社群中所有即將到來的活動。

檢視全部