spring实战 Spring MVC的高级技术

357 阅读28分钟

Spring MVC的高级技术

Spring MVC配置的替代方案

扩展AbstractAnnotationConfigDispatcherServlet-Initializer快速搭建了Spring MVC环境。在这个便利的基础类中,假设我们需要基本的DispatcherServlet和ContextLoaderListener环境,并且Spring配置是使用Java的,而不是XML。

除了DispatcherServlet以外,我们可能还需要额外的Servlet和Filter;我们可能还需要对DispatcherServlet本身做一些额外的配置;或者,如果我们需要将应用部署到Servlet 3.0之前的容器中,那么还需要将DispatcherServlet配置到传统的web.xml中。

自定义DispatcherServlet配置

虽然从下面程序的外观上不一定能够看得出来,但是Abstract-AnnotationConfigDispatcherServletInitializer所完成的事情其实比看上去要多。在SpittrWebAppInitializer中我们所编写的三个方法仅仅是必须要重载的abstract方法。但实际上还有更多的方法可以进行重载,从而实现额外的配置。

此类的方法之一就是customizeRegistration()。在AbstractAnnotation-ConfigDispatcherServletInitializer将DispatcherServlet注册到Servlet容器中之后,就会调用customizeRegistration(),并将Servlet注册后得到的Registration.Dynamic传递进来。通过重载customizeRegistration()方法,我们可以对DispatcherServlet进行额外的配置。

例如,我们将会看到如何在Spring MVC中处理multipart请求和文件上传。如果计划使用Servlet 3.0对multipart 配置的支持,那么需要使用DispatcherServlet的registration来启用multipart请求。我们可以重载customizeRegistration()方法来设置MultipartConfigElement,如下所示:

@Override
prtected void customizeRegistration(Dynamic registration) {
    registration.setMultipartConfig(new MultipartConfigElement("/tmp/spittr/uploads"));
}

借助customizeRegistration()方法中的ServletRegistration.Dynamic,我们能够完成多项任务,包括通过调用setLoadOnStartup()设置load-on-startup优先级,通过setInitParameter()设置初始化参数,通过调用setMultipartConfig()配置Servlet 3.0对multipart的支持。在前面的样例中,我们设置了对multipart的支持,将上传文件的临时存储目录设置在“/tmp/spittr/uploads”中。

添加其他的Servlet和Filter

基于Java的初始化器(initializer)的一个好处就在于我们可以定义任意数量的初始化器类。因此,如果想往Web容器中注册其他组件的话,只需创建一个新的初始化器就可以了。最简单的方式就是实现Spring的WebApplicationInitializer接口。

如下的程序展现了如何创建WebApplicationInitializer实现并注册一个Servlet:

package com.myapp.config;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRegistration.Dynameic;
import org.springframework.web.WebApplicationInitializer;
import com.myapp.MyServlet;

public class MyServletInitializer implements WebApplicationInitializer {
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        Dynamic myServlet = servletContext.addServlet("myServlet", MyServlet.class);
        myServlet.addMapping("/custom/**");
    }
}

上述程序是相当基础的Servlet注册初始化器类。它注册了一个Servlet并将其映射到一个路径上。我们也可以通过这种方式来手动注册DispatcherServlet。(但这并没有必要,因为AbstractAnnotationConfigDispatcherServletInitializer没用太多代码就将这项任务完成得很漂亮。)

还可以创建新的WebApplicationInitializer实现来注册Listener和Filter:

@Override
public void onStartup(ServletContext servletContext) throws ServletException {
    javax.servlet.FilterRegistration.Dynamic filter = servletContext.addFilter("myFilter", MyFilter.class);
    filter.addMappingForUrlPatterns(null, false, "/custom/*");
}

如果要将应用部署到支持Servlet 3.0的容器中,那么WebApplicationInitializer提供了一种通用的方式,实现在Java中注册Servlet、 Filter和Listener。不过,如果你只是注册Filter,并且该Filter只会映射到DispatcherServlet上的话,那么在AbstractAnnotationConfigDispatcherServletInitializer中还有一种快捷方式。

为了注册Filter并将其映射到DispatcherServlet,所需要做的仅仅是重载AbstractAnnotationConfigDispatcherServletInitializer的getServlet-Filters()方法。例如,在如下的代码中,重载了AbstractAnnotationConfig-DispatcherServletInitializer的getServletFilters()方法以注册Filter:

@Override
protected Filter[] getServletFilters() {
    return new Filter[] {
        new MyFilter();
    }
}

可以看到,这个方法返回的是一个javax.servlet.Filter的数组。在这里它只返回了一个Filter,但它实际上可以返回任意数量的Filter。在这里没有必要声明它的映射路径,getServletFilters()方法返回的所有Filter都会映射到DispatcherServlet上。

