SpringMVC-WebFlux-高级教程-三-

190 阅读43分钟

SpringMVC WebFlux 高级教程(三)

原文:Pro Spring MVC with WebFlux

协议:CC BY-NC-SA 4.0

六、实现控制器:高级

本章着眼于 Spring MVC 的一些更高级的部分,然后展示如何利用这个框架来扩展它以满足我们的需求。

我们从检查作用域 beans 以及如何利用它们为我们服务开始。接下来,我们探索如何将通用功能(横切关注点)添加到我们的应用中。为此,我们来看看拦截器,包括如何创建它们以及如何将它们连接到我们的应用中。

无论我们的应用是多么健壮或经过深思熟虑的,总有一天我们的应用会表现得不像预期的那样(例如,可能有人通过网络连接到我们的数据库服务器),这会导致我们的应用出现异常。一般来说,我们希望防止用户看到模糊的堆栈跟踪;为此,我们探索 Spring MVC 中的异常处理机制。

在我们涵盖了所有这些主题之后,我们深入 Spring @MVC 的内部,探索几个我们可以扩展的 APIs 然后,我们使用这些扩展的 API 来增强框架的功能。

使用作用域 Beans

在第二章中,我们提到了 Spring 框架支持的 beans 的不同范围。表 6-1 再次列出。这一节使用范围对我们有利。具体来说,我们将通过一个实际的例子来利用一个限定了作用域的 bean 来创建一个在线购物车。

表 6-1

范围概述

|

前缀

|

描述

| | --- | --- | | singleton | 默认范围。创建一个 bean 实例,并在整个应用中共享。bean 的生命周期与构造它的应用上下文相关联。 | | prototype | 每次需要某个 bean 时,都会返回该 bean 的一个新实例。 | | thread | bean 在需要时创建,并绑定到当前执行的线程。如果线程死了,bean 就被破坏了。 | | request | bean 在需要时创建,并绑定到传入的javax.servlet.ServletRequest的生命周期。如果请求结束,bean 实例被销毁。 | | session | bean 在需要时创建并存储在javax.servlet.http.HttpSession中。当会话被销毁时,bean 实例也被销毁。 | | application | 这个作用域非常类似于单例作用域。主要的区别在于,具有该范围的 beans 也在javax.servlet.ServletContext中注册。 |

我们已经使用了 singleton 范围——因为这是 Spring 框架中 bean 创建的默认范围。org.springframework.context.annotation.Scope注释指定了 bean 的范围;其性能列于表 6-2 中。

表 6-2

范围批注属性

|

财产

|

描述

| | --- | --- | | value + scopeName | 要使用的范围的名称(见表 6-1 )。默认为 singleton。 | | proxyMode | 指示是否应创建作用域代理以及由哪种代理机制创建。除非通过组件扫描标记或注释设置了另一个默认代理模式,否则此属性默认为“否”。 |

该注释可以用作类型级或方法级注释。当您使用Scope作为类型级注释时,该类型的所有 beans 都具有注释指定的范围。当您将它用作方法级注释时,由这个带注释的方法创建的 beans 具有由注释指定的范围。您必须将它放在用org.springframework.context.annotation.Bean注释标注的方法上。

向购物车中添加东西

这部分迈出了让网站访问者从我们的书店购买书籍的第一步。具体来说,我们实现了允许我们将书籍添加到购物车中的逻辑。为此,我们首先需要定义一个会话范围的购物车 bean。

清单 6-1 展示了如何用会话范围定义一个 bean(我们的购物车)。这个 bean 可以被注入到其他 bean 中,就像框架中的任何其他 bean 一样。Spring 处理管理 bean 生命周期的复杂性。bean 的生命周期取决于 bean 的范围(参见表 6-1 )。例如,单例范围的 bean(默认)与应用上下文的生命周期相关联,而会话范围的 bean 与javax.servlet.http.HttpSession对象的生命周期相关联。

package com.apress.prospringmvc.bookstore.web.config;

//Other imports omitted

import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import com.apress.prospringmvc.bookstore.domain.Cart;

@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {

//Other methods omitted

  @Bean
  @Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
  public Cart cart() {
    return new Cart();
  }

}

Listing 6-1Cart Session Scoped Bean

在这种情况下,我们有一个带有注释的 bean 声明,并且我们使用会话范围。我们希望使用基于类的代理(com.apress.prospringmvc.bookstore.domain.Cart没有实现接口,所以我们需要基于类的代理)。我们现在可以简单地将这个 bean 注入到其他 bean 中,并像使用任何其他 bean 一样使用它。让我们创建一个使用这个 bean 的控制器:com.apress.prospringmvc.bookstore.web.controller.CartController(参见清单 6-2 )。

package com.apress.prospringmvc.bookstore.web.controller;

import com.apress.prospringmvc.bookstore.domain.Book;
import com.apress.prospringmvc.bookstore.domain.Cart;
import com.apress.prospringmvc.bookstore.service.BookstoreService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;

@Controller
public class CartController {

    private final Cart cart;
    private final BookstoreService bookstoreService;

    public CartController(Cart cart, BookstoreService bookstoreService) {
        this.cart = cart;
        this.bookstoreService = bookstoreService;
    }

    @PostMapping("/cart/add/{bookId}")
    public String addToCart(@PathVariable("bookId") long bookId,
                            @RequestHeader("referer") String referer) {
        Book book = this.bookstoreService.findBook(bookId);
        this.cart.addBook(book);
        return "redirect:" + referer;
    }
}

Listing 6-2The CartController Bean

在这种情况下,我们简单地自动连接会话范围的 bean cart,就像我们对任何其他 bean 所做的那样。addToCart方法包含将一本书添加到购物车的逻辑。添加完图书后,我们会重定向到我们来的页面(referer 请求标题)。

这个控制器被映射到 URL,/cart/add/{bookId};然而,目前没有任何东西调用我们的控制器,因为我们没有任何东西指向那个 URL。让我们修改我们的图书搜索页面,并添加一个链接,将一本书添加到我们的购物车中(参见清单 6-3 )。粗体突出显示的部分显示了更改。

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>

// Search Form Omitted

<c:if test="${not empty bookList}">
<table>
<tr>
    <th><spring:message code="book.title"/></th>
    <th><spring:message code="book.description"/></th>
    <th><spring:message code="book.price" /></th>
    <th></th>
</tr>

<c:forEach items="${bookList}" var="book">
    <tr>
        <td>
            <a href="<c:url value="/book/detail/${book.id}"/>">${book.title}</a>
        </td>
        <td>${book.description}</td>
        <td>${book.price}</td>
        <td>
            <a href="<c:url value="/cart/add/${book.id}"/>">
                <spring:message code="book.addtocart" />
            </a>
        </td>
    </tr>
</c:forEach>
</table>
</c:if>

Listing 6-3The Book Search Page with an Add to Cart Link

重启我们的应用后,我们应该在图书页面上有一个添加到购物车的链接(见图 6-1 )。如果我们点击那个链接,我们应该停留在图书页面。然而,我们确实往购物车里添加了一些东西。

img/300017_2_En_6_Fig1_HTML.jpg

图 6-1

带有添加到购物车链接的图书搜索

实现结帐

为了最终完成订购流程,我们允许客户检查他们的购物车。这在数据库中创建了一个实际的com.apress.prospringmvc.bookstore.domain.Order对象和条目。结帐是我们在前一章和前一节中讨论的许多事情的组合。控制器是com.apress.prospringmvc.bookstore.web.controller.CheckoutController(见清单 6-4 ),它包含很多逻辑。checkout.jsp文件是包含我们屏幕的 JSP 可以在/WEB-INF/views/cart中找到。

package com.apress.prospringmvc.bookstore.web.controller;

//Other imports omitted

import com.apress.prospringmvc.bookstore.validation.OrderValidator;

@Controller
@SessionAttributes(types = { Order.class })
@RequestMapping("/cart/checkout")
public class CheckoutController {

    private final Cart cart;
    private final BookstoreService bookstoreService;

    public CheckoutController(Cart cart, BookstoreService bookstoreService) {
        this.cart = cart;
        this.bookstoreService = bookstoreService;
    }

    @ModelAttribute("countries")
    public Map<String, String> countries(Locale currentLocale) {
        var countries = new TreeMap<String, String>();
        for (Locale locale : Locale.getAvailableLocales()) {
            countries.put(locale.getCountry(),locale.getDisplayCountry(currentLocale));
        }
        return countries;
    }

    @GetMapping
    public void show(HttpSession session, Model model) {
        var account = (Account) session.getAttribute(LoginController.ACCOUNT_ATTRIBUTE);
        var order = this.bookstoreService.createOrder(this.cart, account);
        model.addAttribute(order);
    }

    @PostMapping(params = "order")
    public String checkout(SessionStatus status,
                        @Validated @ModelAttribute Order order, BindingResult errors) {

        if (errors.hasErrors()) {
            return "cart/checkout";
        } else {
            this.bookstoreService.store(order);
            status.setComplete(); //remove order from session
            this.cart.clear(); // clear the cart
            return "redirect:/index.htm";
        }
    }

    @PostMapping(params = "update")
    public String update(@ModelAttribute Order order) {
        order.updateOrderDetails();
        return "cart/checkout";
    }

    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.setValidator(new OrderValidator());
    }
}

Listing 6-4The CheckoutController

当我们单击 checkout 时,在控制器上调用的第一个方法是 show 方法。它获取我们的购物车,并使用存储在会话中的account来创建订单,并将其添加到模型中。订单存储在请求之间的会话中;这要归功于SessionAttributes的使用。当这些完成后,结账页面被渲染(见图 6-2 )。

img/300017_2_En_6_Fig2_HTML.jpg

图 6-2

结账页面

填写表单时,客户可以做两件事:他可以按订购按钮或更新按钮。当按下 update 按钮时,将调用 Update 方法。这将提交表单,然后更新订单(并重新计算总价)。按下订单按钮时,订单被提交,然后由com.apress.prospringmvc.bookstore.validation.OrderValidator确认。出现错误时,页面会重新显示,并向客户显示错误消息。有趣的部分发生在没有错误的时候。首先,订单存储在数据库中。当我们完成订单时,我们需要从会话中删除它,这是通过调用org.springframework.web.bind.support.SessionStatus对象上的setComplete方法来完成的(参见第五章的“支持的方法参数类型”一节)。最后,在再次重定向到索引页面之前,我们需要清空购物车。我们这样做是为了让顾客可以在购物车中添加新书。因为我们不能简单地替换会话范围的对象,所以我们需要调用一个方法来清除它。如果我们要用一个新的实例替换 cart,我们将销毁作用域代理对象。

贯穿各领域的问题

