《Spring實戰》學習筆記-第七章:Spring MVC進階

本章主要內容:

  • 備用的Spring MVC配置項
  • 處理文件上傳
  • 控制器中的異常處理
  • 使用flash屬性

“等等,客官!不止這些”

也許大家在看電視廣告時對上面這句話比較熟悉,廣告里通常在已經對商品做了完整的介紹,這時,電視里就會冒出這句:等等,客官,還不止這些。。。接著,就會繼續吹噓他們的商品還有更多讓你意想不到的功能。

其實,Spring MVC(或者說Spring的每一個模塊)就給人一種“不止這些”的感覺,就在你以為已經對Spring MVC的功能有了完備的了解時,又會發現可以利用它做的更多。

在第五章中,我們使用Spring MVC的基本功能以及如何編寫控制器來處理各種各樣的請求。接著在第六章中創建了JSP和Thymeleaf視圖來將model數據對用戶進行了展示。也許你會覺得Spring MVC不過如此。但是等等,還不止這些!

本章中會繼續討論Spring MVC,比如編寫控制器來處理文件上傳,如何處理控制器中的異常,以及如何在model上傳遞數據從而可以在重定向時使用。

首先,在第五章中使用了AbstractAnnotationConfigDispatcherServletInitializer來設置Spring MVC,并且說了可以使用其他備用設置選擇。因此在文件上傳和異常處理之前,先來探索一下如何使用其他方式來設置DispatcherServletContextLoaderListener

Spring MVC備用配置

第五章中,通過繼承AbstractAnnotationConfigDispatcherServletInitializer來快速地對Spring MVC進行了設置。該類假設你想要一個基礎的DispatcherServletContextLoaderListener設置,并且通過Java而不是XML文件來配置Spring。

盡管這樣配置對大多數Spring應用都是適用的,但是總有意外,比如你想要除了DispatcherServlet之外的servlet和filter,或者你想對DispatcherServlet做一些進一步的配置,再或者,你想在Servlet3.0之前的版本上部署應用,那么你就要使用傳統的web.xml文件對DispatcherServlet進行配置了。

幸運的是,在(garden-variety)普通的AbstractAnnotationConfigDispatcherServletInitializer不適用于你的需求時,還有其他的一些方式供你使用。下面,我們就開始如何定制化的配置DispatcherServlet吧。

DispatcherServlet個性化配置

SpittrWebAppInitializer中所包含的三個方法僅僅是必須重寫的三個抽象方法,同時還有許多其他方法可以重寫從而可以實現更多的配置。

其中一個就是customizeRegistration(),在AbstractAnnotationConfigDispatcherServletInitializer注冊了DispatcherServlet之后,就會調用customizeRegistration()方法,并根據servlet的注冊返回值傳送ServletRegistration.Dynamic,通過對customizeRegistration()的重寫,就可以對DispatcherServlet進行額外的配置。

比如,在稍后的章節中(7.2),你會看到Spring MVC如何處理多個請求和文件上傳。如果打算使用Servlet3.0來實現多部分配置,那么就需要激活DispatcherServlet配置來實現多路請求。可以使用下面的方式重寫customizeRegistration()方法:

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

其中ServletRegistration.Dynamic作為入參,你可以做很多事情,比如調用setLoadOnStartup()來設置加載時優先級,調用setInitParameter()來設置初始化參數,調用setMultipartConfig()來設置Servlet3.0的多路支持。在上述示例中,設置了多路支持的上傳文件臨時存儲路徑為:/tmp/spittr/uploads。

添加額外的servlet和filter

根據之前的配置,可以生成DispatcherServlet和ContextLoaderListener,但是你需要注冊額外的servlet、filter或者listener時怎么辦呢?

使用基于Java配置的一個好處就是你可以盡量多的定義初始化類。因此,如果需要定義額外的組件,只需新建相應的初始化類即可。最簡單的方法就是實現Spring的WebApplicationInitializer接口。

例如,下面的代碼展示了如何通過實現WebApplicationInitializer接口的方式來注冊一個servlet:

import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRegistration.Dynamic;
import org.springframework.web.WebApplicationInitializer;
import com.myapp.MyServlet;
public class MyServletInitializer implements WebApplicationInitializer {
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        // 定義servlet
        Dynamic myServlet = servletContext.addServlet("myServlet", MyServlet.class);
        // 映射servlet
        myServlet.addMapping("/custom/**");
    }
}

上述代碼僅僅是一個基本的servlet注冊初始化類,實現了對servlet的注冊并映射到一個路徑。你也可以使用這種方式來手動地注冊DispatcherServlet(不過這好像沒有必要,因為AbstractAnnotationConfigDispatcherServletInitializer在這方面已經做得很不錯了)。

同樣的,你也可以通過上述方式來注冊listener和filter。例如:

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

WebApplicationInitializer是一個在注冊servlet、filter、listener時比較推薦的方式,當然你是使用基于Java的配置方式并將應用部署在Servlet3.0容器上的。如果你僅僅需要注冊一個filter并將其映射到DispatcherServlet,那么使用AbstractAnnotationConfigDispatcherServletInitializer將是一個捷徑。

要注冊多個filter并將它們映射到DispatcherServlet,你所要做的僅僅是重寫getServletFilters()方法。比如:

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

如你所見,該方法返回了一個javax.servlet.Filter的數組,這里僅僅返回了一個filter,但是它可以返回很多個。同時這里不再需要為這些filter去聲明映射,因為通過getServletFilters()返回的filter會自動地映射到DispatcherServlet。

當部署到Servlet3.0的容器時,Spring提供了很多方法來注冊servlet、filter和listener,而不再需要web.xml。如果你使用的不是Servlet3.0版本的容器,或者你就喜歡使用基于web.xml的配置方式,那么該如何對Spring MVC進行配置呢?

使用web.xml聲明DispatcherServlet

下面是一個典型的web.xml文件,其中對DispatcherServlet和ContextLoaderListener進行了聲明:

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

正如在第五章中所說的,DispatcherServlet和ContextLoaderListener可以加載Spring應用上下文。contextConfigLocation上下文參數指定了用來定義由ContextLoaderListener加載的根應用上下文的XML文件的位置。DispatcherServlet用來通過文件中定義的bean(名稱基于指定的servlet名稱:appServlet)來加載應用上下文。因此,DispatcherServlet會從/WEB-INF/appServlet-context.xml文件中加載應用上下文。

如果你想指定DispatcherServlet配置文件的位置,那么可以通過設置contextConfigLocation初始化參數的方式實現。例如,下面的DispatcherServlet配置就會從/WEB-INF/spring/appServlet/servlet-context.xml文件中加載:

<servlet>
    <servlet-name>appServlet</servlet-name>
    <!-- 注冊DispatcherServlet -->
    <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>

本書中采用的都是基于Java的配置方式,所以你需要對Spring MVC進行設置從而可以從@Configuration注解的類中加載配置。為了使用基于Java的配置,需要通知DispatcherServlet和ContextLoaderListener去使用AnnotationConfigWebApplicationContext,該類是WebApplicationContext接口的實現類,它可以對Java配置類進行加載。可以通過設置DispatcherServlet的contextClass參數和初始化參數來實現。下面對web.xml進行配置從而可以使用Java配置:

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
    <!-- 使用Java配置 -->
    <context-param>
        <param-name>contextClass</param-name>
        <param-value>
            org.springframework.web.context.support.AnnotationConfigWebApplicationContext
        </param-value>
    </context-param>
    
    <!-- 指定所使用的Java配置類 -->
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>spittr.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>
        <!-- 使用Java配置 -->
        <init-param>
            <param-name>contextClass</param-name>
            <param-value>
                org.springframework.web.context.support.AnnotationConfigWebApplicationContext
            </param-value>
        </init-param>
        <!-- 指定DispatcherServlet的配置類 -->
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>
                spittr.config.WebConfigConfig
            </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>

以上就是配置Spring MVC的一些方法,下面來看如何使用Spring MVC處理文件上傳。

處理multipart表單數據

