使用檢視進行內容協商

工程 | Paul Chapman | 2013 年 6 月 3 日 | ...

在我之前的文章中,我介紹了內容協商的概念以及 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 頭部屬性,其值 就是 MIME 型別。

內容協商檢視解析器

這是一個特殊的檢視解析器,我們的策略就內建其中。這是 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,並請求它們提供一個與控制器返回的檢視名稱(在本例中為 accounts/list)相對應的 View 例項。每個 View 都“知道”它能生成什麼型別的內容,因為它上面有一個 getContentType() 方法(繼承自 View 介面)。JSP 頁面由 JstlView 渲染(由 InternalResourceViewResolver 返回),其內容型別為 text/html,而 AccountExcelView 生成 application/vnd.ms-excel

CNVR 實際的配置方式委託給 ContentNegotiationManager,後者又透過配置器(Java Configuration)或 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 演示專案使用兩組 Spring 配置檔案。web.xml 中可以分別指定 xmljavaconfig 用於 XML 或 Java 配置。對於它們中的任何一個,都可以指定 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 的 物件到 XML 編組 (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" />

然而,這會產生一種不同風格的 Controller 方法,其優點是功能更強大。那麼,該選擇哪種方式:Views 還是 @ResponseBody

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

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

然而,正如我之前的文章中

領先一步

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

瞭解更多

獲得支援

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

瞭解更多

即將舉行的活動

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

檢視所有