如果要将应用部署到Servlet 3.0容器中,那么Spring提供了多种方式来注册Servlet(包括DispatcherServlet)、Filter和Listener,而不必创建web.xml文件。但是,如果不想采取以上所述方案的话,也是可以的。假设需要将应用部署到不支持Servlet 3.0的容器中(或者只是希望使用web.xml文件),那么完全可以按照传统的方式,通过web.xml配置Spring MVC。

在web.xml中声明DispatcherServlet

在典型的Spring MVC应用中,需要DispatcherServlet和Context-Loader Listener。AbstractAnnotationConfigDispatcherServletInitializer会自动注册它们,但是如果需要在web.xml中注册的话,那就需要自己来完成这项任务了。

如下是一个基本的web.xml文件,它按照传统的方式搭建了DispatcherServlet和ContextLoaderListener:

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee"
         xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" version="2.5">
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/spring/root-context.xml</param-value>
    </context-param>
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
    <servlet>
        <servlet-name>appServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>appServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>
</web-app>

就像曾经介绍过的,ContextLoaderListener和DispatcherServlet各自都会加载一个Spring应用上下文。上下文参数contextConfigLocation指定了一个XML文件的地址,这个文件定义了根应用上下文,它会被ContextLoaderListener加载。如上述程序所示,根上下文会从“/WEB-INF/spring/root-context.xml”中加载bean定义。

DispatcherServlet会根据Servlet的名字找到一个文件,并基于该文件加载应用上下文。在上述程序中,Servlet的名字是appServlet,因此DispatcherServlet会从“/WEB-INF/appServlet-context.xml”文件中加载其应用上下文。

如果希望指定DispatcherServlet配置文件的位置的话,那么可以在Servlet上指定一个contextConfigLocation初始化参数。例如,如下的配置中,DispatcherServlet会从“/WEB-INF/spring/appServlet/servlet-context.xml”加载它的bean:

<servlet>
        <servlet-name>springServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>/WEB-INF/spring/appServlet/servlet-context.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

当然,上面阐述的都是如何让DispatcherServlet和ContextLoaderListener从XML中加载各自的应用上下文。但是,这里大部分内容中,我们都更倾向于使用Java配置而不是XML配置。因此,我们需要让Spring MVC在启动的时候,从带有@Configuration注解的类上加载配置。

要在Spring MVC中使用基于Java的配置,需要告诉DispatcherServlet和ContextLoaderListener使用AnnotationConfigWebApplicationContext,这是一个WebApplicationContext的实现类,它会加载Java配置类,而不是使用 XML。要实现这种配置,可以设置contextClass上下文参数以及DispatcherServlet的初始化参数。如下的程序清单展现了一个新的 web.xml,在这个文件中,它所搭建的Spring MVC使用基于Java的Spring配置:

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee"
         xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" version="2.5">
    <context-param>
        <param-name>contextClass</param-name>
        <param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
    </context-param>
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>com.habuma.spitter.config.RootConfig</param-value>
    </context-param>
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
    <servlet>
        <servlet-name>appServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextClass</param-name>
            <param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
        </init-param>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>com.habuma.spitter.config.WebConfig</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>appServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>
</web-app>

处理multipart形式的数据

Spittr应用在两个地方需要文件上传。当新用户注册应用的时候,想要他们能够上传一张图片,从而与他们的个人信息相关联。当用户提交新的Spittle时,除了文本消息以外,他们可能还会上传一张照片。

一般表单提交所形成的请求结果是很简单的,就是以“&”符分割的多个name-value对。例如,当在Spittr应用中提交注册表单时,请求会如下所示:

firstName=Charles&lastName=Xavier&email=professorx@40xmen.org&username=professor&password=letmein01

尽管这种编码形式很简单,并且对于典型的基于文本的表单提交也足够满足要求,但是对于传送二进制数据,如上传图片,就显得力不从心了。与之不同的是,multipart格式的数据会将一个表单拆分为多个部分(part),每个部分对应一个输入域。在一般的表单输入域中,它所对应的部分中会放置文本型数据,但是如果上传文件的话,它所对应的部分可以是二进制,下面展现了multipart的请求体:

------WebKitFormBoundaryqgkabn8IHJCuNmiW
Content-Disposition: form-data; name="firstName"

Charles
------WebKitFormBoundaryqgkabn8IHJCuNmiW
Content-Disposition: form-data; name="lastName"

Xavier
------WebKitFormBoundaryqgkabn8IHJCuNmiW
Content-Disposition: form-data; name="email"

charles@xmen.com
------WebKitFormBoundaryqgkabn8IHJCuNmiW
Content-Disposition: form-data; name="username"