一個web應用通常都會允許用戶上傳內容,比如像Facebook、Flickr這樣的站點,都會允許用戶上傳小片的。我們的Spittr應用中在兩處會用到文件上傳:一是新用戶注冊的時候,這時需要選擇一個頭像之類的;還有就是當用戶新建一個Spittle(推文?)時,也許需要在文中插入一張圖片。

來自傳統的表單提交的請求結果一般比較簡單并且采用多個鍵值對的方式。例如,當提交一個注冊信息的表單時,請求會是這樣的:
firstName=Charles&lastName=Xavier&email=professorx%40xmen.org &username=professorx&password=letmein01

雖然這種編碼方式對于傳統的基于文本的提交是最夠的,但是它卻沒有強大到可以攜帶二進制數據,比如上傳一個圖像。相反的,Multipart/form-data將表單分割成獨立的部分,每個部分都有各自的類型。傳統的表單域都有文本數據,但是當要上傳一些東西時,該部分可以是二進制的,如下面的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請求看起來比較復雜,但是在Spring MVC中處理起來還是比較簡單的。在編寫控制器方法來處理文件上傳之前,還需要配置一個multipart解析器來告知DispatcherServlet如何讀取multipart請求。

配置multipart解析器

DispatcherServlet并沒有實現任何邏輯用來將數據轉換成multipart請求。它使用了Spring的MultipartResolver接口的實現類來解析multipart請求中的內容。從Spring3.1開始,Spring提供了兩種MultipartResolver實現類供選擇:

  • CommonsMultipartResolver:使用Jakarta Commons FileUpload來解析multipart請求;
  • StandardServletMultipartResolver:依靠Servlet 3.0支持來解析(Spring 3.1及以上);

一般來講,StandardServletMultipartResolver應該是第一選擇。它使用servlet容器中現有的支持,并且不需要其他附加的項目依賴。但是,如果你將應用部署在Servlet 3.0之前的版本,或者你沒有使用Spring3.1及以上版本,那么就要使用CommonsMultipartResolver

使用Servlet 3.0解析multipart請求

StandardServletMultipartResolver沒有構造器參數和屬性需要設置,這樣它的設置就比較簡單,就像在Spring配置文件中聲明一個bean:

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

也許你想這么簡單的方法,我該如何加一下限制呢?比如,如何限制一個用戶可以上傳的文件大小,或者如何設置上傳過程中文件的臨時存放位置。因為沒有構造器和屬性可以設置,StandardServletMultipartResolver好像是有限制的。

其實是有辦法來設置StandardServletMultipartResolver的,但是它的設置不是在Spring配置中進行的,而是在Servlet配置中。起碼要配置一下存放臨時文件的位置,進一步來講,還要將multipart配置為DispatcherServlet的一部分。

如果你是在繼承自WebMvcConfigurerAdapter的servlet初始化類中配置的DispatcherServlet,那么就可以在servlet注冊時通過調用setMultipartConfig()方法來配置multipart詳情。比如:

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

如果你是在繼承自AbstractAnnotationConfigDispatcherServletInitializer或者AbstractDispatcherServletInitializer的servlet初始化類進行的配置,沒有創建DispatcherServlet的實例或者使用servlet上下文對其進行注冊。因此就沒有直接的引用供Dynamicservlet注冊來使用。但是你可以重寫customizeRegistration()方法來進行配置:

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

MultipartConfigElement的唯一參數設置了上傳文件時臨時文件的存放位置。也可以進行其他一些設置:

  • 文件上傳的最大值(byte),默認沒有限制;
  • 所有multipart請求的文件最大值(byte),不管有多少個請求,默認無限制;
  • 直接上傳文件(不需存儲到臨時目錄)的最大值(byte),默認是0,也就是所有的文件都要寫入硬盤;

例如,你想設置文件大小不超過2MB,所有請求的總和不超過4MB,并且所有文件都要寫入硬盤,那么就可以這樣設置:

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

如果你是使用的傳統的web.xml的方式來設置的DispatcherServlet,那么就需要使用多個<multipart-config>元素,其默認值和MultipartConfigElement相同,并且<location>是必填項:

<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>

配置Jakarta Commons FileUpload解析器

