使用视图进行内容协商

工程 | Paul Chapman | June 03, 2013 | ...

在我的上一篇文章中,我介绍了内容协商的概念以及 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),并且它总是返回 AccountExcelView。  InternalResourceViewResolver 从未被咨询过(其 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 profile。在 web.xml 中,你可以分别指定 xmljavaconfig 用于 XML 或 Java 配置。对于它们中的任何一个,可以指定 separatecombinedseparate profile 将所有视图解析器定义为顶级 bean,并让 CNVR 扫描上下文来查找它们(如前一节所述)。在 combined profile 中,视图解析器是显式定义的,而不是作为 Spring bean,并通过其 viewResolvers 属性传递给 CNVR(如本节所示)。

JSON 支持

Spring 提供了一个 MappingJacksonJsonView,它支持使用 Jackson 对象到 JSON 映射库从 Java 对象生成 JSON 数据。  MappingJacksonJsonView 会自动将 Model 中找到的所有属性转换为 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。它获取模型中第一个可以被序列化的对象并对其进行处理。你可以选择通过指定要选取哪个 Model 属性(键)来配置视图 - 参见 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" />

然而,这会产生不同风格的控制器方法,其优点在于它也更强大。那么该选择哪种方式呢:视图还是 @ResponseBody

对于已经使用 Spring MVC 和视图的现有网站,MappingJacksonJsonViewMarshallingView 提供了一种简便的方法来扩展 Web 应用程序,使其也能返回 JSON 和/或 XML。  在许多情况下,这些是你唯一需要的数据格式,并且是支持只读移动应用程序和/或启用 AJAX 的网页的一种简便方法,在这些应用中,RESTful 请求仅用于获取数据。

对 REST 的全面支持,包括修改数据的能力,涉及使用带注解的控制器方法并结合 HTTP 消息转换器。在这种情况下使用视图没有意义,只需返回一个 @ResponseBody 对象,让转换器完成工作即可。

然而,如我在上一篇文章此处所示,一个控制器同时使用这两种方法是完全可能的。现在,同一个控制器既可以支持传统的 Web 应用程序,也可以实现完整的 RESTful 接口,从而增强那些可能已构建和开发多年的 Web 应用程序。

Spring 一直以来在为开发者提供灵活性和选择方面表现出色。这也不例外。

获取 Spring 新闻通讯

订阅 Spring 新闻通讯,保持联系

订阅

迈向成功

VMware 提供培训和认证,助你快速进步。

了解更多

获取支持

Tanzu Spring 通过一项简单的订阅提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将到来的活动

查看 Spring 社区中所有即将到来的活动。

查看全部