professorx
------WebKitFormBoundaryqgkabn8IHJCuNmiW
Content-Disposition: form-data; name="password"

letmein01
------WebKitFormBoundaryqgkabn8IHJCuNmiW
Content-Disposition: form-data; name="profilePicture"; filename="me.jpg"
Content-Type: image/jpeg

	[[ Binary image data goes here]]
------WebKitFormBoundaryqgkabn8IHJCuNmiW--

在这个multipart的请求中,可以看到profilePicture部分与其他部分明显不同。除了其他内容以外,它还有自己的Content-Type头,表明它是一个JPEG图片。尽管不一定那么明显,但profilePicture部分的请求体是二进制数据,而不是简单的文本。

配置multipart解析器

DispatcherServlet并没有实现任何解析multipart请求数据的功能。它将该任务委托给了Spring中MultipartResolver策略接口的实现,通过这个实现类来解析multipart请求中的内容。从Spring 3.1开始,Spring内置了两个MultipartResolver的实现供我们选择:

  • CommonsMultipartResolver:使用Jakarta Commons FileUpload解析multipart请求
  • StandardServletMultipartResolver:依赖于Servlet 3.0对multipart请求的支持(始于Spring 3.1)

一般来讲,在这两者之间,StandardServletMultipartResolver可能会是优选的方案。它使用Servlet所提供的功能支持,并不需要依赖任何其他的项目。如果我们需要将应用部署到Servlet 3.0之前的容器中,或者还没有使用Spring 3.1或更高版本,那么可能就需要CommonsMultipartResolver了。

使用Servlet 3.0解析multipart请求

兼容Servlet 3.0的StandardServletMultipartResolver没有构造器参数,也没有要设置的属性。这样,在Spring应用上下文中,将其声明为bean就会非常简单,如下所示:

@Bean
public MultipartResolver multipartResolver() throws IOException {
    return new StandardServletMultipartResolver();
}

配置StandardServletMultipartResolver的限制条件的。只不过不是在Spring中配置StandardServletMultipartResolver,而是要在Servlet中指定multipart的配置。至少,必须要指定在文件上传的过程中,所写入的临时文件路径。如果不设定这个最基本配置的话,StandardServlet-MultipartResolver就无法正常工作。具体来讲,必须要在web.xml或Servlet初始化类中,将multipart的具体细节作为DispatcherServlet配置的一部分。

如果采用Servlet初始化类的方式来配置DispatcherServlet的话,这个初始化类应该已经实现了WebApplicationInitializer,那我们可以在Servlet registration上调用setMultipartConfig()方法,传入一个MultipartConfig-Element实例。如下是最基本的DispatcherServlet multipart配置,它将临时路径设置为“/tmp/spittr/uploads”:

DispatcherServlet ds = new DispatcherServlet();
Dynamic registration = context.addServlet("appServlet", ds);
registration.addMapping("/");
registration.setMultipartConfig(new MultipartConfigElement("/tmp/spittr/up;oads"));

如果配置DispatcherServlet的Servlet初始化类继承了Abstract AnnotationConfigDispatcherServletInitializer或AbstractDispatcher-ServletInitializer的话,那么不会直接创建DispatcherServlet实例并将其注册到Servlet上下文中。这样的话,将不会有对Dynamic Servlet registration的引用供使用了。但是,可以通过重载customizeRegistration()方法(它会得到一个Dynamic作为参数)来配置multipart的具体细节:

@Override
protected void custonizeRegistration(Dynamic registration) {
    registration.setMultipartConfig(new MultipartConfigElement("/tmp/spittr/uploads"));
}

目前为止,所使用是只有一个参数的MultipartConfigElement构造器,这个参数指定的是文件系统中的一个绝对目录,上传文件将会临时写入该目录中。但是,还可以通过其他的构造器来限制上传文件的大小。除了临时路径的位置,其他的构造器所能接受的参数如下:

  • 上传文件的最大容量(以字节为单位)。默认是没有限制的
  • 整个multipart请求的最大容量(以字节为单位),不会关心有多少个part以及每个part的大小。默认是没有限制的
  • 在上传的过程中,如果文件大小达到了一个指定最大容量(以字节为单位),将会写入到临时文件路径中。默认值为0,也就是所有上传的文件都会写入到磁盘上

假设想限制文件的大小不超过2MB,整个请求不超过4MB,而且所有的文件都要写到磁盘中。下面的代码使用MultipartConfigElement设置了这些临界值:

@Override
protect void customizeRegistration(Dynamic registration) {
    registration.setMultipartConfig(new MultipartConfigElement("tmp/spittr/uploads", 2097152, 4194304, 0));
}