最簡單的CommonsMultipartResolver聲明方式是這樣的:

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

與StandardServletMultipartResolver不同的是,它不需要配置一個臨時目錄。默認情況下會使用servlet容器的臨時目錄。但是,你也可以通過uploadTempDir屬性進行設置,同時還可以對其他參數進行設置:

@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,即每個上傳文件都會直接寫入磁盤的。但是它是無法設置multipart請求總的文件大小的。

處理multipart請求

通過上面的配置,Spring已經支持multipart請求,那么就可以開始編寫控制器來處理文件上傳了。最普遍的做法就是使用@RequestPart注解一個控制器參數。

假設你想讓用戶可以在注冊時上傳圖像,那么就需要對注冊表單進行更改從而用戶可以選擇一個圖片,同時還需要更改SpitterController中的processRegistration()方法以獲取上傳的文件。下面的代碼是使用Thymeleaf的注冊頁面:

  <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/>
    <input type="submit" value="Register" />
  ...   

可以發現<form>標簽多了enctype="multipart/form-data"屬性,該屬性會告知瀏覽器要將當前form作為multipart數據處理。

除此之外,還添加了一個新的file類型的<input>標簽,該標簽允許用戶選擇一個圖片進行上傳。accept屬性設置了允許選擇的圖片類型。根據它的name屬性,圖片數據會放在profilePicture部分進行發送。

現在所需做的就是更新processRegistration()方法,來獲取上傳的圖片,其中一種方法就是添加一個用@RequestPart注解的byte數組:

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

當注冊表單提交時,請求部分的數據就會賦予到profilePicture屬性中,如果用戶沒有選中一個文件,那么該數組就會是一個空值(不是null)。既然已經獲取到上傳的文件,下面所需要的就是將文件保存。

接收multipart文件

處理上傳文件的原始數據比較簡單但是是有局限的,因此,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;
}

MultipartFile提供獲取上傳文件的方法,同時提供了很多其他方法,比如原始文件名稱、大小和內容類型等。另外還提供了一個InputStream可以將文件數據作為數據流讀取。

另外,MultipartFile還提供了一個方便的transferTo()方法幫助你將上傳文件寫入到文件系統。例如,你可以將如下代碼加入到processRegistration()中:

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

像這樣將文件保存到本地文件系統非常簡單,但是將文件管理的工作留給了你。你需要保證有足夠的空間,保證對文件進行了備份以防硬件問題。同事還需要進行多服務器之間的文件同步。

將文件保存到Amazon S3

另外的辦法就是將上面這些都托管給其他人,可以存放在云端,下面的代碼可以將上傳的圖像保存到Amazon S3:

private void saveImage(MultipartFile image) throws ImageUploadException {
    try {
        AWSCredentials awsCredentials = new AWSCredentials(s3AccessKey, s2SecretKey);
        // 配置S3服務
        S3Service s3 = new RestS3Service(awsCredentials);
        // 創建S3 bucket對象
        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的密鑰和私鑰,這些在注冊S3服務時Amazon都會給你的。

認證過AWS之后,saveImage()創建了一個JetS3t的RestS3Service實例,可以通過它操作S3文件系統。它會獲取一個spittrImages的bucket引用,并創建用于包含圖標的S3Object對象,然后將突破數據填充到S3Object中。

在調用putObject()方法將圖片數據寫入S3之前,saveImage()方法設置了S3Object的權限,允許有所有用戶查看。這很重要,因為如果沒有設置的話,那么這些圖片對于應用程序的用戶來說都是不可見得了。如果出現什么問題的話,會拋出ImageUploadException異常。

接收上傳文件為Part

如果你將應用部署在Servlet 3.0的容器上,那么你可以選擇不使用MultipartFile,Spring MVC也可以將javax.servlet.http.Part作為控制器的入參,使用Part后processRegistration()方法就是這樣的了:

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

大多數情況下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();
}

一些方法就是名稱上的不同,比如getSubmittedFileName()getOriginalFilename()是對應的。write()transferTo()是對應的,可以這樣使用:
profilePicture.write("/data/spittr/" + profilePicture.getOriginalFilename());

值得注意的是,如果你使用Part作為參數,那么就不再需要配置StandardServletMultipartResolverbean,它只需在使用MultipartFile時進行配置。

異常處理

一直以來我們都是假設Spittr應用中的一切都是正常運行的,但是如果哪里出現錯誤了呢?或者在處理請求時出現了異常?這時該向客戶端發送什么響應呢?

不論發生什么,好的或者壞的,一個servlet請求的輸出只能是一個servlet響應。如果在處理請求的過程中出現異常,輸出結果仍然是一個servlet響應,需要將異常轉換為一個響應。

Spring提供了一些將異常轉化為響應的方法:

  • 某些Spring異常會自動的映射為特定的HTTP狀態碼;
  • 使用@ResponseStatus注解將一個異常映射為HTTP狀態碼;
  • 使用ExceptionHandler注解的方法可以用來處理異常

映射異常為HTTP狀態碼

Spring可以自動地將其異常映射為狀態碼,如下表:

Spring異常 HTTP狀態碼
BindException 400 - Bad Request
ConversionNotSupportedException 500 - Internal Server Error
HttpMediaTypeNotAcceptableException 406 - Not Acceptable
HttpMediaTypeNotSupportedException 415 - Unsupported Media Type
HttpMessageNotReadableException 400 - Bad Request
HttpMessageNotWritableException 500 - Internal Server Error
HttpRequestMethodNotSupportedException 405 - Method Not Allowed
MethodArgumentNotValidException 400 - Bad Request
MissingServletRequestParameterException 400 - Bad Request
MissingServletRequestPartException 400 - Bad Request
NoSuchRequestHandlingMethodException 404 - Not Found
TypeMismatchException 400 - Bad Request

表格里的異常通常是在DispatcherServlet中出錯由Spring自身拋出的。例如,如果DispatcherServlet無法找到合適的控制器來處理請求,那么就會拋出NoSuchRequestHandlingMethodException,對應的狀態碼就是404。

雖然這些內置的映射有點用,但是不一定適用于其他的應用異常。還好,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";
}

如果findOne()方法返回了一個null,那么就會拋出SpittleNotFoundException。這里,SpittleNotFoundException就是一個未經檢查的異常:

package spittr.web;

public class SpittleNotFoundException extends Exception {

}

如果在處理請求時調用了spittle()方法,并且傳入的ID是空的,那么SpittleNotFoundException就會默認產生500的響應。實際上,如果沒有找到對應的映射都會返回500的錯誤。但是你也可以通過對SpittleNotFoundException進行映射改變這種情況。

當拋出SpittleNotFoundException時就表示一個請求的資源不存在,404恰好符合這種情況。那么,我們就使用@ResponseStatus來將其映射到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 Exception {

}

編寫異常處理方法

將異常映射為狀態碼大多數情況下是比較簡單有效的,但是如果想讓響應不僅僅只有一個狀態碼呢?也許你想對異常進行一些處理,就行處理請求一樣。

例如,SpittleRepository的save()方法在用戶重復創建Spittle時拋出了一個DuplicateSpittleException,那么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";
    }
}

上面的代碼并沒有什么特別的,這就是一個簡單的Java異常處理。

這樣做還可以,但是這個方法有點復雜。如果saveSpittle()方法專注于業務處理,讓其他方法來處理異常該多好。下面就為SpittleController添加一個新的方法來處理DuplicateSpittleException異常:

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

@ExceptionHandler注解應用在handleDuplicateSpittle()方法上,用來指定在有DuplicateSpittleException異常拋出時執行。

有意思的是,@ExceptionHandler注解的方法在同一個控制器里是通用的額,即無論SpittleController的哪一個方法拋出DuplicateSpittleException異常,handleDuplicateSpittle()方法都可以對其進行處理,而不再需要在每一個出現異常的地方進行捕獲。

也許你在想,@ExceptionHandler注解的方法能不能捕獲其他controller里的異常啊?在Spring3.2里是可以的,但僅僅局限于定義在控制器增強類(controller advice class)里的方法。

那么什么是控制器增強類呢?下面我們就來看看這個控制器增強類。