在开发企业应用时,我们经常面临横切关注点的挑战。这些是影响许多对象和动作的关注点。横切关注点的例子包括事务管理和安全性,以及为每个传入的 web 请求公开通用数据之类的操作。

一般来说,使用传统的面向对象方法很难在我们的代码库中实现这些问题。如果我们用传统的方式实现它们,将会导致代码重复和难以维护的代码。对于我们的一般对象,我们可以使用面向方面编程(AOP)来解决这些横切关注点;然而,在将它应用于请求时,我们需要一种稍微不同的方法。

Spring MVC 给了我们两种实现横切关注点的方法。第一种方法使用拦截器来实现通用逻辑,而第二种方法依赖于异常处理。这一节将介绍在我们的 web 应用中应用横切关注点的两种技术。

截击机

拦截器对于请求处理程序就像过滤器对于 servlets 一样。根据 servlet 规范, 1 过滤器是一段可重用的代码,可以转换 HTTP 请求、响应和头信息的内容。过滤器修改或调整对资源的请求,并修改或调整来自资源的响应。过滤的例子包括认证、审计和加密。

过滤器和拦截器都实现了通用的功能(横切关注点),以应用于所有(或部分)传入的 HTTP 请求。过滤器比拦截器更强大,因为它们可以替换(或包装)传入的请求/响应,而拦截器不能做到这一点。另一方面,拦截器比过滤器有更多的生命周期方法(见表 6-3 )。

表 6-3

拦截器回调

|

方法

|

描述

| | --- | --- | | preHandle | 在调用处理程序之前调用。 | | postHandle | 当处理程序方法被成功调用时,在呈现视图之前调用。它可以在模型中放置共享对象。 | | afterCompletion | 在视图呈现之后,请求处理完成时调用。这个方法总是在成功调用 preHandle 方法的拦截器上调用,即使在请求处理过程中出现错误。它可以清理资源。 |

Spring MVC 有两种拦截器策略。

  • org.springframework.web.servlet.HandlerInterceptor(见清单 6-5 )

  • org.springframework.web.context.request.WebRequestInterceptor(见清单 6-6 )

package org.springframework.web.context.request;

import org.springframework.ui.ModelMap;

public interface WebRequestInterceptor {

    void preHandle(WebRequest request) throws Exception;

    void postHandle(WebRequest request, ModelMap model) throws Exception;

    void afterCompletion(WebRequest request, Exception ex) throws Exception;

}

Listing 6-6The WebRequestInterceptor Interface (in module spring-web)

package org.springframework.web.servlet;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public interface HandlerInterceptor {

    boolean preHandle(HttpServletRequest request,
                      HttpServletResponse response, Object handler) throws Exception;

    void postHandle(HttpServletRequest request, HttpServletResponse response,
                    Object handler, ModelAndView modelAndView) throws Exception;

    void afterCompletion(HttpServletRequest request, HttpServletResponse response,
 Object handler, Exception ex) throws Exception;

}

Listing 6-5The HandlerInterceptor Interface (in module spring-webmvc)

正如 Spring 框架中经常出现的情况,这两种策略都被表示为提供实现的接口。这两种策略的主要区别在于WebRequestInterceptor独立于底层技术。它可以在 JSF 或 Servlet 环境中使用,而无需更改实现。一个handler interceptor只能在 Servlet 环境中使用。HandlerInterceptor的一个优点是我们可以用它来防止处理程序被调用。我们通过从preHandle方法返回false来做到这一点。

配置拦截器

要使用拦截器,您需要在配置中配置它。配置拦截器包括两个步骤。

  1. 配置拦截器。

  2. 将它连接到处理程序。

将拦截器连接到我们的处理程序有两种方式。可以同时使用这两种方法,但我们不推荐这样做。首先,我们可以使用BeanPostProcessor显式地将拦截器添加到我们的处理程序映射中。第二,我们可以使用org.springframework.web.servlet.config.annotation.InterceptorRegistry来添加拦截器。

一般来说,最好使用InterceptorRegistry来添加拦截器,因为这是一种非常方便的添加方式。限制拦截器匹配的 URL 也非常容易(在关于InterceptorRegistry的部分中解释)。)

使用 BeanPostProcessor 显式配置带有拦截器的处理程序映射

为了用处理程序映射注册拦截器,我们首先需要包含处理程序映射。为此,我们需要显式地添加它们或者扩展 Spring 基类来获得对它们的引用(参见清单 6-7 )。接下来,我们简单地将所有拦截器添加到实例中。使用多个处理程序映射可能会很麻烦,尤其是如果我们只想将拦截器应用于某些 URL。

package com.apress.prospringmvc.bookstore.web.config;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import java.util.List;

public class InterceptorAddingPostProcessor implements BeanPostProcessor {

    private final List<HandlerInterceptor> interceptors;

    public InterceptorAddingPostProcessor(List<HandlerInterceptor> interceptors) {
        this.interceptors = interceptors;
    }

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        if (bean instanceof RequestMappingHandlerMapping) {
            RequestMappingHandlerMapping handlerMapping = (RequestMappingHandlerMapping) bean;
            handlerMapping.setInterceptors(this.interceptors);
        }
        return bean;
    }
}

Listing 6-7A Sample of Explicit HandlerMapping BeanPostProcessor for Interceptors

使用拦截注册表

一种更强大、更灵活的注册拦截器的方法是使用org.springframework.web.servlet.config.annotation.InterceptorRegistry。添加到这个注册表中的拦截器被添加到所有已配置的处理程序映射中。此外,使用这种方法很容易映射到某些 URL。为了访问注册表,我们需要在配置 web 资源的配置类上实现org.springframework.web.servlet.config.annotation.WebMvcConfigurer接口。这个接口有几个回调方法,在 Spring MVC 的配置过程中被调用。

InterceptorRegistry有两个方法(每种拦截器类型一个)可以用来添加拦截器(参见清单 6-8 )。这两个方法都返回了一个org.springframework.web.servlet.config.annotation.InterceptorRegistration的实例,我们可以用它来微调拦截器的映射。我们可以使用蚂蚁风格的路径模式 2 来为注册的拦截器配置细粒度的映射。如果我们不提供模式,拦截器将应用于所有传入的请求。

package org.springframework.web.servlet.config.annotation;

import java.util.ArrayList;
import java.util.List;
import org.springframework.web.context.request.WebRequestInterceptor;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.handler.WebRequestHandlerInterceptorAdapter;

public class InterceptorRegistry {

    public InterceptorRegistration addInterceptor(HandlerInterceptor interceptor) { .. }

    public InterceptorRegistration addWebRequestInterceptor(WebRequestInterceptor interceptor) { ..
}

}

Listing 6-8The InterceptorRegistry Interface

清单 6-9 显示了我们当前的配置。此时,我们已经配置了一个拦截器来更改区域设置,这个拦截器应用于所有传入的请求(我们没有指定匹配的 URL 模式)。接下来,我们配置拦截器并使用addInterceptor方法将其添加到注册表中。该框架负责用已配置的处理程序映射注册拦截器的附加细节。

package com.apress.prospringmvc.bookstore.web.config;

//Other imports omitted

import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;

@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(localeChangeInterceptor());
  }

  @Bean
  public HandlerInterceptor localeChangeInterceptor() {
    var localeChangeInterceptor = new LocaleChangeInterceptor();
    localeChangeInterceptor.setParamName("lang");
    return localeChangeInterceptor;
  }

//... Other methods omitted
}

Listing 6-9Using the InterceptorRegistry to Add Interceptors

清单 6-10 显示了一段代码,其中我们将所有 URL 的映射改为只有以/customers开头的 URL。

package com.apress.prospringmvc.bookstore.web.config;

//Imports omitted

@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
      var registration = registry.addInterceptor(localeChangeInterceptor());
      registation.addPathPatterns("/customers/**");
  }

//Other methods omitted

}

Listing 6-10Limiting an Interceptor to Certain URLs

实现拦截器

到目前为止,我们已经介绍了不同类型的拦截器以及如何注册它们以供使用。现在让我们为我们的存储实现拦截器。我们实现了两种不同的拦截器。第一个将一些常用的数据添加到我们的模型中,以显示给用户。第二个解决了一个安全需求:我们希望只有注册用户才能访问帐户和结帐页面。

实现 WebRequestInterceptor

在本节中,我们实现org.springframework.web.context.request. WebRequestInterceptor。如果你在图 6-3 中查看我们的网页,你会看到一个随机图书区。到目前为止,我们网页上的这一部分仍然是空的。现在我们创建一个拦截器,向模型中添加一些随机书籍。为此,我们实现了后处理方法(参见清单 6-11 )。

img/300017_2_En_6_Fig3_HTML.jpg

图 6-3

没有列出随机书籍的欢迎页面

在一个真实的网上商店中,你可能会称这个部分为“新书”或“推荐书籍”

package com.apress.prospringmvc.bookstore.web.interceptor;

import com.apress.prospringmvc.bookstore.service.BookstoreService;
import org.springframework.ui.ModelMap;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.context.request.WebRequestInterceptor;

public class CommonDataInterceptor implements WebRequestInterceptor {

    private final BookstoreService bookstoreService;

    public CommonDataInterceptor(BookstoreService bookstoreService) {
        this.bookstoreService = bookstoreService;
    }

    @Override
    public void preHandle(WebRequest request) throws Exception {
    }

    @Override
    public void postHandle(WebRequest request, ModelMap model) throws Exception {
        if (model != null) {
            model.addAttribute("randomBooks", this.bookstoreService.findRandomBooks());
        }
    }

    @Override
    public void afterCompletion(WebRequest request, Exception ex) throws Exception {
    }
}

Listing 6-11The CommonDataInterceptor

postHandle方法向模型中添加一些随机的书籍,但是只有当这个模型可用时。这就是为什么我们的代码包含了一个null检查。当我们使用 AJAX 或自己编写响应时,模型可以是null

为了将拦截器应用于传入的请求,我们需要注册它。拦截器需要为每个传入的请求调用,所以它不需要太多额外的配置(参见清单 6-12 中突出显示的行)。

package com.apress.prospringmvc.bookstore.web.config;

import org.springframework.web.context.request.WebRequestInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;

com.apress.prospringmvc.bookstore.web.interceptor.CommonDataInterceptor;

// Other imports omitted

@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {

  private final BookstoreService bookstoreService;

  public WebMvcContextConfiguration(BookstoreService bookstoreService) {
    this.bookstoreService = bookstoreService;
  }

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(localeChangeInterceptor());
    registry.addWebRequestInterceptor(commonDataInterceptor());
  }

  @Bean
  public WebRequestInterceptor commonDataInterceptor() {
    return new CommonDataInterceptor(this.bookstoreService);
  }

// Other methods omitted
}

Listing 6-12The CommondDataInterceptor Configuration