如果使用更为传统的web.xml来配置MultipartConfigElement的话,那么可以使用中的元素,如下所示:

<servlet>
    <servlet-name>appServlet</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
    <multipart-config>
        <location>/tmp/spittr/uploads</location>
        <max-file-size>2097152</max-file-size>
        <max-request-size>4194304</max-request-size>
    </multipart-config>
</servlet>

的默认值与MultipartConfigElement相同。与MultipartConfigElement一样,必须要配置的是。

配置Jakarta Commons FileUpload multipart解析器

通常来讲,StandardServletMultipartResolver会是最佳的选择,但是如果我们需要将应用部署到非Servlet 3.0的容器中,那么就得需要替代的方案。如果喜欢的话,我们可以编写自己的MultipartResolver实现。不过,除非想要在处理multipart请求的时候执行特定的逻辑,否则的话,没有必要这样做。Spring内置了CommonsMultipartResolver,可以作为StandardServletMultipartResolver的替代方案。

将CommonsMultipartResolver声明为Spring bean的最简单方式如下:

@Bean
public MultipartResolver multipartResolver() {
    return new CommonsMultipartResolver();
}

与StandardServletMultipartResolver有所不同,CommonsMultipart-Resolver不会强制要求设置临时文件路径。默认情况下,这个路径就是Servlet容器的临时目录。不过,通过设置uploadTempDir属性,我们可以将其指定为一个不同的位置:

@Bean
public MultipartResolver multipartResolver() throws IOException {
    CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver();
    multipartResolver.setUploadTempDir(new FileSystemResource("/tmp/spittr/uploads"));
    return multipartResolver;
}

实际上,我们可以按照相同的方式指定其他的multipart上传细节,也就是设置CommonsMultipartResolver的属性。例如,如下的配置就等价于我们在前文通过MultipartConfigElement所配置的StandardServletMultipartResolver:

@Bean
public MultipartResolver multipartResolver() throws IOException {
    CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver();
    multipartResolver.setUploadTempDir(new FileSystemResource("/tmp/spittr/uploads"));
    multipartResolver.setMaxUploadSize(2097152);
    multipartResolver.setMaxInMemorySize(0);
    return multipartResolver;
}

在这里,将最大的文件容量设置为2MB,最大的内存大小设置为0字节。这两个属性直接对应于MultipartConfigElement的第二个和第四个构造器参数,表明不能上传超过2MB的文件,并且不管文件的大小如何,所有的文件都会写到磁盘中。但是与MultipartConfigElement有所不同,无法设定multipart请求整体的最大容量。

处理multipart请求

假设允许用户在注册Spittr应用的时候上传一张图片,那么我们需要修改表单,以允许用户选择要上传的图片,同时还需要修改SpitterController 中的processRegistration()方法来接收上传的图片。如下的代码片段来源于Thymeleaf注册表单视图(registrationForm.html),着重强调了表单所需的修改:

<form method="POST" th:object="${spitter}" enctype="multipart/form-data">
...
    <label>Profile Picture</label>
    <input type="file" name="profilePicture" accept="image/jpeg,image/png,image/gif" /><br/>
...
</form>
标签现在将enctype属性设置为multipart/form-data,这会告诉浏览器以multipart数据的形式提交表单,而不是以表单数据的形式进行提交。在multipart中,每个输入域都会对应一个part。

除了注册表单中已有的输入域,我们还添加了一个新的 input 域,其type为file。这能够让用户选择要上传的图片文件。accept属性用来将文件类型限制为JPEG、PNG以及GIF图片。根据其name属性,图片数据将会发送到multipart请求中的profilePicture part之中。

现在,需要修改processRegistration()方法,使其能够接受上传的图片。其中一种方式是添加byte数组参数,并为其添加@RequestPart注解。如下为示例:

@RequestMapping(value="/register", method=POST)
  public String processRegistration(@RequestPart("profilePicture") byte[] profilePicture, @Valid Spitter spitter, Errors errors) {
  	...
  }

当注册表单提交的时候,profilePicture属性将会给定一个byte数组,这个数组中包含了请求中对应part的数据(通过@RequestPart指定)。如果用户提交表单的时候没有选择文件,那么这个数组会是空(而不是null)。获取到图片数据后,processRegistration()方法剩下的任务就是将文件保存到某个位置。

接受MultipartFile

使用上传文件的原始byte比较简单但是功能有限。因此,Spring还提供了MultipartFile接口,它为处理multipart数据提供了内容更为丰富的对象。如下的程序展现了MultipartFile接口的概况。

package org.springframework.web.multipart;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;

public interface MultipartFile {

   String getName();

   String getOriginalFilename();

   String getContentType();

   boolean isEmpty();

   long getSize();

   byte[] getBytes() throws IOException;