控制器增強類(controller advice class)

如果controller類的特定切面可以跨越應用的所有controller進行使用,那么這將會帶來極大的便捷。例如,@ExceptionHandler方法就可以處理多個controller拋出的異常了。如果多個controller類都拋出同一個異常,也許你會在這些controller進行重復的@ExceptionHandler方法編寫。或者,你也可以編寫一個異常處理的基類,供其他@ExceptionHandler方法進行繼承。

Spring3.2帶來了另外一種處理方法:控制器增強類,即使用@ControllerAdvice進行注解的類,它們會有下面幾個方法構成:

  • @ExceptionHandler注解的
  • @InitBinder注解的
  • @ModelAttribute注解的

@ControllerAdvice注解的類中的這些方法會在整個應用中的所有controller的所有@RequestMapping注解的方法上應用。

@ControllerAdvice注解本身是使用了@Component注解的,因此,使用@ControllerAdvice注解的類會在組件掃描時進行提取,就行使用@Controller注解的類一樣。

@ControllerAdvice的最實用的一個功能就是將所有的@ExceptionHandler方法集成在一個類中,從而可以在一個地方處理所有controller中的異常。例如,假設你想處理應用中所有的DuplicateSpittleException異常,可以采用下面的方法:

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 handleDuplicateSpittle() {
        return "error/duplicate";
    }

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

}

現在,不論哪一個controller拋出DuplicateSpittleException,都會調用handleDuplicateSpittle()方法來處理。

在redirect請求中攜帶數據

正如前文提到的,在處理完一個POST請求后進行重定向是一個不錯的選擇,起碼這樣可以避免用戶點擊刷新造成的POST請求重發的問題。

在第五章中,已經在控制器方法返回的視圖名稱中使用了redirect:前綴,這時返回的String不是用來尋找視圖,而是瀏覽器進行跳轉的路徑:
return "redirect:/spitter/" + spitter.getUsername();

也許你認為Spring處理重定向只能這樣了,但是等等:Spring還可以做得更多。

特別是一個重定向方法如何向處理重定向的方法發送數據呢?一般的,當一個處理函數結束后,方法中的model數據都會作為request屬性復制到request中,并且request會傳遞到視圖中進行解析。因為控制器和視圖面對的是同一個request,因此request屬性在forward時保留了下來。

但是,當一個控制器返回的是一個redirect時,原來的request會終止,并且會開啟一個新的HTTP請求。原來request中所有的model數據都會清空。新的request不會有任何的model數據。

Model屬性會作為request的屬性但是不能再redirect中傳遞
Model屬性會作為request的屬性但是不能再redirect中傳遞

明顯的,現在不能再redirect時使用model來傳遞數據了。但是還有其他方法用來從重定向的方法中獲取數據:

  • 將數據轉換為路徑參數或者查詢參數
  • 在flash屬性中發送數據
    首先來看一下Spring如何在路徑參數或者查詢參數中傳遞數據。

使用URL模版重定向

將數據轉化為路徑參數和查詢參數看起來比較簡單。在之前的代碼里,新建的Spitter的username就是作為路徑參數進行傳遞的。但是這里的username是轉換為String進行傳遞的。使用String傳遞URL和SQL時是比較危險的事情。

除了使用重定向鏈接,Spring提供了使用模版來定義重定向鏈接。例如下面的代碼:
return "redirect:/spitter/{username}";

你所需做的就是設置model中的相關值。因此,processRegistration()方法需要接收model作為入參,并將username設置其中。

@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進行連接,就可以將username中的不安全字符隱藏起來。這樣就比讓用戶直接輸入username并將其添加到路徑后面要更加安全。

另外,model中其他的原始值也會作為查詢參數添加到重定向URL中。例如,除了username,model同時也包括新建的Spitter對象的id屬性:

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

返回的重定向String并沒有什么變化,但是由于model中的spitterId屬性并沒有映射到URL中的占位符,它會自動作為查詢參數。

如果username是habuma,spitterId是42,那么返回的重定向路徑將是/spitter/habuma?spitterId=42