现在,当我们重新部署我们的应用并访问一个页面时,我们应该看到随机书籍显示在页面的随机书籍部分(见图 6-4 )。(我们模板中用于选择随机书籍的逻辑如清单 6-13 所示。)

img/300017_2_En_6_Fig4_HTML.jpg

图 6-4

欢迎页面,标题在随机书籍部分

<div class="right_box">
    <div class="title">
        <span class="title_icon">
            <img src="<c:url value="/resourcimg/bullet4.gif"/>" alt="" title="" />
        </span>
        <spring:message code="main.title.randombooks"/>
    </div>

    <c:forEach items="${randomBooks}" var="book">
        <div class="new_prod_box">
            <c:url value="/book/detail/${book.id}" var="bookUrl" />
            <a href="${bookUrl}">${book.title}</a>
            <div class="new_prod_img">
                <c:url value="/resourcimg/${book.isbn}/book_front_cover.png" var="bookImage"/>
                    <a href="${bookUrl}">
                        <img src="${bookImage}" alt="${book.title}" title="${book.title}" class="thumb" border="0" width="100px"/>
                    </a>
            </div>
        </div>
    </c:forEach>
</div>

Listing 6-13The Random Books Section from the Template

实现处理程序拦截器

目前,我们的帐户页面不安全。例如,某人可以简单地更改 URL 中的 ID 来查看另一个帐户的内容。让我们使用拦截器方法将安全性应用到我们的页面。我们将创建一个拦截器来检查我们是否已经登录(我们的帐户在 HTTP 会话中是可用的)。如果没有,它抛出com.apress.prospringmvc.bookstore.service.AuthenticationException(参见清单 6-14 )。我们还将原始 URL 存储在会话属性中;这样,我们可以在用户登录后将他重定向到他想要访问的 URL。

在为应用实现或添加安全性时,通常最好使用像 Spring Security 3 (参见第十二章)或阿帕奇·希罗 4 这样的框架,而不是推出自己的安全解决方案!

package com.apress.prospringmvc.bookstore.web.interceptor;

// javax.servlet imports omitted

import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import org.springframework.web.util.WebUtils;
import com.apress.prospringmvc.bookstore.domain.Account;
import com.apress.prospringmvc.bookstore.service.AuthenticationException;
import com.apress.prospringmvc.bookstore.web.controller.LoginController;

public class SecurityHandlerInterceptor implements HandlerInterceptor {

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
 Object handler) throws Exception {
    var account= (Account) WebUtils.getSessionAttribute(request, LoginController.ACCOUNT_ATTRIBUTE);
    if (account == null) {
      //Retrieve and store the original URL.
      var url = request.getRequestURL().toString();
      WebUtils.setSessionAttribute(request, LoginController.REQUESTED_URL, url);
      throw new AuthenticationException("Authentication required.", "authentication.required");
    }

    return true;
  }
}

Listing 6-14SecurityHandlerInterceptor

对于这个拦截器,我们的配置稍微复杂一些,因为我们想要将它映射到某些 URL(参见清单 6-15 中突出显示的部分)。

package com.apress.prospringmvc.bookstore.web.config;

import com.apress.prospringmvc.bookstore.web.interceptor.SecurityHandlerInterceptor;

//Other imports omitted

@Configuration
public class WebMvcContextConfiguration extends WebMvcConfigurerAdapter {

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(localeChangeInterceptor());
    registry.addWebRequestInterceptor(commonDataInterceptor());
    registry.addInterceptor(new SecurityHandlerInterceptor()).
        addPathPatterns("/customer/account*", "/cart/checkout");

  }

// Other methods omitted

}

Listing 6-15SecurityHandlerInterceptor Configuration

最后,我们还需要对我们的com.apress.prospringmvc.bookstore.web.controller.AccountController进行修改。目前,我们希望 URL 中包含一个 ID。但是,我们不是从数据库中检索帐户,而是从会话中恢复它。清单 6-16 显示了必要的修改。

package com.apress.prospringmvc.bookstore.web.controller;

// Imports omitted

@Controller
@RequestMapping("/customer/account")
@SessionAttributes(types = Account.class)
public class AccountController {

//Fields and other methods omitted

    @GetMapping
    public String index(Model model, HttpSession session) {
        var account = (Account) session.getAttribute(LoginController.ACCOUNT_ATTRIBUTE);
        model.addAttribute(account);
        model.addAttribute("orders", this.orderRepository.findByAccount(account));
        return "customer/account";
    }

    @PostMapping
    @PutMapping
    public String update(@ModelAttribute Account account) {
        this.accountRepository.save(account);
        return "redirect:/customer/account";
    }
}

Listing 6-16The AccountController

当我们重新部署应用并单击菜单栏中的 Account 时,我们会看到一个错误页面(参见图 6-5 )。我们使用默认的异常处理机制将错误代码发送回客户端,以便浏览器可以对其进行操作。在下一节中,我们将更详细地讨论异常处理。

img/300017_2_En_6_Fig5_HTML.jpg

图 6-5

单击安全链接后出现 403 错误页面

虽然我们已经保护了我们的资源,但如果能向用户显示登录页面,并提示她需要登录才能看到所请求的页面,那就更好了。这是我们在下一节要做的。

异常处理

正如第四章中提到的,当请求处理过程中发生异常时,Spring 会尝试处理该异常。为了给我们一种处理异常的通用方法,Spring 使用了另一种策略,可以通过实现org.springframework.web.servlet.HandlerExceptionResolver接口来利用这种策略。

org.springframework.web.servlet.HandlerExceptionResolver为 dispatcher servlet 提供了一个回调方法(参见清单 6-17 )。当请求处理工作流中发生异常时,将调用此方法。该方法可以返回org.springframework.web.servlet.ModelAndView,也可以选择自己处理异常。

package org.springframeowork.web.servlet;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public interface HandlerExceptionResolver {
    ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,
 Object handler, Exception ex);

}

Listing 6-17The HandlerExceptionResolver Interface

默认情况下,DispatcherServlet 在类型为org.springframework.web.servlet.HandlerExceptionResolver的应用上下文中查找所有 beans(参见第四章中的“配置 dispatcher servlet”一节)。当检测到多个解析器时,dispatcher servlet 会查询它们,直到返回 viewname 或写入响应。如果异常不能被处理,那么异常被重新抛出,以便 servlet 容器可以处理它。servlet 容器使用其配置中的错误页面配置,或者简单地将异常传播给用户。(在大多数情况下,您会在屏幕上看到一个错误 500 和一个堆栈跟踪。)

Spring MVC 附带了几个org.springframework.web.servlet.HandlerExceptionResolver接口的实现,如图 6-6 所示。请注意,这些实现的工作方式各不相同。表 6-4 给出了不同实现如何工作的简要概述。

表 6-4

HandlerExceptionResolver 实现

|

处理器异常解析器

|

描述

| | --- | --- | | ExceptionHandlerExceptionResolver | 在当前控制器中搜索用@ExceptionHandler标注的方法,并选择最佳的异常处理方法来处理异常。然后它调用选定的方法。 | | DefaultHandlerExceptionResolver | 将众所周知的异常转换为对客户端的正确响应。返回一个空的ModelAndView并将适当的 HTTP 响应代码发送给客户机。 | | ResponseStatusExceptionResolver | 查找异常上的org.springframework.web.bind.annotation.ResponseStatus注释,并使用它向客户端发送响应。 | | SimpleMappingExceptionResolver | 通过异常类名或该类名的一部分(子字符串)将异常映射到视图名。这种实现既可以全局配置,也可以针对某些控制器进行配置。 | | HandlerExceptionResolverComposite | 由 MVC 配置在内部用来链接异常解析器。只有框架可以使用它。 |

img/300017_2_En_6_Fig6_HTML.png

图 6-6

HandlerExceptionResolver 层次结构

如图 6-6 中的类图所示,大多数解决异常的可用实现都扩展了org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver。这是一个方便的超类,为所有实现提供了通用的特性和配置选项。表 6-5 列出并简要描述了其通用属性。

表 6-5

公共抽象处理程序 exception 解析属性

|

财产

|

描述

| | --- | --- | | mappedHandlerClasses | 一组HandlerExceptionResolver应该处理异常的处理程序类。从不在集合中的类型的处理程序传播的异常不由这个HandlerExceptionResolver处理。 | | mappedHandlers | 类似于mappedHandlerClasses,但是它包含实际的处理程序(在本例中是控制器),而不是类。 | | preventResponseCaching | 使我们能够防止缓存由此HandlerExceptionResolver解析的视图。默认值是false,允许浏览器缓存错误页面。 | | warnLogCategory | 设置用于记录异常的类别(日志级别为 WARN)。缺省值是 no category,这意味着没有日志记录。 |

所有属性都在AbstractHandlerExceptionResolver上定义。

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver实现总是返回一个空的ModelAndView,并向客户端发送一个 HTTP 响应代码。在表 6-6 中,您可以看到 HTTP 响应代码和描述映射的异常。

表 6-6

异常 HTTP 响应代码映射

|

例外

|

HTTP 代码

|

描述

| | --- | --- | --- | | NoSuchRequestHandlingMethodException | Four hundred and four | 未发现 | | HttpRequestMethodNotSupportedException | Four hundred and five | 不允许的方法 | | HttpMediaTypeNotSupportedException | Four hundred and fifteen | 不支持的媒体类型 | | HttpMediaTypeNotAcceptableException | Four hundred and six | 不可接受 | | ConversionNotSupportedExceptionHttpMessageNotWritableException | Five hundred | 内部服务器错误 | | MissingServletRequestParameterExceptionServletRequestBindingExceptionTypeMismatchExceptionHttpMessageNotReadableExceptionMethodArgumentNotValidExceptionMissingServletRequestPartException | four hundred | 错误的请求 |

ResponseStatusExceptionResolver

ResponseStatusExceptionResolver检查抛出的异常是否用org.springframework.web.bind.annotation.ResponseStatus注释进行了注释(参见清单 6-18 )。如果是这种情况,它会处理异常,将来自注释的 HTTP 响应代码发送到客户端,然后返回一个空的ModelAndView,指示异常已被处理。如果该注释不存在,它只是返回null来表明异常没有被处理。

package com.apress.prospringmvc.bookstore.service;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(value = HttpStatus.FORBIDDEN)
public class AuthenticationException extends Exception {

    private final String code;

    public AuthenticationException(String message, String code) {
        super(message);
        this.code = code;
    }

    public String getCode() {
        return this.code;
    }
}

Listing 6-18Handling an AuthenticationException

当我们抛出这个异常时,org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver检测到它已经用org.springframework.web.servlet.bind.annotation.ResponseStatus进行了注释。这是我们用来让框架处理com.apress.prospringmvc.bookstore.service.AuthenticationException的机制。这个注释有两个属性可以用来指定信息(见表 6-7 )。