   InputStream getInputStream() throws IOException;

   void transferTo(File dest) throws IOException, IllegalStateException;

}

可以看到,MultipartFile提供了获取上传文件byte的方式,但是它所提供的功能并不仅限于此,还能获得原始的文件名、大小以及内容类型。它还提供了一个InputStream,用来将文件数据以流的方式进行读取。

除此之外,MultipartFile还提供了一个便利的transferTo()方法,它能够帮助将上传的文件写入到文件系统中。作为样例,可以在process-Registration()方法中添加如下的几行代码,从而将上传的图片文件写入到文件系统中:

profilePicture.transferTo(new File("/data/spittr/" + profilePicture.getOriginalFilename()));

将文件保存到本地文件系统中是非常简单的,但是这需要我们对这些文件进行管理。我们需要确保有足够的空间,确保当出现硬件故障时,文件进行了备份,还需要在集群的多个服务器之间处理这些图片文件的同步。

将文件保存到Amazon S3中

另外一种方案就是让别人来负责处理这些事情。多加几行代码,我们就能将图片保存到云端。例如,如下的程序清单所展现的saveImage()方法能够将上传的文件保存到Amazon S3中,我们在processRegistration()中可以调用该方法。

private void saveImage(MultipartFile image) throws ImageUploadException {
    try {
        AWSCredentials awsCredentials = new AWSCredentials(s3AccessKey, s2SecretKey);
        S3Service s3 = new RestS3Service(awsCredentials);
        S3Bucket bucket = s3.getBucket("spittrImages");
        S3Object imageObject = new S3Object(image.getOriginalFilename());
        imageObject.setDataInputStream(image.getInputStream());
        imageObject.setContentLength(image.getSize());
        imageObject.setContentType(image.getContentType());
        
        AccessControlList acl = new AccessControlList();
        acl.setOwner(bucket.getOwner());
        acl.grantPermission(GroupGrantee.ALL_USERS, Permission.PERMISSION_READ);
        imageObject.setAcl(acl);
        
        s3.putObject(bucket, imageObject);
    } catch (Exception e) {
        throw new ImageUploadException("Unable to save image", e);
    }
}

saveImage()方法所做的第一件事就是构建Amazon Web Service(AWS)凭证。为了完成这一点,你需要有一个S3 Access Key和S3 Secret Access Key。当注册S3服务的时候,Amazon会将其提供给你。它们会通过值注入的方式提供给Spitter-Controller。

AWS凭证准备好后,saveImage()方法创建了一个JetS3t的RestS3Service实例,可以通过它来操作S3文件系统。它获取spitterImages bucket的引用并创建用来包含图片的S3Object对象,接下来将图片数据填充到S3Object。

在调用putObject()方法将图片数据写到S3之前,saveImage()方法设置了S3Object的权限,从而允许所有的用户查看它。这是很重要的——如果没有它的话,这些图片对我们应用程序的用户就是不可见的。最后,如果出现任何问题的话,将会抛出ImageUploadException异常。

以Part的形式接受上传的文件

如果需要将应用部署到Servlet 3.0的容器中,那么会有MultipartFile的一个替代方案。Spring MVC也能接受javax.servlet.http.Part作为控制器方法的参数。如果使用Part来替换MultipartFile的话,那么processRegistration()的方法签名将会变成如下的形式:

@RequestMapping(value="/register", method=POST)
public String processRegistration(@RequestPart("profilePicture") Part profilePicture, @Valid Spitter spitter, Errors errors) {
    ...
}

就主体来言(不开玩笑地说),Part接口与MultipartFile并没有太大的差别。在如下的程序中,我们可以看到Part接口的有一些方法其实是与MultipartFile相对应的。

package javax.servlet.http;

import java.io.*;
import java.util.*;

public interface Part {

    public InputStream getInputStream() throws IOException;

    public String getContentType();

    public String getName();

    public String getSubmittedFileName();

    public long getSize();

    public void write(String fileName) throws IOException;

    public void delete() throws IOException;

    public String getHeader(String name);

    public Collection<String> getHeaders(String name);

    public Collection<String> getHeaderNames();

}

在很多情况下,Part方法的名称与MultipartFile方法的名称是完全相同的。有一些比较类似,但是稍有差异,比如getSubmittedFileName()对应于getOriginalFilename()。类似地,write()对应于transferTo(),借助该方法能够将上传的文件写入文件系统中:

profilePicture.write("/data/spittr/" + profilePicture.getOriginalFilename());

值得一提的是,如果在编写控制器方法的时候,通过Part参数的形式接受文件上传,那么就没有必要配置MultipartResolver了。只有使用MultipartFile的时候,我们才需要MultipartResolver。

处理异常

