使用视图进行内容协商

工程 | 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 是什么,如何使用它以及它是如何工作的。它采用了上一篇文章中的相同账户应用程序,并将其构建为以 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
  • 或者将使用 HTTPAccept标头属性(这实际上是 HTTP 的定义工作方式,但并不总是方便使用 - 特别是当客户端是浏览器时)。

在前两种情况下,后缀或参数值(xmljson...)必须映射到正确的内容类型。可以使用JavaBeans Activation Framework,也可以显式指定映射。对于Accept标头属性,其值就是内容类型。

内容协商视图解析器

这是一个特殊的视图解析器,我们的策略已插入其中。这是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与我在上一篇文章中讨论的完全相同。

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 对象到 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" />

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

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

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

查看全部