表 6-7

ResponseStatus属性

|

财产

|

描述

| | --- | --- | | Value | 发送要发送给客户端的 HTTP 响应代码。这是必须的。 | | Reason | 将原因发送给客户端。这是可选的。它还提供了附加信息。 |

SimpleMappingExceptionResolver

SimpleMappingExceptionResolver可以配置为将某些异常转换为视图。例如,我们可以将(部分)异常类名映射到一个视图。我们在这里说部分是因为匹配是基于类名完成的,而不是基于它的具体类型。匹配是通过简单的子串机制完成的;不支持通配符(ant 样式的正则表达式)。

清单 6-19 显示了SimpleMappingExceptionResolver的配置。它被配置为将一个AuthenticationException映射到名为 login 的视图。我们还设置了一个 HTTP 响应代码,用 login 视图发送。

package com.apress.prospringmvc.bookstore.web.config;

import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;

// Imports omitted

@Configuration

public class WebMvcContextConfiguration implements WebMvcConfigurer {

    @Override
    public void configureHandlerExceptionResolvers(List<HandlerExceptionResolver> exceptionResolvers {
        exceptionResolvers.add(simpleMappingExceptionResolver());
    }

    @Bean
    public SimpleMappingExceptionResolver simpleMappingExceptionResolver() {
        var mappings = new Properties();
        mappings.setProperty("AuthenticationException", "login");

        var statusCodes = new Properties();
        mappings.setProperty("login", String.valueOf(HttpServletResponse.SC_UNAUTHORIZED));

        var exceptionResolver = new SimpleMappingExceptionResolver();
        exceptionResolver.setExceptionMappings(mappings);
        exceptionResolver.setStatusCodes(statusCodes);
        return exceptionResolver;
    }
// Other methods omitted
}

Listing 6-19A SimpleMappingExceptionResolver Configuration

匹配是基于类名而不是具体类型来完成的。如果抛出的异常的类名与指定的模式匹配,则使用相应的视图名。该模式不支持通配符;它仅仅是一个匹配类名的子串。我们需要仔细选择图案。例如,Exception匹配几乎所有抛出的异常(因为大多数异常的类名中都有异常)。类似地,DataAccessException或多或少地匹配所有 Spring 的数据访问异常。

我们需要做最后的调整。即,我们需要修改our com.apress.prospringmvc.bookstore.web.controller.LoginController。此刻,控制器内部有异常处理;然而,这可以被删除,因为AuthenticationException是由我们最近配置的HandlerExceptionResolver处理的(参见清单 6-20 中改进的控制器)。

package com.apress.prospringmvc.bookstore.web.controller;

// Imports omitted

@Controller

@RequestMapping(value = "/login")
public class LoginController {

public static final String ACCOUNT_ATTRIBUTE = "account";
public static final String REQUESTED_URL = "REQUESTED_URL";

private final AccountService accountService;

    @GetMapping
    public void login() {}

    @PostMapping
    public String handleLogin(@RequestParam String username, @RequestParam String password,
 HttpSession session) throws AuthenticationException {

        var account = this.accountService.login(username, password);
        session.setAttribute(ACCOUNT_ATTRIBUTE, account);
        var url = (String) session.getAttribute(REQUESTED_URL);
        session.removeAttribute(REQUESTED_URL);
        if (StringUtils.hasText(url) && !url.contains("login")) {
            return "redirect:" + url;
        } else {
            return "redirect:/index.htm";
        }
    }
}

Listing 6-20The Improved Login Controller

如果我们在重新部署后点击菜单栏上的 Account,我们会看到一个登录页面(见图 6-7 )。

img/300017_2_En_6_Fig7_HTML.jpg

图 6-7

登录页面

ExceptionHandlerExceptionResolver

ExceptionHandlerExceptionResolver在当前控制器或@ControllerAdvice注释类中寻找用org.springframework.web.bind.annotations.ExceptionHandler注释的方法。

异常处理方法非常像控制器方法(如第五章所解释的);它们可以使用相同的方法参数和相同的返回值。异常处理方法使用相同的底层基础结构来检测返回类型和方法参数类型。然而,除了这些方法之外,我们还可以在抛出的异常中传递一个附加的方法;也就是说,我们可以指定 Exception 类型的参数(或子类)。

清单 6-21 中的方法处理定义它的控制器中抛出的所有异常。它会将错误代码 500 连同给定的原因一起发送回客户端。这是我们可以编写的最基本的异常处理方法。如前所述,我们可以在方法签名中使用多个参数,这也适用于方法参数,因为方法返回类型不同。(有关概述,请参见上一章中的表 5-3 和表 5-4。)

@ExceptionHandler
@ResponseStatus(value=HttpStatus.INTERNAL_SERVER_ERROR, reason="Exception while handling request.")
public void handleException() {}

Listing 6-21A Basic Exception-handling Method Sample

清单 6-22 显示了一个更详细的例子。当org.springframework.dao.DataAccessException发生时,它用尽可能多的信息填充模型。之后,名为db-error的视图被渲染。

@ExceptionHandler
public ModelAndView handle(DataAccessException ex, Principal principal, WebRequest request) {
    var mav = new ModelAndView("db-error");
    mav.addObject("exception", ex);
    mav.addObject("username", principal.getName());
    mav.addAllObjects(request.getParameterMap());
    for(Iterator<String> names = request.getHeaderNames(); names.hasNext(); ) {
        var name = names.next();
        var value = request.getHeaderValues(name);
        mav.addObject(name, value);
    }
    return mav;
}

Listing 6-22An Advanced Exception-handling Method Sample

扩展 Spring@MVC

在前面的章节中,我们解释了 Spring MVC 是如何工作的,以及我们如何编写控制器。然而,可能会有这样的时候,即装即用的框架支持不够充分,我们希望改变或增加框架的行为。一般来说,Spring 框架由于其构建方式而具有灵活性。它使用了许多策略和委托,我们可以用它们来扩展或修改框架的行为。在这一节中,我们将深入研究请求映射、请求处理和表单呈现的内部机制。最后,我们将讨论如何扩展这些特性。

延伸RequestMappingHandlerMapping

为了将传入的请求映射到控制器方法,Spring 使用了处理程序映射。对于我们的用例,我们一直使用org.springframework.web.servlet.mvc.method.annotation. RequestMappingHandlerMapping,我们已经多次提到它的灵活性。为了将基于方法的请求与org.springframework.web.bind.annotation.RequestMapping注释相匹配,处理程序映射参考了几个org.springframework.web.servlet.mvc.condition.RequestCondition实现(参见图 6-8 )。

img/300017_2_En_6_Fig8_HTML.png

图 6-8

RequestCondition 类图

如图所示,每个属性都有一个实现(即,消费、头、方法、参数、产品和值;更多细节见org.springframework.web.bind.annotation.RequestMapping注释的表 5-2)。RequestConditionHolderRequestMappingInfo是框架内部使用的两个实现。

要创建一个实现,我们需要两样东西。首先,我们需要一个接口的实现(参见清单 6-23 中的 API)。第二,我们需要扩展org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping。这个类包含两个回调方法,作为我们定制请求方法的工厂方法(参见清单 6-24 )。调用getCustomTypeCondition方法来创建匹配类型级条件的实例,而getCustomMethodCondition方法用于方法级条件。

package org.springframework.web.servlet.mvc.method.annotation;

import java.lang.reflect.Method;
import org.springframework.context.EmbeddedValueResolverAware;
//other imports omitted

public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMapping implements MatchableHandlerMapping, EmbeddedValueResolverAware {

// Other methods omitted.

    protected RequestCondition<?> getCustomMethodCondition(Method method) {
        return null;
    }

    protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
        return null;
    }
}

Listing 6-24The RequestMappingHandlerMapping

package org.springframework.web.servlet.mvc.condition;

import javax.servlet.http.HttpServletRequest;

import org.springframework.web.bind.annotation.RequestMapping;

public interface RequestCondition<T> {

T combine(T other);

T getMatchingCondition(HttpServletRequest request);

int compareTo(T other, HttpServletRequest request);

}

Listing 6-23The RequestCondition API

扩展 RequestMappingHandlerAdapter

RequestMappingHandlerMapping一样,RequestMappingHandlerAdapter使用几种不同的策略来完成它的工作。为了确定在方法参数中注入什么,适配器参考了几个org.springframework.web.method.support.HandlerMethodArgumentResolver实现。对于返回类型,它参考已注册的org.springframework.web.method.support.HandlerMethodReturnValueHandler实现。

HandlerMethodArgumentResolver

RequestMappingHandlerAdapter使用HandlerMethodArgumentResolver来确定方法参数的用途。每个支持的方法参数类型或注释都有一个实现(参见第五章的“支持的方法参数类型”一节)。API 很简单,如清单 6-25 所示。

package org.springframework.web.method.support;

import org.springframework.core.MethodParameter;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;

public interface HandlerMethodArgumentResolver {

    boolean supportsParameter(MethodParameter parameter);

    Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
 NativeWebRequest webRequest, WebDataBinderFactory binderFactory)throws Exception;

}

Listing 6-25The HandlerMethodArgumentResolver API

在每个注册的HandlerMethodArgumentResolver上调用supportsParameter方法。返回true的函数检测或创建用于该方法参数的实际值。我们通过调用 resolveArgument 方法来实现这一点。

handletmethodreturnvaluehandler

HandlerMethodReturnValueHandler类似于HandlerMethodArgumentResolver,但是有一个重要的区别。顾名思义,HandlerMethodReturnValueHandler适用于方法返回值。每个支持的返回值或注释都有一个实现(参见第五章的“支持的返回值”一节)。这个 API 也很简单,如清单 6-26 所示。

package org.springframework.web.method.support;

import org.springframework.core.MethodParameter;

import org.springframework.web.context.request.NativeWebRequest;

public interface HandlerMethodReturnValueHandler {

    boolean supportsReturnType(MethodParameter returnType);

    void handleReturnValue(Object returnValue, MethodParameter returnType,
                           ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
                           throws Exception;

}

Listing 6-26HandlerMethodReturnValueHandler

每个注册的HandlerMethodReturnValueHandlersupportsReturnType方法用该方法的返回类型调用。返回true的函数处理返回值,这是通过调用handleReturnValue方法来完成的。

实现您自己的

我们可以利用RequestMappingHandlerAdapter使用的策略。例如,我们想要一种简单的方法来存储和检索javax.servlet.http.HttpSession中的对象。为此,我们首先需要一个注释,将方法参数或返回类型标记为我们想要检索或放入HttpSession的内容。清单 6-27 描述了我们使用的注释。

package com.apress.prospringmvc.bookstore.web.method.support;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SessionAttribute {

    String value() default "";

    boolean required() default true;

    boolean exposeAsModelAttribute() default false;

}

Listing 6-27The SessionAttribute Annotation

然而,添加注释本身并没有多大帮助,因为我们仍然需要一个使用该注释的类。因为我们想从HttpSession中检索并存储,所以我们创建了一个实现了HandlerMethodReturnValueHandlerHandlerMethodArgumentResolver接口的类(参见清单 6-28 )。

package com.apress.prospringmvc.bookstore.web.method.support;

import org.springframework.core.MethodParameter;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
import org.springframework.web.method.support.ModelAndViewContainer;

public class SessionAttributeProcessor implements HandlerMethodReturnValueHandler, HandlerMethodArgumentResolver {