不管发生什么事情,不管是好的还是坏的,Servlet请求的输出都是一个Servlet响应。如果在请求处理的时候,出现了异常,那它的输出依然会是Servlet响应。异常必须要以某种方式转换为响应。

Spring提供了多种方式将异常转换为响应:

  • 特定的Spring异常将会自动映射为指定的HTTP状态码
  • 异常上可以添加@ResponseStatus注解,从而将其映射为某一个HTTP状态码
  • 在方法上可以添加@ExceptionHandler注解,使其用来处理异常

处理异常的最简单方式就是将其映射到HTTP状态码上,进而放到响应之中。接下来,看一下如何将异常映射为某一个HTTP状态码。

将异常映射为HTTP状态码

在默认情况下,Spring会将自身的一些异常自动转换为合适的状态码。

Spring异常HTTP状态码
BindException400 - Bad Request
ConversionNotSupportedException500 - Internal Server Error
HttpMediaTypeNotAcceptableException406 - Not Acceptable
HttpMediaTypeNotSupportedException415 - Unsupported Media Type
HttpMessageNotReadableException400 - Bad Request
HttpMessageNotWritableException500 - Internal Server Error
HttpRequestMethodNotSupportedException405 - Method Not Allowed
MethodArgumentNotValidException400 - Bad Request
MissingServletRequestParameterException400 - Bad Request
MissingServletRequestPartException400 - Bad Request
NoSuchRequestHandlingMethodException404 - Not Found
TypeMismatchException400 - Bad Request

表7中的异常一般会由Spring自身抛出,作为DispatcherServlet处理过程中或执行校验时出现问题的结果。例如,如果DispatcherServlet无法找到适合处理请求的控制器方法,那么将会抛出NoSuchRequestHandlingMethodException异常,最终的结果就是产生404状态码的响应(Not Found)。

尽管这些内置的映射是很有用的,但是对于应用所抛出的异常它们就无能为力了。幸好,Spring提供了一种机制,能够通过@ResponseStatus注解将异常映射为HTTP状态码。

为了阐述这项功能,请参考SpittleController中如下的请求处理方法,它可能会产生HTTP 404状态(但目前还没有实现):

@RequestMapping(value="/{spittleId}", method=RequestMethod.GET)
public String spittle(
    @PathVariable("spittleId") long spittleId, 
    Model model) {
  Spittle spittle = spittleRepository.findOne(spittleId);
  if (spittle == null) {
    throw new SpittleNotFoundException();
  }
  model.addAttribute(spittle);
  return "spittle";
}

在这里,会从SpittleRepository中,通过ID检索Spittle对象。如果findOne()方法能够返回Spittle对象的话,那么会将Spittle放到模型中,然后名为spittle的视图会负责将其渲染到响应之中。但是如果findOne()方法返回null的话,那么将会抛出SpittleNotFoundException异常。现在SpittleNotFoundException就是一个简单的非检查型异常,如下所示:

package spittr.web;

public class SpittleNotFoundException extends RuntimeException {

}

如果调用spittle()方法来处理请求,并且给定ID获取到的结果为空,那么SpittleNotFoundException(默认)将会产生500状态码(Internal Server Error)的响应。实际上,如果出现任何没有映射的异常,响应都会带有500状态码,但是,我们可以通过映射SpittleNotFoundException对这种默认行为进行变更。

当抛出SpittleNotFoundException异常时,这是一种请求资源没有找到的场景。如果资源没有找到的话,HTTP状态码404是最为精确的响应状态码。所以,我们要使用@ResponseStatus注解将SpittleNotFoundException映射为HTTP状态码404。

package spittr.web;

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

@ResponseStatus(value=HttpStatus.NOT_FOUND, reason="Spittle Not Found")
public class SpittleNotFoundException extends RuntimeException {

}

在引入@ResponseStatus注解之后,如果控制器方法抛出SpittleNotFound-Exception异常的话,响应将会具有404状态码,这是因为Spittle Not Found。

编写异常处理的方法

作为样例,假设用户试图创建的Spittle与已创建的Spittle文本完全相同,那么SpittleRepository的save()方法将会抛 出DuplicateSpittle Exception异常。这意味着SpittleController的saveSpittle()方法可能需要处理这个异常。如下面的程序所示,saveSpittle()方法可以直接处理这个异常。

@RequestMapping(method=RequestMethod.POST)
public String saveSpittle(SpittleForm form, Model model) {
  try {
    spittleRepository.save(new Spittle(null, form.getMessage(), new Date(), 
        form.getLongitude(), form.getLatitude()));
    return "redirect:/spittles";
  } catch (DuplicateSpittleException e) {
    return "error/duplicate";
  }
}

