使用视图进行内容协商

工程 | 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 社区所有即将举行的活动。

查看所有