    @Override
    public boolean supportsReturnType(MethodParameter returnType) {
        return returnType.getMethodAnnotation(SessionAttribute.class) != null;
    }

    @Override
    public void handleReturnValue(Object returnValue, MethodParameter returnType,
                                  ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throw Exception {

        var annotation = returnType.getMethodAnnotation(SessionAttribute.class);
        webRequest.setAttribute(annotation.value(), returnValue, WebRequest.SCOPE_SESSION);
        exposeModelAttribute(annotation, returnValue, mavContainer);
    }

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(SessionAttribute.class);
    }

    private void exposeModelAttribute(SessionAttribute annotation, Object value,
 ModelAndViewContainer mavContainer) {
        if (annotation.exposeAsModelAttribute()) {
            mavContainer.addAttribute(annotation.value(), value);
        }
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                NativeWebRequest webRequest, WebDataBinderFactory binderFactory)
                                throws Exception {

        var annotation = parameter.getParameterAnnotation(SessionAttribute.class);
        var value = webRequest.getAttribute(annotation.value(), WebRequest.SCOPE_SESSION);
        if (value == null && annotation.required()) {
            throw new MissingServletRequestParameterException(annotation.value(), parameter.getParameterType().getName());
        }
        exposeModelAttribute(annotation, value, mavContainer);
        return value;
    }

}

Listing 6-28The SessionAttributeProcessor

在使用处理器之前,我们需要对其进行配置。为此,我们需要修改我们的配置类。具体来说,我们需要将处理器添加为 bean,并让环境知道 bean 的存在(参见清单 6-29 )。

package com.apress.prospringmvc.bookstore.web.config;

com.apress.prospringmvc.bookstore.web.method.support.SessionAttributeProcessor;

// Other imports omitted

@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {

    @Bean
    public SessionAttributeProcessor sessionAttributeProcessor() {
        return new SessionAttributeProcessor();
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(sessionAttributeProcessor());
    }

    @Override
    public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> returnValueHandlers) {
        returnValueHandlers.add(sessionAttributeProcessor());
    }

// Other methods omitted
}

Listing 6-29The Modified WebMvcContextConfiguration

现在我们已经配置了处理器,我们终于可以使用它了。让我们从修改帐户的控制器开始,如清单 6-30 所示。我们不再需要直接访问会话并将帐户添加到模型中;这现在全部由处理器处理。粗体字反映了变化;它只是一个方法参数的注释。此时,我们不再需要直接访问 HTTP 会话。

package com.apress.prospringmvc.bookstore.web.controller;

// Imports omitted

@Controller
@RequestMapping("/customer/account")
@SessionAttributes(types = Account.class)
public class AccountController {

    @RequestMapping(method = RequestMethod.GET)
    public String index(Model model,@SessionAttribute(value = LoginController.ACCOUNT_ATTRIBUTE, exposeAsModelAttribute = true) Account account) {
        model.addAttribute("orders", this.orderRepository.findByAccount(account));

        return "customer/account";

    }

// Other methods omitted

}

Listing 6-30The Modified AccountController

如果我们现在重新启动应用并单击 Account(登录后),我们会看到我们的帐户页面(见图 6-9 )。

img/300017_2_En_6_Fig9_HTML.jpg

图 6-9

帐户页面

使用 RequestDataValueProcessor

org.springframework.web.servlet.support.RequestDataValueProcessor组件是可选的,因为我们可以在呈现请求参数值或发出重定向之前使用它来检查或修改请求参数值。

我们可以使用这个组件作为解决方案 5 的一部分,以提供数据完整性、机密性和防止跨站点请求伪造(CSRF)。 6 我们也可以用它来自动给所有表单和 URL 添加隐藏字段。

RequestDataValueProcessor API 由四个方法组成(参见清单 6-31 )。

package org.springframework.web.servlet.support;

import java.util.Map;
import javax.servlet.http.HttpServletRequest;

public interface RequestDataValueProcessor {

    String processAction(HttpServletRequest request, String action, String httpMethod);

    String processFormFieldValue(HttpServletRequest request, String name, String value, String type);

    Map<String, String> getExtraHiddenFields(HttpServletRequest request);

    String processUrl(HttpServletRequest request, String url);

}

Listing 6-31The RequestDataValueProcessor API

我们可以用这个界面做一些有趣的事情。例如,我们可以在控制器(或拦截器)中不可编辑的字段(如 ID)上创建一个校验和,然后检查这个校验和,看是否有任何字段被篡改。

<c:/url ../>标签使用了processUrl方法。在重定向时,我们可以使用它来编码或向 URL 添加额外的参数,以保护我们的 URL(例如,我们可以添加校验和来检查参数的有效性)。

框架没有提供默认实例。因此,需要为我们的应用定制一个实现(HDIV 7 网站有一个插件来保护网站免受各种漏洞的攻击)。要配置RequestDataValueProcessor,我们需要将它添加到应用上下文中,然后用名称requestDataValueProcessor注册它,这是框架用来检测注册实例的名称。

摘要

本章讲述了构建 web 应用的一些更高级的技术。例如,我们从查看作用域 beans 开始,并利用它们为我们服务。为此,我们在示例应用中实现了一个购物车。

有时,我们发现自己需要重用代码或跨许多类或 URL 执行代码。这些横切关注点可以使用面向方面的编程来解决;然而,这并不总是很适合 web 应用。在 Spring MVC 中,我们可以使用拦截器和高级异常处理策略来解决那些横切关注点。例如,我们可以使用拦截器为许多控制器执行一段代码。在配置这些拦截器时,我们可以根据 URL 指定是映射到所有控制器还是只映射到某些控制器。

尽管我们都试图构建尽可能健壮的应用,但总有出错的可能。当事情确实出错时,我们希望优雅地处理问题。例如,当我们需要用户的凭证时,我们可能希望向用户显示一个错误页面或登录页面。为此,我们深入研究了 Spring MVC 中的异常处理策略。

随后,我们更深入地研究了 Spring MVC 的基础设施类,并研究了如何在需要时扩展框架。我们还解释了如何通过指定额外的请求条件来扩展请求匹配。接下来,我们解释(并展示)了如何编写一个处理器来处理方法参数类型和请求处理方法的返回值。

最后,我们以对请求数据值处理器的简要介绍结束了本章,介绍了如何使用它来防止 CSFR 并提供数据完整性。

Footnotes 1

参见 Servlet 规范,第六章。

  2

有关 ant 风格表达式的信息,请参见第三章。

  3

https://spring.io/projects/spring-security

  4

https://shiro.apache.org

  5

http://www.hdiv.org

  6

http://www.owasp.org

  7

http://www.hdiv.org

 

七、REST 和 AJAX

到目前为止,我们一直在构建一个经典的 web 应用:我们向服务器发送一个请求,服务器处理这个请求,我们呈现结果并显示给客户端。然而,在过去的十年中,我们构建 web 应用的方式发生了很大的变化。现在我们有了 JavaScript 和 JSON/XML,它们允许基于 AJAX 的 web 应用,并将越来越多的行为推送到客户端,包括验证、渲染部分屏幕等等。

本章从 REST 1 (表述性状态转移)开始,这是一种架构风格,影响了开发人员对 web 资源的思考和处理。稍后,我们将讨论 AJAX,并将其与 REST 结合起来考虑。

本章的第二部分讲述了文件上传。您将学习如何使用 Spring 框架上传文件,并在我们的控制器中处理任务。然而,在我们进入这个之前,让我们看看休息。

表征状态转移(REST)

本节简要解释 REST 的主题,它本质上有两个部分:首先,资源和如何识别它们,以及我们如何操作或使用这些资源。REST 在 2000 年由罗伊·托马斯·菲尔丁在一篇题为“架构风格和基于网络的软件架构的设计”的论文中描述 2 它描述了如何使用 HTTP 协议和该协议提供的特性来处理资源。

识别资源

第章 4 简要讨论了一个 URL(统一资源定位符) 3 的组成部分。对于休息,这不会改变;但是,URL 很重要,因为它指向一个唯一的资源。这就是为什么当谈到 REST APIs 时,URL 被替换为 URI(统一资源标识符)。 4 表 7-1 给出了几个资源位置的例子。

表 7-1

资源定位器

|

上呼吸道感染

|

描述

| | --- | --- | | http://www.example.com/books | 书单 | | http://www.example.com/books/9781430241553 | 书号为 978-1-4302-4155-3 的详细资料 |

URIs 在操作中使用他们所描述的对象的复数是一种最佳做法。

在 REST 中,这完全是关于资源的一个表示,因此 URI 很重要。它给我们一个实际资源的位置(网页,网页上的图像,mp3 文件,或者其他)。我们在 web 浏览器中看到的不是实际的资源,而是该资源的表示。下一节将解释我们如何使用这个资源位置进行修改、删除等操作。)那个资源。

使用资源

HTTP 协议指定了几种方法(HTTP 方法) 5 来处理来自我们应用的信息。表 7-2 给出了这些方法的概述。

表 7-2

可用的 HTTP 方法

|

方法

|

描述

| | --- | --- | | 得到 | 从给定位置检索资源(例如,一本书)的表示形式。 | | 头 | 类似于 GET 但是,并不返回实际的表示形式,而只返回属于该资源的标头。有助于确定是否发生了变化以及是否需要发送 GET 请求。 | | 放 | 在服务器上存储资源(书籍)的表示形式。通常,资源有一个唯一的标识符。当 PUT 请求的主体包含带有标识符的对象时,主体的内容会更新带有指定 ID 的现有资源。如果 PUT 请求主体没有标识符,则会创建一个新资源。如果用户多次发出相同的 PUT 请求,结果应该总是相同的。 | | 邮政 | 类似于 PUT,但服务器控制创建资源或启动操作。帖子对于创建新资源(比如用户)或触发动作(在我们的示例中,将一本书添加到购物车)非常有用。多次发出相同的请求不会产生相同的结果(也就是说,图书会被添加两次)。这就是为什么成功的 POST 请求的结果通常是重定向到包含已创建资源的页面。 6 | | 删除 | 删除寻址的资源(在这种情况下,删除书)。 | | 选择 | 确定与此资源或服务器功能相关的选项。(例如,支持的 HTTP 方法、是否启用了安全性、任何版本等等)。 | | 微量 | 沿着到目标资源的路径执行消息环回测试,提供有用的调试机制。 | | 修补 | 旨在对现有资源进行部分更改。当带宽有限时,修补程序对于更新资源非常有用。这种方法既不安全也不幂等。 |