它运行起来没什么问题,但是这个方法有些复杂。该方法可以有两个路径,每个路径会有不同的输出。如果能让saveSpittle()方法只关注正确的路径,而让其他方法处理异常的话,那么它就能简单一些。

@RequestMapping(method=RequestMethod.POST)
public String saveSpittle(SpittleForm form, Model model) {
    spittleRepository.save(new Spittle(null, form.getMessage(), new Date(), 
        form.getLongitude(), form.getLatitude()));
    return "redirect:/spittles";
}

可以看到,saveSpittle()方法简单了许多。因为它只关注成功保存Spittle的情况,所以只有一个执行路径,很容易理解(和测试)。

现在,为SpittleController添加一个新的方法,它会处理抛出DuplicateSpittleException的情况:

@ExceptionHandler(DuplicateSpittleException.class)
public String handleNotFound() {
  return "error/duplicate";
}

handleDuplicateSpittle()方法上添加了@ExceptionHandler注解,当抛出DuplicateSpittleException异常的时候,将会委托该方法来处理。它返回的是一个String,这与处理请求的方法是一致的,指定了要渲染的逻辑视图名,它能够告诉用户他们正在试图创建一条重复的条目。

对于@ExceptionHandler注解标注的方法来说,比较有意思的一点在于它能处理同一个控制器中所有处理器方法所抛出的异常。所以,尽管我们从saveSpittle()中抽取代码创建了handleDuplicateSpittle()方法,但是它能够处理SpittleController中所有方法所抛出的DuplicateSpittleException异常。我们不用在每一个可能抛出DuplicateSpittleException的方法中添加异常处理代码,这一个方法就涵盖了所有的功能。

既然@ExceptionHandler注解所标注的方法能够处理同一个控制器类中所有处理器方法的异常,那么你可能会问有没有一种方法能够处理所有控制器中处理器方法所抛出的异常呢。从Spring 3.2开始,这肯定是能够实现的,我们只需将其定义到控制器通知类中即可。

为控制器添加通知

如果控制器类的特定切面能够运用到整个应用程序的所有控制器中,那么这将会便利很多。举例来说,如果要在多个控制器中处理异常,那@ExceptionHandler注解所标注的方法是很有用的。不过,如果多个控制器类中都会抛出某个特定的异常,那么你可能会发现要在所有的控制器方法中重复相同的@ExceptionHandler方法。或者,为了避免重复,我们会创建一个基础的控制器类,所有控制器类要扩展这个类,从而继承通用的@ExceptionHandler方法。

Spring 3.2为这类问题引入了一个新的解决方案:控制器通知。控制器通知(controller advice)是任意带有@ControllerAdvice注解的类,这个类会包含一个或多个如下类型的方法:

  • @ExceptionHandler注解标注的方法
  • @InitBinder注解标注的方法
  • @ModelAttribute注解标注的方法

在带有@ControllerAdvice注解的类中,以上所述的这些方法会运用到整个应用程序所有控制器中带有@RequestMapping注解的方法上。

@ControllerAdvice注解本身已经使用了@Component,因此@ControllerAdvice注解所标注的类将会自动被组件扫描获取到,就像带有@Component注解的类一样。

@ControllerAdvice最为实用的一个场景就是将所有的@ExceptionHandler方法收集到一个类中,这样所有控制器的异常就能在一个地方进行一致的处理。例如,我们想将DuplicateSpittleException的处理方法用到整个应用程序的所有控制器上。如下的程序展现的AppWideExceptionHandler就能完成这一任务,这是一个带有@ControllerAdvice注解的类。

package spittr.web;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class AppWideExceptionHandler {

  @ExceptionHandler(DuplicateSpittleException.class)
  public String duplicateSpittleHandler() {
    return "error/duplicate";
  }

}

现在,如果任意的控制器方法抛出了DuplicateSpittleException,不管这个方法位于哪个控制器中,都会调用这个duplicateSpittleHandler()方法来处理异常。我们可以像编写@RequestMapping注解的方法那样来编写@ExceptionHandler注解的方法。如上述程序所示,它返回“error/duplicate”作为逻辑视图名,因此将会为用户展现一个友好的出错页面。

跨重定向请求传递数据

在处理完POST请求后,通常来讲一个最佳实践就是执行一下重定向。除了其他的一些因素外,这样做能够防止用户点击浏览器的刷新按钮或后退箭头时,客户端重新执行危险的POST请求。

在控制器方法返回的视图名称中,我们借助了“redirect:”前缀的力量。当控制器方法返回的String值以“redirect:”开头的话,那么这个String不是用来查找视图的,而是用来指导浏览器进行重定向的路径。

return "redirect:/spitter/" + spitter.getUsername();