使用路徑參數和查詢參數傳遞數據比較簡單,但是它也有局限性。它只適用于傳遞簡單值,比如String和數字,不能傳遞比較復雜的東西,那么我們就需要flash屬性來幫忙。

使用flash屬性

比如說你不再是想在重定向中傳送一個username或者ID,而是傳送一個真正的Spitter對象。如果只傳送了一個ID,那么處理重定向的方法不得不去數據庫中查找該對象。但是在重定向之前你已經有有一個Spitter對象了,為什么不將它傳送給重定向處理方法呢?

Spitter對象不像String或者int那么簡單,因此不能作為路徑參數或者查詢參數進行傳送。但是,它可以作為model的一個屬性。

但是在上面的討論中,model屬性最終都會拷貝到request中,并隨著redirect的觸發而消失。因此,你需要將Spitter對象放在一個會隨著redirect存活的地方。

其中一個方法是將其放在session中,session是可以長期存活的,可以跨越多個request。因此,你可以將Spitter對象在redirect之前放在session中,并在redirect之后取出。當然你還要在取出之后將其從session中清理。

事實證明,Spring允許將數據存放在session中,從而在redirect時傳遞數據。但是Spring認為你不應該負責管理這些數據。相反,Spring提供了將數據作為flash屬性進行傳送的功能。Flash屬性,即在到下一個request之前一直攜帶數據,然后它們就走了。

Spring提供了通過RedirectAttributes來設置flash屬性,RedirectAttributes作為Model的子接口,新增了一些方法用來設置flash屬性。

特別的,RedirectAttributes提供了addFlashAttribute()方法用來添加flash屬性。那么就可以利用它來重寫processRegistration()方法:

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

這里,可以調用addFlashAttribute()方法將Spitter對象作為一個值添加到flash屬性中。另外,你也可以不填對應的key值:
model.addFlashAttribute(spitter);
由于你傳遞了一個Spitter對象,因此key會自動生成為spitter

在重定向之前,所有的flash屬性都會拷貝到session中,在重定向之后,存儲在session中的flash屬性會從session中移出到model中。然后處理重定向請求的方法就可以使用Spitter對象了,如下圖所示:

flash屬性都會拷貝到session中,然后轉存到model中
flash屬性都會拷貝到session中,然后轉存到model中

下面對showSpitterProfile()進行一點點更,在從數據庫查找之前對Spitter進行檢查:

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

正如你所見,該方法的第一件事是檢查model中是否含有spitter的屬性,如果有就啥也不做了。Spitter對象會被直接傳送到視圖中進行解析。如果沒有再去數據庫里查。

總結

每當使用Spring時,好像總有更多:更多的特性、更多的選擇以及更多的途徑可以達到目標,Spring MVC有很多花樣繁多的功能。

Spring MVC的配置就是一個你需要進行選擇的地方。本章中,我們從如何配置Spring MVC的DispatcherServletContextLoaderListener說起。你可以看到如何進行DispatcherServlet注冊以及其他的servlet和filter的注冊。另外,如果將應用部署在比較舊的容器上,我們還可以使用web.xml進行配置。

接著,我們看了如何處理Spring MVC控制器拋出的異常。盡管@RequestMapping方法可以處理異常,如果你將異常處理部分抽取出來那么你的代碼就會比較清爽。

為了完成通用的任務,比如異常處理,會在整個應用中使用,Spring3.2開始提供了@ControllerAdvice來創建增強型控制器,從而可以在一個地方完成通用的異常處理。

最后,我們研究了如何在重定向時傳遞數據,那就是使用Spring的flash屬性。

至此,也許你會覺得,不過如此嘛!但是我們討論的僅僅是Spring MVC功能的一小部分。在16章中我們還會討論其他功能,比如如何利用它來創建REST API。

下面的章節,我們先放一放Spring MVC,來看一下Spring Web Flow,這是一個流框架,是Spring MVC的擴展,它能夠在Spring中實現面向會話的Web開發。


如果覺得有用,歡迎關注我的微信,有問題可以直接交流:

你的關注是對我最大的鼓勵!
你的關注是對我最大的鼓勵!
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容