TRACE 和 OPTIONS 方法不在 REST 中使用,但为了完整起见在这里提到了它们。

PATCH 方法不在 REST 中使用,因为对于 API 来说,使用 PUT 方法自动应用补丁是一个很好的实践。

在“识别资源”一节中,我们提到了 URI 是如何指向资源的。如果我们将 REST 与表 7-1 中的资源结合起来,我们可以使用它们,如表 7-3 中所述。

表 7-3

应用接口

|

统一资源定位器

|

方法

|

描述

| | --- | --- | --- | | http://www.example.com/books | 得到 | 得到一份书单。 | | http://www.example.com/books | 放 | 更新书单。 | | http://www.example.com/books | 邮政 | 创建新的图书列表。 | | http://www.example.com/books | 删除 | 删除所有的书。 | | http://www.example.com/books/9781430241553 | 得到 | 获取 ISBN 为 978-1-4302-4155-3 的图书的代表。 | | http://www.example.com/books/9781430241553 | 放 | 用 ISBN 978-1-4302-4155-3 更新这本书。 | | http://www.example.com/books/9781430241553 | 邮政 | 创建 ISBN 为 978-1-4302-4155-3 的图书。 | | http://www.example.com/books/9781430241553 | 删除 | 删除 ISBN 为 978-1-4302-4155-3 的书。 |

HTTP 方法的列表比大多数 web 浏览器支持的要大。通常,它们只支持 GET 和 POST 方法,不支持其他方法。为了在传统的 web 应用中使用不同的方法,我们需要使用一种变通方法;对于这个,Spring MVC 有HiddenHttpMethodFilter

HiddenHttpMethodFilter

org.springframework.web.filter. HiddenHttpMethodFilter组件将 POST 请求屏蔽为另一种指定类型的请求。它使用请求参数来确定对传入的请求使用哪种方法。这适用于使用 Spring 标签库和百里香叶创建的表单。默认情况下,它使用名为_method的请求参数;然而,这个名称可以通过扩展HiddenHttpMethodFilter类并覆盖setMethodParam(String)来为参数设置一个不同的名称来配置。

通过确保请求参数存在,可以将 POST 请求“转换”为 PUT 或 DELETE 然后请求被包装在HttpMethodRequestWrapper(它是HiddenHttpMethodFilter的内部类)中。GET 请求按原样处理;它不会被转换成另一种类型的请求。这是因为与其他类型不同,GET 请求将所有参数都编码在 URL 中。相比之下,POST 和 PUT 请求将它们编码在请求体中。

为我们的 web 应用启用HiddenHttpMethodFilter需要向application.properties添加一个属性(参见清单 7-1 )。

spring.mvc.hiddenmethod.filter.enabled=true

Listing 7-1Enable HiddenHttpMethodFilter

启用过滤器后,我们需要修改我们的帐户页面。打开account.jsp文件,确保有一个名为_method,和值为PUT的隐藏字段。清单 7-2 显示页面的开始;如您所见,表单的开头定义了这个隐藏字段。

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>

<form:form method="POST" modelAttribute="account" id="accountForm">
    <!-- filter to transform POST method in PUT -->
    <input type="hidden" name="_method" value="PUT" />

    <fieldset>
    <legend><spring:message code="account.personal"/></legend>

// Remainder of page omitted

Listing 7-2Account.jsp Heading

当提交页面时,HiddenHttpMethodFilter执行它的工作,将我们的 POST 请求转换成 PUT 请求。对于简单的应用,控制器可以使用相同的处理程序方法处理两种请求方法(参见清单 7-3 )。对于更复杂的应用,它们可以单独处理。

package com.apress.prospringmvc.bookstore.web.controller;

//Imports omitted

@Controller
@RequestMapping("/customer/account")
@SessionAttributes(types = Account.class)
public class AccountController {

@PostMapping
@PutMapping
public String update(@ModelAttribute Account account) {
    this.accountRepository.save(account);
    return "redirect:/customer/account";
}

// Other methods omitted

}

Listing 7-3AccountController Update Method

过滤器仍然是一种变通方法,使浏览器和普通表单的 REST 成为可能,如果我们选择对我们的网站使用渐进增强或优雅降级,这可能是有用的。渐进式增强意味着向基本页面添加丰富的行为,并首先确保我们的基本页面如我们所愿地工作。优雅的降级正好相反——我们开发一个丰富的网站,并试图确保整个网站仍然工作,即使某些功能不可用。

异步 JavaScript 和 XML (AJAX)

杰西·詹姆斯·加勒特在 2005 年创造了 AJAX 这个术语。AJAX 本身并不是一项技术。它是一系列技术的集合,共同为我们的 web 应用创造丰富的用户体验。AJAX 结合了以下技术。

  • 使用 HTML 和 CSS 实现基于标准的表示

  • 使用文档对象模型(DOM)进行动态显示和交互

  • 数据交换和操作(使用 XML 或 JSON)

  • 使用XMLHttpRequest进行异步数据检索

  • JavaScript 将所有这些整合在一起

虽然首字母缩写代表异步 JavaScript 和 XML ,但它通常与 JavaScript 对象符号(JSON)一起使用,在客户机和服务器之间传递数据。

由于 AJAX 已经使用了几年,所以有很多 JavaScript 框架和库可以让创建丰富的用户体验变得更加容易。对于 Spring MVC,您选择哪个 JavaScript 框架或库并不重要,讨论大量的 JavaScript 框架和库也超出了本书的范围。对于我们的例子,我们使用 jQuery 7 ,因为它是使用最广泛的库之一。要使用 jQuery,我们需要加载包含这个库的 JavaScript 文件。为此,我们修改 template.jsp 文件以包含 jQuery(参见清单 7-4 )。

<!DOCTYPE HTML>

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="tiles" uri="http://tiles.apache.org/tags-tiles" %>

<html>
<head>
    <meta charset="utf-8">
    <c:set var="titleKey">
        <tiles:getAsString name="title" />
    </c:set>

    <title> Bookstore |
      <spring:message code="${titleKey}" text="Your Home in Books"/>
    </title>

    <link rel="stylesheet" type="text/css"
      href="<c:url value="/resources/css/style.css"/>" >
    <script
        src="<c:url value="/resources/jquery/jquery-3.5.1.min.js"/>">
    </script>
</head>

//Body Omitted
</html>

Listing 7-4Modified template.jsp Header

如果你有一个良好和持续的互联网连接,你就不必下载jQuery;你可以直接链接公开的缩小版。

<script src="https://code.jquery.com/jquery-3.5.1.min.js"/>

这会将 jQuery JavaScript 库添加到所有页面中(如果添加到 commons 布局中);然而,就其本身而言,它并没有多大作用。我们仍然需要向页面添加逻辑,这意味着当用户与 HTML 元素交互时,调用现在可用的函数。在接下来的小节中,我们将 AJAX 行为添加到我们的示例应用中。我们从一个简单的表单提交开始,一路探索 Spring MVC 提供的用于 AJAX 的特性,以及它如何帮助我们构建 REST 应用。

向我们的应用添加 AJAX

由于 Spring MVC 的灵活性,很容易将 AJAX 行为添加到我们的应用中,并与 Spring MVC 很好地集成。在本节中,您将学习如何将表单提交更改为基于 AJAX 的表单提交(使用和不使用 JSON)。AJAX 更适合提交搜索表单,因为它可以防止页面完全重载。然而,表单提交并不是 AJAX 唯一可能的用途;它仅仅服务于我们的示例应用;还可以创建自动完成字段、自动字段/表单验证等等。

提交带有 HTML 结果 AJAX 表单

让我们看看我们的图书搜索页面,并将其转换成一个更动态的网页。我们首先将普通表单提交改为 AJAX 表单提交。打开search.jsp文件,将清单 7-5 中所示的脚本添加到表单之后或页面底部,以确保 HTML 代码被呈现并且 JS 可以操作它。

<script>
  $('#bookSearchForm').submit(function(evt){
    evt.preventDefault();
    formData = $('#bookSearchForm').serialize();
    $.ajax({
      url: $('#bookSearchForm').action,
      type: 'GET',
      data: formData
    });
  });
</script>

Listing 7-5Book Search Page with AJAX Form Submit

这个脚本代替了实际的表单提交。它首先阻止实际的提交,然后构建一个 AJAX 请求,将数据传递给服务器。如果我们现在重新部署我们的应用,导航到我们的图书搜索页面,然后按 Submit,看起来什么也没发生。至少我们在屏幕上看不到任何变化。如果我们调试我们的应用,我们可以看到请求到达服务器和搜索被发出。那么为什么结果没有被渲染呢?

在本节的开始,我们提到 AJAX 是一个技术集合,其中之一是使用XMLHttpRequest的异步数据检索。这也是我们目前的问题所在。我们向服务器发送一个请求,但是我们没有包含任何处理来自服务器的响应的内容。

清单 7-6 显示了修改后的脚本(见突出显示的部分)来呈现返回的页面。

<script>
  $('#bookSearchForm').submit(function(evt) {
    evt.preventDefault();
    formData = $('#bookSearchForm').serialize();
    $.ajax({
      url: $('#bookSearchForm').action,
      type: 'GET',
      data: formData,
      success: function(html) {
        resultTable = $('#bookSearchResults', html);
        $('#bookSearchResults').html(resultTable);
      }
    });
  });
</script>

Listing 7-6Book Search Page with Success Handler

我们为这个脚本添加了成功处理程序,它的作用是呈现我们从服务器收到的结果。结果是正常呈现的整个页面。我们选择带有结果的表,并用检测到的表替换屏幕上的当前表。如果重新部署应用并发出搜索命令,页面将再次工作。

提交带有 JSON 结果 AJAX 表单

上一节展示了一个基本的 AJAX 表单提交,我们从中获得了 HTML。我们将数据发送到服务器,并获得一个 HTML 页面片段页面进行渲染。另一种方法是获取我们需要在客户端渲染和处理的数据。这使 JavaScript 代码有点复杂,但是我们也需要扩展我们的服务器端。我们需要一个额外的方法将 JSON 编码的数据返回给客户端(参见清单 7-7 )。

package com.apress.prospringmvc.bookstore.web.controller;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.ResponseBody;

// Other imports omitted

@Controller
public class BookSearchController {

// Other methods omitted