“redirect:”前缀能够让重定向功能变得非常简单。你可能会想Spring很难再让重定向功能变得更简单了。但是,请稍等:Spring为重定向功能还提供了一些其他的辅助功能。

一般来讲,当一个处理器方法完成之后,该方法所指定的模型数据将会复制到请求中,并作为请求中的属性,请求会转发(forward)到视图上进行渲染。因为控制器方法和视图所处理的是同一个请求,所以在转发的过程中,请求属性能够得以保存。

当控制器的结果是重定向的话,原始的请求就结束了,并且会发起一个新的GET请求。原始请求中所带有的模型数据也就随着请求一起消亡了。在新的请求属性中,没有任何的模型数据,这个请求必须要自己计算数据。

显然,对于重定向来说,模型并不能用来传递数据。但是我们也有一些其他方案,能够从发起重定向的方法传递数据给处理重定向方法中:

  • 使用URL模板以路径变量和/或查询参数的形式传递数据
  • 通过flash属性发送数据

通过URL模板进行重定向

通过路径变量和查询参数传递数据看起来非常简单。例如,我们以路径变量的形式传递了新创建Spitter的username。 但是按照现在的写法,username的值是直接连接到重定向String上的。这能够正常运行,但是还远远不能说没有问题。当构建URL或SQL查询语句的时候,使用String连接是很危险的。

return "redirect:/spitter/{username}";

除了连接String的方式来构建重定向URL,Spring还提供了使用模板的方式来定义重定向URL。例如,processRegistration()方法的最后一行可以改写为如下的形式:

@RequestMapping(value="/register", method=POST)
public String processRegistration(Spitter spitter, Model model) {
    spitterRepository.save(spitter);
    model.addAttribute("username", spitter.getUsername());
    return "redirect:/spitter/{username}";
}

所返回的重定向String并没有太大的变化。但是,因为模型中的spitterId属性没有匹配重定向URL中的任何占位符,所以它会自动以查询参数的形式附加到重定向URL上。

如果username属性的值是habuma并且spitterId属性的值是42,那么结果得到的重定向URL路径将会是“/spitter/habuma?spitterId=42”。

通过路径变量和查询参数的形式跨重定向传递数据是很简单直接的方式,但它也有一定的限制。它只能用来发送简单的值,如String和数字的值。在URL中,并没有办法发送更为复杂的值,但这正是flash属性能够提供帮助的领域。

使用flash属性

Spring也认为将跨重定向存活的数据放到会话中是一个很不错的方式。但是,Spring认为我们并不需要管理这些数据,相反,Spring提供了将数据发送为flash属性(flash attribute)的功能。按照定义,flash属性会一直携带这些数据直到下一次请求,然后才会消失。

Spring提供了通过RedirectAttributes设置flash属性的方法,这是Spring 3.1引入的Model的一个子接口。RedirectAttributes提供了Model的所有功能,除此之外,还有几个方法是用来设置flash属性的。

具体来讲,RedirectAttributes提供了一组addFlashAttribute()方法来添加flash属性。重新看一下processRegistration()方法,可以使用addFlashAttribute()将Spitter对象添加到模型中:

@RequestMapping(value="/register", method=POST)
public String processRegistration(Spitter spitter, Model model) {
    spitterRepository.save(spitter);
    model.addAttribute("username", spitter.getUsername());
    model.addFlashAttribute("spitter", spitter);
    return "redirect:/spitter/{username}";
}

在这里,调用了addFlashAttribute()方法,并将spitter作为key,Spitter对象作为值。另外,还可以不设置key参数,让key根据值的类型自行推断得出:

model.addFlashAttribute("spitter", spitter);

因为传递了一个Spitter对象给addFlashAttribute()方法,所以推断得到的key将会是spitter。

在重定向执行之前,所有的flash属性都会复制到会话中。在重定向后,存在会话中的flash属性会被取出,并从会话转移到模型之中。处理重定向的方法就能从模型中访问Spitter对象了,就像获取其他的模型对象一样。

为了完成flash属性的流程,如下展现了更新版本的showSpitterProfile()方法,在从数据库中查找之前,它会首先从模型中检查Spitter对象:

@RequestMapping(value="/{username}", method=GET)
public String showSpitterProfile(
        @PathVariable String username, Model model) {
  if (!model.containsAttribute("spitter")) {
    model.addAttribute(
        spitterRepository.findByUsername(username));
  }
  return "profile";
}

可以看到,showSpitterProfile()方法所做的第一件事就是检查是否存有key为spitter的model属性。如果模型中包含spitter属性,那就什么都不用做了。这里面包含的Spitter对象将会传递到视图中进行渲染。但是如果模型中不包含spitter属性的话,那么showSpitterProfile()将会从Repository中查找Spitter,并将其存放到模型中。