    @GetMapping(value = "/book/search", produces = MediaType.APPLICATION_JSON_VALUE )
    public @ResponseBody Collection<Book> listJSON(
        @ModelAttribute("bookSearchCriteria") BookSearchCriteria criteria) {
        return this.bookstoreService.findBooks(criteria);
    }
}

Listing 7-7BookSearch Controller with JSON Producing Method

该方法与同一控制器上的原始列表方法相同;然而,有两个重要的区别,这是突出的。第一个是,每当一个传入的请求指定它想要接收 JSON(通过设置Accept头,如第四章中所解释的)时,这个方法就会被调用。接下来,我们使用@ResponseBody注释来指示 Spring MVC 使用返回值作为响应的主体(参见第五章中的“支持的方法参数注释”一节)。使用org.springframework.http.converter.HttpMessageConverter<T>转换返回值。

当在类路径中找到 Jackson Java JSON 处理器 8 时,Spring MVC 会自动注册org.springframework.http.converter.json.MappingJackson2HttpMessageConverter

除了控制器之外,我们需要修改我们的 JavaScript 来指定我们想要从服务器接收 JSON。因为我们接收 JSON,所以我们需要使用 JSON 来替换我们的结果表的内容。在清单 7-8 中,您可以看到search.jsp文件的结果。

<script>
  $('#bookSearchForm').submit(function(evt){
  evt.preventDefault();
  formData = $('#bookSearchForm').serialize();
  $.ajax({
    url: $('#bookSearchForm').action,
    type: 'GET',
    dataType: 'json',
    data: formData,
    success: function(data){
      var content = '';
      var books = data;
      var baseDetailUrl = '<c:url value="/book/detail/"/>';
      var baseAddCartUrl = '<c:url value="/cart/add/" />';
      for (var i = 0; i<books.length; i++) {
        content += '<tr>';
        content += '<td><a href="'
         + baseDetailUrl + books[i].id+'">'
         + books[i].title+'</a</td>';
        content += '<td>'+books[i].description+'</td>';
        content += '<td>'+books[i].price+'</td>';
        content += '<td><a href="'+ baseAddCartUrl +books[i].id
          +'"><spring:message code="book.addtocart"/></a></td></tr>';
       }
       $('#bookSearchResults tbody').html(content);
     }
   });
  });
</script>

Listing 7-8Book Search Page with JSON Success Handler

当应用被重新部署并执行搜索时,我们的新方法被调用,JSON 被返回给客户机。客户端使用 JSON 对象创建一个新的表体,当表体创建后,它将替换当前的表体。

发送和接收 JSON

可以向服务器发送 JSON,也可以从服务器接收 JSON。发送 JSON 的优点是它比 XML 更紧凑,发送和处理(客户端和服务器端)都更快。一个缺点是,您需要一些手工编码来准备发送到服务器的 JSON,特别是在重用现有对象时(正如您在我们的示例中看到的)。

为了实现这一点,我们需要修改我们的客户端 JavaScript,并对我们的请求处理方法进行一些更改。控制器需要知道我们没有使用普通的模型属性,而是想要使用 JSON 作为我们的BookSearchCriteria。为了实现这一点,我们用@RequestBody注释我们的方法参数;它类似于@ResponseBody,但是针对传入的请求。为了明确 handler 方法需要某种类型的数据输入,可以将consumes属性添加到@PostMapping注释中。

清单 7-9 强调了需要对控制器进行的更改。

package com.apress.prospringmvc.bookstore.web.controller;

import org.springframework.web.bind.annotation.RequestBody;

// Other imports omitted

@Controller
public class BookSearchController {

// Other methods omitted

    @PostMapping(value = "/book/search", produces = MediaType.APPLICATION_JSON_VALUE
        ,consumes = MediaType.APPLICATION_JSON_VALUE)
    public @ResponseBody Collection<Book> listJSON(
        @RequestBody BookSearchCriteria criteria) {
        return this.bookstoreService.findBooks(criteria);
    }
}

Listing 7-9BookSearchController with RequestBody Annotation

请注意从 GET 请求到 POST 请求的变化;这是必需的,因为我们使用了@RequestBody注释。注释在请求的主体上操作,但是 GET 请求通常将数据编码在 URL 中而不是主体中。

使用@RequestBody 和@ResponseBody 注释时,用于表示/构建资源的所有内容都应该是请求的一部分。Spring MVC 将请求体反序列化为在 handler 方法中处理的 Java 对象,并将返回的结果序列化为由 produces 属性指定的类型。

修改了控制器之后,我们还需要再次修改 JavaScript。我们需要将表单中的数据转换成可以发送给服务器的 JSON 字符串。清单 7-10 显示了需要更改的内容。

<script>
  $('#bookSearchForm').submit(function(evt){
    evt.preventDefault();
    var title = $('#title').val();
    var category = $('#category').val();
    var json = { "title" : title, "category" : { "id" : category}};
    $.ajax({
      url: $('#bookSearchForm').action,
      type: 'POST',
      dataType: 'json',
      contentType: 'application/json',
      data: JSON.stringify(json),
      success: function(books) {
        var content = '';
        var baseDetailUrl = '<c:url value="/book/detail/"/>';
        var baseAddCartUrl = '<c:url value="/cart/add/" />';
        for (var i = 0; i<books.length; i++) {
         content += '<tr>';
         content += '<td><a href="'+ baseDetailUrl + books[i].id+'">'
          +books[i].title+'</a></td>';
         content += '<td>'+books[i].description+'</td>';
         content += '<td>'+books[i].price+'</td>';
         content += '<td><a href="'+ baseAddCartUrl +books[i].id
         +'"><spring:message code="book.addtocart"/></a></td></tr>';
        }
        $('#bookSearchResults tbody').html(content);
      }
    });
  });
</script>

Listing 7-10Book Search Page with JSON Form Submit

正如您所看到的,添加了contentType属性来将表单数据转换成 JSON 对象,并且请求的类型被更改为 POST。这是必要的,因为内容是请求的主体,而 GET 请求没有主体,而是将所有内容编码到 URL 中。

data属性值用于将 JSON 对象转换成 JSON 字符串,该字符串可以发送给服务器。其他一切都保持不变。

如果应用被重新部署,我们发出一个搜索,搜索结果再次显示给用户。

jQuery 有一个插件架构,有几个插件可以使表单到 JSON (Dream.js,9JsonView10)的转换更容易。我们选择不使用插件,以避免关注插件本身。

结合 AJAX 和 REST

我们简要介绍了 REST,也谈到了 AJAX,但是我们分别介绍了每个主题。但是,这两者结合起来也是非常容易的。在 REST 部分,我们将帐户更新表单更改为带有 PUT 请求的表单,但这是使用 POST 的模拟。使用我们使用的 JavaScript 库,可以创建一个真正的 PUT 请求,而不是作为 PUT 请求使用的 POST 请求。

要发出和处理 PUT 请求,必须做两件事:AJAX 必须提交表单作为 PUT 请求,我们需要准备服务器来处理 PUT 请求。POST 和 PUT 请求之间存在一些差异。一个主要的区别是 POST 请求必须有可用的表单数据(规范要求这样),但是 PUT 请求不是这样。Spring 提供了org.springframework.web.filter.FormContentFilter,在这里可以帮到我们。

当检测到内容类型为application/x-www-form-urlencoded的 PUT 请求时,过滤器开始工作。它解析传入请求的主体(委托给org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter),结果是一个可以像普通表单参数一样使用的参数映射。要在 Spring Boot 应用中启用过滤器,必须在应用配置中将spring.mvc.formcontent.filter.enabled属性设置为true(参见清单 7-11 )。

spring.mvc.formcontent.filter.enabled=true

Listing 7-11Enable the FormContentFilter

接下来,我们需要向我们的account.jsp file添加一些 JavaScript。它类似于我们第一次添加到图书搜索页面的脚本,有一个主要区别:我们现在使用 PUT 而不是 GET。参见清单 7-12 中添加在表单之后或页面末尾的 JavaScript。控制器方法(参见清单 7-3 )保持不变,因为它仍然是控制器的一个 PUT 请求。

<script>
  $('#accountForm').submit(function(evt){
   evt.preventDefault();
   formData = $('#accountForm').serialize();
     $.ajax({
       url: $('#accountForm').action,
       type: 'PUT',
       data: formData
     });
  });
</script>

Listing 7-12Account Page PUT AJAX Form Submit

渐进增强

我们应用 AJAX 特性的方式是一种叫做渐进增强的技术。这意味着构建一个简单的 web 页面,然后用 JavaScript 为页面添加动态的丰富行为。

相反的方法也是可能的;这种技术被称为优雅退化,这意味着我们从一个页面开始,包含我们想要的所有行为。根据浏览器提供的功能,我们缩小了所使用的丰富行为。

现在的趋势是使用渐进增强,因为它更容易构建和维护。它还有一个优势,我们可以根据连接到我们应用的设备功能进行增强(iPhone 与安装了 Internet Explorer 9 的 Windows 7 PC 相比,具有不同的功能)。

处理文件上传

RFC 1867 中定义了 HTML 格式的 HTTP 文件上传或基于表单的文件上传。 11 在向表单添加一个带有类型文件的 HTML 输入字段并将编码设置为multipart/form-data后,浏览器可以将文本和/或二进制文件作为 POST 请求的一部分发送到服务器。

为了处理文件上传,我们首先需要注册org.springframework.web.multipart.MultipartResolver。开箱即用,Spring 提供了两种处理文件上传的方式。第一个是 Servlet API 规范中描述的多部分支持,第二个是通过使用 Apache 的 Commons file upload12项目提供的特性。

Spring 框架提供了两种实现。

  • org.springframework.web.multipart.support.StandardServletMultipartResolver

  • org.springframework.web.multipart.commons.CommonsMultipartResolver

第一个实现可以在 Servlet 上启用了 multipart 的 Servlet API 环境中使用,第二个实现使用 Commons FileUpload 库。

对于文件上传的实际处理,我们需要修改控制器。这些修改大多独立于所使用的文件上传技术。Spring 提供了几个抽象来处理文件上传。

  • 我们可以编写一个请求处理方法,采用类型为org.springframework.web.multipart.MultipartFile(或Collection<MultipartFile>)的参数,或者我们可以使用org.springframework.web.multipart.MultipartHttpServletRequest并自己检索文件。

  • 当我们处于 Servlet API 环境中并使用多部分解析支持时。我们也可以使用javax.servlet.http.Part接口来获取文件。

文件上传的最后一种方式是用org.springframework.web.bind.annotation.RequestPart标注方法参数(见第四章)。当放上前面描述的任何东西时,Spring 使用类型转换系统来转换文件的内容。

我们首先讨论两种不同策略的配置。之后,我们看看如何在控制器内部处理文件上传。

配置

启用文件上传的第一步是配置我们的环境。由于 Spring 提供了两种现成的不同技术,每种技术都需要一组不同的配置项。我们看一下 Servlet API 多部分支持和 Commons FileUpload。

配置 Servlet API 文件上传

默认情况下,Spring Boot 支持 Servlet API 上传文件的方式,因为这在 Servlet 容器中总是可用的。可以通过在应用配置中将spring.servlet.multipart.enabled设置为true / false来启用或禁用它(参见清单 7-13 )。

spring.servlet.multipart.enabled=true

Listing 7-13Explicitly Enable Multipart File Upload

当使用 Spring vanilla 配置时,org.springframework.web.servlet.DispatcherServlet上的多部分解析的第一步是向 XML 配置添加一个多部分配置部分,或者在我们的org.springframework.web.WebApplicationInitializer实现中包含javax.servlet.MultipartConfigElement

在 Spring Boot web 应用中,其他属性可以配置最大文件大小、请求大小等等(参见表 7-4 )。

表 7-4

文件上传的 Spring Boot 属性

|

财产

|

描述

|

默认

| | --- | --- | --- | | spring.servlet.multipart.enabled | 启用或禁用文件上传 | true | | spring.servlet.multipart.location | 上传文件的临时位置,如果未指定,将使用临时目录。 |   | | spring.servlet.multipart.max-file-size | 要上传的文件的最大大小 | 1 兆字节 | | spring.servlet.multipart.max-request-size | 当请求包含多个文件时,上载请求的最大大小 | 10 兆 | | spring.servlet.multipart.file-size-threshold | 在写入磁盘之前,有多少文件保留在内存中 | 0 字节 | | spring.servlet.multipart.resolve-lazily | 文件应该立即解析/解析还是延迟到作为参数访问时再解析/解析 | false |

配置 Apache Commons 文件上传

要在 Spring Boot 使用 Commons FileUpload 支持,需要注册CommonsMultipartResolver来启用文件上传(参见清单 7-14 )。像spring.servlet.multipart.location,这样的配置中使用的参数不会自动应用于 Commons FileUpload 配置——尽管我们可以重用配置属性来进行手动配置!

package com.apress.prospringmvc.bookstore.web.config;

import org.springframework.web.multipart.MultipartResolver;
import org.springframework.web.multipart.commons.CommonsMultipartResolver;

// Other imports omitted

@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {

    @Bean
    public MultipartResolver multipartResolver(MultipartProperties multipartProperties) {
        CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver();
        multipartResolver.setMaxUploadSize(multipartProperties.getMaxFileSize().toBytes());
        return multipartResolver;
    }
}

Listing 7-14Configuration with CommonsMultipartResolver

文件上传的请求处理方法

除了配置上传,我们还需要一个表单页面来提交文件。为此,我们需要创建一个编码设置为multipart/form-data的表单(参见清单 7-15 )。

如果我们改变可用的不同技术,这种形式不会改变;只有处理上传的方式发生了变化。当使用 file 类型添加 input 元素时,给它一个名称是很重要的,特别是当我们进行单个文件上传时。从请求中检索文件也需要这个名称。

<form id="orderForm"
      action="<c:url value="/order/upload"/>"
      method="POST"
      enctype="multipart/form-data">
    <fieldset>
        <legend>Upload order</legend>
        <input type="file" placeholder="Select File"
                id="order" name="order"/>
        <button id="upload"><spring:message code="button.upload"/></button>
    </fieldset>
</form>

Listing 7-15Upload Order Form for Account Page

我们将这个表单添加到已经存在的表单之后的account.jsp文件中。当我们现在渲染账户页面时,它看起来如图 7-1 。

img/300017_2_En_7_Fig1_HTML.jpg

图 7-1

带有文件上传的帐户页面

在下面的章节中,我们将探讨在控制器中处理文件上传的不同方式。大多数方法可以在两种不同的文件上传技术之间移植;然而,最后一个只有在使用 Servlet API 多部分支持时才可用。上传文件时,每种不同的请求处理方法都有相同的输出;它打印上传文件的名称和文件的大小,如图 7-2 所示。

img/300017_2_En_7_Fig2_HTML.jpg

图 7-2

样本文件上传输出

用多部分文件编写请求处理方法

当编写请求处理方法时,如果我们想上传文件并使用 Spring 的多部分文件抽象,我们需要创建一个方法,对其进行注释,并确保它将MultipartFile作为方法参数。当上传了多个同名文件时,我们也可以接收一个Collection<MultipartFile>的文件,而不是一个元素。清单 7-16 展示了一个控制器,它有一个可以使用这种技术处理文件上传的方法。

package com.apress.prospringmvc.bookstore.web.controller;

// Other imports omitted
import org.springframework.web.multipart.MultipartFile;

@Controller
public class UploadOrderController {

    private Logger logger =
        LoggerFactory.getLogger(UploadOrderController.class);

    @PostMapping(path = "/order/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String handleUpload(final MultipartFile order) {
        logFile(order.getOriginalFilename(), order.getSize());
        return "redirect:/customer/account";
    }

    private void logFile(String name, long size) {
        this.logger.info("Received order: {}, size {}", name, size);
    }
}

Listing 7-16UploadOrderController with MultipartFile

使用 MultipartHttpServletRequest 处理文件上载

除了直接访问文件,也可以使用MultipartHttpServletRequest来访问多部分文件(见清单 7-17 )。访问多部分文件的方法在org.springframework.web.multipart.MultipartRequest超级接口中定义。

package com.apress.prospringmvc.bookstore.web.controller;

// Other imports omitted

import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;

@Controller
public class UploadOrderController {

    private Logger logger =
      LoggerFactory.getLogger(UploadOrderController.class);

    @PostMapping("/order/upload")
    public String handleUpload(final MultipartHttpServletRequest request) {
        Map<String, MultipartFile> files = request.getFileMap();
        for (MultipartFile file : files.values()) {
            logFile(file.getOriginalFilename(), file.getSize());
        }

        return "redirect:/customer/account";
    }

    private void logFile(String name, long size) {
        this.logger.info("Received order: {}, size {}", name, size);
    }
}

Listing 7-17UploadOrderController with MultipartHttpServletRequest

使用表单对象处理上传

除了直接处理上传,我们还可以让它成为表单对象的一部分(模型属性)。如果上传是包含更多字段的表单的一部分,这可能会很方便(如我们的客户帐户页面,包括一张图片)。为此,我们需要创建一个可以用作表单对象的类,它的属性类型为MultipartFile(参见清单 7-18 )。

package com.apress.prospringmvc.bookstore.web;

import org.springframework.web.multipart.MultipartFile;

public class UploadOrderForm {

private MultipartFile order;

    public MultipartFile getOrder() {
        return this.order;
    }

    public void setOrder(MultipartFile order) {
        this.order = order;
    }
}

Listing 7-18UploadOrderForm Class

我们需要修改控制器,将表单作为方法参数(参见清单 7-19 )。

package com.apress.prospringmvc.bookstore.web.controller;

// Other imports omitted
import com.apress.prospringmvc.bookstore.web.UploadOrderForm;

@Controller
public class UploadOrderController {

    private Logger logger =
        LoggerFactory.getLogger(UploadOrderController.class);

    @PostMapping(path = "/order/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String handleUpload(UploadOrderForm form) {
        logFile(form.getOrder().getOriginalFilename(), form.getOrder().getSize());
        return "redirect:/customer/account";
    }

    private void logFile(String name, long size) {
        this.logger.info("Received order: {}, size {}", name, size);
    }
}

Listing 7-19UploadOrderController with UploadOrderForm Object

使用 Servlet API 编写请求处理方法

在严格的 Servlet API 环境中,我们可以使用标准的javax.servlet.http.Part接口来访问上传的文件。我们简单地创建一个方法,将Part作为一个参数(参见清单 7-20 )。我们需要创建一个方法,注释它,并给它一个方法参数。这种技术只在 Servlet API 环境中有效(所以如果你正在使用 Netty 服务器编写一个反应式应用,这种方法是不可用的),而且无论如何都比使用MultipartFile参数具有可移植性。

package com.apress.prospringmvc.bookstore.web.controller;

// Other imports omitted
import javax.servlet.http.Part;

@Controller
public class UploadOrderController {

    private Logger logger =
       LoggerFactory.getLogger(UploadOrderController.class);

    @PostMapping(path = "/order/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String handleUpload(final Part order) {
        logFile(order.getName(), order.getSize());
        return "redirect:/customer/account";
    }

    private void logFile(String name, long size) {
        this.logger.info("Received order: {}, size {}", name, size);
    }
}

Listing 7-20UploadOrderController with Part

异常处理

上传文件也可能会失败。文件可能太大而无法处理(大于配置的最大文件大小),或者我们的磁盘可能已满。失败的原因有很多。如果可能的话,我们希望处理错误并向用户显示一个漂亮的错误页面。我们可以使用异常处理(如第六章中所解释的)来处理异常并显示一个漂亮的错误页面。当异常发生时,多部分支持抛出 org。springframework.web.multipart.MultipartException,我们可以用这个异常来显示一个错误页面。

摘要

这一章讲述了罗伊·托马斯·菲尔丁解释的表述性状态转移(REST)。您了解了如何配置 Spring MVC 来促进 REST 使用的不同方法。我们讨论了HiddenHttpMethodFilter的配置和该滤波器的使用案例。

接下来,我们简要解释了异步 JavaScript 和 XML (AJAX ),以及我们如何在客户端使用它们并让控制器对这些请求做出响应。虽然 AJAX 最初是关于 XML 的,但现在是关于 JSON 的。我们通过使用@RequestBody@ResponseBody注释探索了 Spring MVC 提供的 JSON 特性。

本章的最后一部分介绍了如何将文件上传到我们的应用中。为此,我们研究了 Servlet API 多部分支持和 Commons FileUpload 支持所需的配置。然后,我们探索了编写能够处理文件上传的控制器的不同方法。

Footnotes 1

www。ics。uci。edu/~菲尔丁/酒馆/论文/休息 _ 拱门 _ 风格。htm

  2

www。ics。uci。edu/~菲尔丁/酒馆/论文/ top。htm

  3

en . Wikipedia . org/wiki/URL

  4

en。维基百科。org/ wiki/ Uniform_ Resource_ 标识符

  5

www。w3。org/Protocols/RFC 2616/RFC 2616-sec 9。html

  6

en . Wikipedia . org/wiki/post/重定向/ Get

  7

jquery。组织〔??〕

  8

github。com/ FasterXML

  9

github。com/ adleroliveira/ dreamjs

  10

github。com/ yesmeck/ jquery-jsonview

  11

www。ietf 的。org/ rfc/ rfc1867。txt〔??〕

  12

commons . Apache . org/proper/commons-file upload/