主要內容
- 將web請求映射到Spring控制器
- 綁定form參數
- 驗證表單提交的參數
對於很多Java程序員來說,他們的主要工作就是開發Web應用,如果你也在做這樣的工作,那么你一定會了解到構建這類系統所面臨的挑戰,例如狀態管理、工作流和參數驗證等。HTTP協議的無狀態性使得這些任務極具挑戰性。
Spring的web框架用於解決上述提到的問題,基於Model-View-Controller(MVC)模型,Spring MVC可以幫助開發人員構建靈活易擴展的Web
應用。
這一章將涉及Spring MVC框架的主要知識,由於基於注解開發是目前Spring社區的潮流,因此我們將側重介紹如何使用注解創建控制器,進而處理各類web請求和表單提交。在深入介紹各個專題之前,首先從一個比較高的層面觀察和理解下Spring MVC的工作原理。
5.1 Spring MVC入門
5.1.1 request的處理過程
用戶每次點擊瀏覽器界面的一個按鈕,都發出一個web請求(request)。一個web請求的工作就像一個快遞員,負責將信息從一個地方運送到另一個地方。
從web請求離開瀏覽器(1)到返回響應,中間經歷了幾個節點,在每個節點都進行一些操作用於交換信息。下圖展示了Spring MVC應用中web請求會遇到的幾個節點。

請求旅行的第一站是Spring的DispatcherServlet,和大多數Javaweb應用相同,Spring MVC通過一個單獨的前端控制器過濾分發請求。當Web應用委托一個servlet將請求分發給應用的其他組件時,這個servlert稱為前端控制器(front controller)。在Spring MVC中,DispatcherServlet就是前端控制器。
DispatcherServlet的任務是將請求發送給某個Spring控制器。控制器(controller)是Spring應用中處理請求的組件。一般在一個應用中會有多個控制器,DispatcherServlet來決定把請求發給哪個控制器處理。DispatcherServlet會維護一個或者多個處理器映射(2),用於指出request的下一站——根據請求攜帶的URL做決定。
一旦選好了控制器,DispatcherServlet會把請求發送給指定的控制器(3),控制器中的處理方法負責從請求中取得用戶提交的信息,然后委托給對應的業務邏輯組件(service objects)處理。
控制器的處理結果包含一些需要傳回給用戶或者顯示在瀏覽器中的信息。這些信息存放在模型(model)中,但是直接把原始信息返回給用戶非常低效——最好格式化成用戶友好的格式,例如HTML或者JSON格式。為了生成HTML格式的文件,需要把這些信息傳給指定的視圖(view),一般而言是JSP。
控制器的最后一個任務就是將數據打包在模型中,然后指定一個視圖的邏輯名稱(由該視圖名稱解析HTML格式的輸出),然后將請求和模型、視圖名稱一起發送回DispatcherServlet(4)。
注意,控制器並不負責指定具體的視圖,返回給DispatcherServlet的視圖名稱也不會指定具體的JSP頁面(或者其他類型的頁面);控制器返回的僅僅是視圖的邏輯名稱,DispatcherServlet用這個名稱查找對應的視圖解析器(5),負責將邏輯名稱轉換成對應的頁面實現,可能是JSP也可能不是。
現在DispatcherServlet就已經知道將由哪個視圖渲染結果,至此一個請求的處理就基本完成了。最后一步就是視圖的實現(6),最經典的是JSP。視圖會使用模型數據填充到視圖實現中,然后將結果放在HTTP響應對象中(7)。
5.1.2 設置Spring MVC
如上一小節的圖展示的,看起來需要填寫很多配置信息。幸運地是,Spring的最新版本提供了很多容易配置的選項,降低了Spring MVC的學習門檻。這里我們先簡單配置一個Spring MVC應用,作為這一章將會不斷完善的例子。
CONFIGURING DISPATCHERSERVLET
DispatcherServlet是Spring MVC的核心,每當應用接受一個HTTP請求,由DispatcherServlet負責將請求分發給應用的其他組件。
在舊版本中,DispatcherServlet之類的servlet一般在web.xml文件中配置,該文件一般會打包進最后的war包種;但是Spring 3引入了注解,我們在這一章將展示如何基於注解配置Spring MVC。
既然不適用web.xml文件,你需要在servlet容器中使用Java配置DispatcherServlet,具體的代碼列舉如下:
package org.test.spittr.config;
import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;
public class SpittrWebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
@Overrideprotected Class<?>[] getRootConfigClasses() { //根容器return new Class<?>[] { RootConfig.class };
}
@Overrideprotected Class<?>[] getServletConfigClasses() { //Spring mvc容器return new Class<?>[] { WebConfig.class };
}
@Overrideprotected String[] getServletMappings() { //DispatcherServlet映射,從"/"開始return new String[] { "/" };
}
}
spitter這個單詞是我們應用的名稱,SpittrWebAppInitializer類是整個應用的總配置類。
AbstractAnnotationConfigDispatcherServletInitializer這個類負責配置DispatcherServlet、初始化Spring MVC容器和Spring容器。getRootConfigClasses()方法用於獲取Spring應用容器的配置文件,這里我們給定預先定義的RootConfig.class;getServletConfigClasses負責獲取Spring MVC應用容器,這里傳入預先定義好的WebConfig.class;getServletMappings()方法負責指定需要由DispatcherServlet映射的路徑,這里給定的是"/",意思是由DispatcherServlet處理所有向該應用發起的請求。
A TALE OF TWO APPLICATION CONTEXT
當DispatcherServlet啟動時,會創建一個Spring MVC應用容器並開始加載配置文件中定義好的beans。通過getServletConfigClasses()方法,可以獲取由DispatcherServlet加載的定義在WebConfig.class中的beans。
在Spring Web應用中,還有另一個Spring應用容器,這個容器由ContextLoaderListener創建。
我們希望DispatcherServlet僅加載web組件之類的beans,例如controllers(控制器)、view resolvers(視圖解析器)和處理器映射(handler mappings);而希望ContextLoaderListener加載應用中的其他類型的beans——例如業務邏輯組件、數據庫操作組件等等。
實際上,AbstractAnnotationConfigDispatcherServletInitializer創建了DispatcherServlet和ContextLoaderListener:getServletConfigClasses()返回的配置類定義了Spring MVC應用容器中的beans;getRootConfigClasses()返回的配置類定義了Spring應用根容器中的beans。【書中沒有說的】:Spring MVC容器是根容器的子容器,子容器可以看到根容器中定義的beans,反之不行。
注意:通過AbstractAnnotationConfigDispatcherServletInitializer配置DispatcherServlet僅僅是傳統的web.xml文件方式的另一個可選項。盡管你也可以使用AbstractAnnotationConfigDispatcherServletInitializer的一個子類引入web.xml文件來配置,但這沒有必要。
這種方式配置DispatcherServlet需要支持Servlert 3.0的容器,例如Apache Tomcat 7或者更高版本的。
ENABLING SPRING MVC
正如可以通過多種方式配置DispatcherServlet一樣,也可以通過多種方式啟動Spring MVC特性。原來我們一般在xml文件中使用<mvc:annotation-driven>元素啟動注解驅動的Spring MVC特性。
這里我們仍然使用JavaConfig配置,最簡單的Spring MVC配置類代碼如下:
package org.test.spittr.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@Configuration @EnableWebMvc public class WebConfig {
}
@Configuration表示這是Java配置類;@EnableWebMvc注解用於啟動Spring MVC特性。
僅僅這些代碼就可以啟動Spring MVC了,雖然它換缺了一些必要的組件:
- 沒有配置視圖解析器。這種情況下,Spring會使用BeanNameViewResolver,這個視圖解析器通過查找ID與邏輯視圖名稱匹配且實現了View接口的beans。
- 沒有啟動Component-scanning。
- DispatcherServlet作為默認的servlet,將負責處理所有的請求,包括對靜態資源的請求,例如圖片和CSS文件等。
因此,我們還需要在配置文件中增加一些配置,使得這個應用可以完成最簡單的功能,代碼如下:
package org.test.spittr.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.servlet.view.InternalResourceViewResolver;
@Configuration@EnableWebMvc@ComponentScan("org.test.spittr.web")
public class WebConfig extends WebMvcConfigurerAdapter{@Bean
public ViewResolver viewResolver() { //配置JSP視圖解析器InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix("/WEB-INF/views/");
resolver.setSuffix(".jsp");
//可以在JSP頁面中通過${}訪問beans
resolver.setExposeContextBeansAsAttributes(true);
return resolver;
}
@Override
public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable(); //配置靜態文件處理
}
}
首先,通過@ComponentScan("org.test.spittr.web")注解指定bean的自動發現機制作用的范圍,待會會看到,被@Controller等注解修飾的web的bean將被發現並加載到spring mvc應用容器。這樣就不需要在配置類中顯式定義任何控制器bean了。
然后,你通過@Bean注解添加一個ViewResolverbean,具體來說是InternalResourceViewResolver。后面我們會專門探討視圖解析器,這里的三個函數的含義依次是:setPrefix()方法用於設置視圖路徑的前綴;setSuffix()用於設置視圖路徑的后綴,即如果給定一個邏輯視圖名稱——"home",則會被解析成"/WEB-INF/views/home.jsp"; setExposeContextBeansAsAttributes(true)使得可以在JSP頁面中通過${ }訪問容器中的bean。
這里需要注意靜態路徑的設置,目前我的項目目錄如下:

最后,WebConfig繼承了WebMvcConfigurerAdapter類,然后覆蓋了其提供的configureDefaultServletHandling()方法,通過調用configer.enable(),DispatcherServlet將會把針對靜態資源的請求轉交給servlert容器的default servlet處理。
RootConfig的配置就非常簡單了,唯一需要注意的是,它在設置掃描機制的時候,將之前WebConfig設置過的那個包排除了;也就是說,這兩個掃描機制作用的范圍正交。RootConfig的代碼如下:
package org.test.spittr.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@Configuration@ComponentScan(basePackages = {"org.test.spittr"},
excludeFilters = {@ComponentScan.Filter(type = FilterType.ANNOTATION, value = EnableWebMvc.class)})
public class RootConfig {
}
5.1.3 Spittr應用簡介
這一章要用的例子應用,從Twitter獲取了一些靈感,因此最開始叫Spitter;然后又借鑒了最近比較流行的網站Flickr,因此我們也把e去掉,最終形成Spittr這個名字。這也有利於區分領域名稱(類似於twitter,這里用spring實現,因此叫spitter)和應用名稱。
Spittr應用有兩個關鍵的領域概念:spitters(應用的用戶)和spittles(用戶發布的狀態更新)。在這一章中,將專注於構建該應用的web層,創建控制器和顯示spittles,以及處理用戶注冊的表單。
基礎已經打好了,你已經配置好了DispatcherServlet,啟動了Spring MVC特性等,接下來看看如何編寫Spring MVC控制器。
5.2 編寫簡單的控制器
在Spring MVC應用中,控制器類就是含有被@RequestMapping注解修飾的方法的類,其中該注解用於指出這些方法要處理的請求類型。
我們從最簡單的請求"/"開始,用於渲染該應用的主頁,HomeController的代碼列舉如下:
package org.test.spittr.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller public class HomeController {
@RequestMapping(value = "/", method = RequestMethod.GET) public String home() {
return "home";
}
}
@Controller是一個模式化的注解,它的作用跟@Component一樣;Component-scanning機制會自動發現該控制器,並在Spring容器中創建對應的bean。
HomeController中的home()方法用於處理http://localhost:8080/這個URL對應的"/"請求,且僅處理GET方法,方法的內容是返回一個邏輯名稱為"home"的視圖。DispatcherServlet將會讓視圖解析器通過這個邏輯名稱解析出真正的視圖。
根據之前配置的InternalResourceViewResolver,最后解析成/WEB-INF/views/home.jsp,home.jsp的內容列舉如下:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %><%@ page contentType="text/html;charset=UTF-8" language="java" session="false" %><html><head><title>Spittr</title></head><body><h1>Welcome to Spittr</h1><a href="<c:url value="/spittles" /> ">Spittles</a><a href="<c:url value="/spitter/register"/> ">Register</a></body></html>
啟動應用,然后訪問http://localhost:8080/,Spittr應用的主頁如下圖所示:

5.2.1 控制器測試
控制器的測試通過Mockito框架進行,首先在pom文件中引入需要的依賴庫:
<dependency><groupId>org.springframework</groupId><artifactId>spring-test</artifactId></dependency><!-- test support --><dependency><groupId>org.mockito</groupId><artifactId>mockito-all</artifactId><version>${mockito.version}</version></dependency><dependency><groupId>junit</groupId><artifactId>junit</artifactId><version>${junit.version}</version></dependency>
然后,對應的單元測試用例HomeControllerTest的代碼如下所示:
package org.test.spittr.web;
import org.junit.Before;import org.junit.Test;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;
public class HomeControllerTest {
MockMvc mockMvc;
@Beforepublic void setupMock() {
HomeController controller = new HomeController();
mockMvc = standaloneSetup(controller).build();
}
@Testpublic void testHomePage() throws Exception {
mockMvc.perform(get("/"))
.andExpect(view().name("home"));
}
}
首先stanaloneSetup()方法通過HomeController的實例模擬出一個web服務,然后使用perform執行對應的GET請求,並檢查返回的視圖的名稱。MockMvcBuilders類有兩個靜態接口,代表兩種模擬web服務的方式:獨立測試和集成測試。上面這段代碼是獨立測試,我們也嘗試了集成測試的方式,最終代碼如下:
package org.test.spittr.web;
import org.junit.Before;import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.ContextHierarchy;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.context.WebApplicationContext;
import org.test.spittr.config.RootConfig;
import org.test.spittr.config.WebConfig;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration(value = "src/main/webapp")
@ContextHierarchy({
@ContextConfiguration(name = "parent", classes = RootConfig.class),
@ContextConfiguration(name = "child", classes = WebConfig.class)})
public class HomeControllerTest {
@Autowiredprivate WebApplicationContext context;
MockMvc mockMvc;
@Beforepublic void setupMock() {
//HomeController controller = new HomeController();//mockMvc = standaloneSetup(controller).build();
mockMvc = webAppContextSetup(context).build();
}
@Testpublic void testHomePage() throws Exception {
mockMvc.perform(get("/"))
.andExpect(view().name("home"));
}
}
5.2.2 定義類級別的請求處理
上面一節對之前的HomeController進行了簡單的測試,現在可以對它進行進一步的完善:將@RequestMapping從修飾函數改成修飾類,代碼如下:
package org.test.spittr.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller @RequestMapping(value = "/") public class HomeController {
@RequestMapping(method = RequestMethod.GET) public String home() {
return "home";
}
}
在新的HomeController中,"/"被移動到類級別的@RequestMapping中,而定義HTTP方法的@RequestMapping仍然用於修飾home()方法。RequestMapping注解可以接受字符串數組,即可以同時映射多個路徑,因此我們還可以按照下面這種方式修改:
@Controller@RequestMapping({"/", "/homepage"})
public class HomeController {
}
}
5.2.3 給視圖傳入模型數據
對於DispatcherServlet傳來的請求,控制器通常不會實現具體的業務邏輯,而是調用業務層的接口,並且將業務層服務返回的數據放在模型對象中返回給DispatcherServlet。
在Spittr應用中,需要一個頁面顯示最近的spittles列表。首先需要定義數據庫存取接口,這里不需要提供具體實現,只需要用Mokito框架填充模擬測試數據即可。SpittleRepository接口的代碼列舉如下:
package org.test.spittr.data;
import java.util.List;
public interface SpittleRepository {
List<Spittle> findSpittles(long max, int count);
}
SpittleRepository接口中的findSpittles()方法有兩個參數:max表示要返回的Spittle對象的最大ID;count表示指定需要返回的Spittle對象數量。為了返回20個最近發表的Spittle對象,則使用List<Spittle> recent = spittleRepository.findSpittle(Long.MAX_VALUE, 20)
這行代碼即可。該接口要處理的實體對象是Spittle,因此還需要定義對應的實體類,代碼如下:
package org.test.spittr.data;
import org.apache.commons.lang.builder.EqualsBuilder;
import org.apache.commons.lang.builder.HashCodeBuilder;
import java.util.Date;
public class Spittle {
private final Long id;
private final String message;
private final Date time;
private Double latitude;
private Double longitude;
public Spittle(String message, Date time) {
this(message, time, null, null);
}
public Spittle(String message,Date time, Double latitude, Double longitude) {
this.id = null;
this.time = time;
this.latitude = latitude;
this.longitude = longitude;
this.message = message;
}
public Long getId() {
return id;
}
public String getMessage() {
return message;
}
public Date getTime() {
return time;
}
public Double getLongitude() {
return longitude;
}
public Double getLatitude() {
return latitude;
}
@Overridepublic boolean equals(Object obj) {
return EqualsBuilder.reflectionEquals(this, obj,
new String[]{"message","latitude", "longitude"});
}
@Overridepublic int hashCode() {
return HashCodeBuilder.reflectionHashCode(this,
new String[]{"message", "latitude", "longitude"});
}
}
Spittle對象還是POJO,並沒什么復雜的。唯一需要注意的就是,利用Apache Commons Lang庫的接口,用於簡化equals和hashCode方法的實現。參考Apache Commons EqualsBuilder and HashCodeBuilder
首先為新的控制器接口寫一個測試用例,利用Mockito框架模擬repository對象,並模擬出request請求,代碼如下:
package org.test.spittr.web;
import org.junit.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.servlet.view.InternalResourceView;
import org.test.spittr.data.Spittle;import org.test.spittr.data.SpittleRepository;import java.util.ArrayList;
import java.util.Date;import java.util.List;
import static org.hamcrest.Matchers.hasItems;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;
public class SpittleControllerTest {
@Testpublic void shouldShowRecentSpittles() throws Exception {
//step1 准備測試數據
List<Spittle> expectedSpittles = createSpittleList(20);
SpittleRepository mockRepository = mock(SpittleRepository.class);
when(mockRepository.findSpittles(Long.MAX_VALUE, 20))
.thenReturn(expectedSpittles);
SpittleController controller = new SpittleController(mockRepository);
MockMvc mockMvc = standaloneSetup(controller)
.setSingleView(new InternalResourceView("/WEB-INF/views/spittles.jsp"))
.build();
//step2 and step3
mockMvc.perform(get("/spittles"))
.andExpect(view().name("spittles"))
.andExpect(model().attributeExists("spittleList"))
.andExpect(model().attribute("spittleList",
hasItems(expectedSpittles.toArray())));
}
private List<Spittle> createSpittleList(int count) {
List<Spittle> spittles = new ArrayList<Spittle>();
for (int i = 0; i < count; i++) {
spittles.add(new Spittle("Spittle " + i, new Date()));
}
return spittles;
}
}
單元測試的基本組成是:准備測試數據、調用待測試接口、校驗接口的執行結果。對於shouldShowRecentSpittles()這個用例我們也可以這么分割:首先規定在調用SpittleRepository接口的findSpittles()方法時將返回20個Spittle對象。
這里選擇獨立測試,跟HomeControllerTest不同的地方在於,這里構建MockMvc對象時還調用了setSingleView()函數,這是為了防止mock框架從控制器解析view名字。在很多情況下並沒有這個必要,但是對於SpittleController控制器來說,視圖名稱和路徑名稱相同,如果使用默認的視圖解析器,則MockMvc會混淆這兩者而失敗,報出如下圖所示的錯誤:

在這里其實可以隨意設置InternalResourceView的路徑,但是為了和WebConfig中的配置相同。
通過get方法構造GET請求,訪問"/spittles",並確保返回的視圖名稱是"spittles",返回的model數據中包含spittleList屬性,且對應的值為我們之前創建的測試數據。
最后,為了使用hasItems,需要在pom文件中引入hamcrest庫,代碼如下
<dependency><groupId>org.hamcrest</groupId><artifactId>hamcrest-library</artifactId><version>1.3</version></dependency>
現在跑單元測試的話,必然會失敗,因為我們還沒有提供SpittleController的對應方法,代碼如下:
package org.test.spittr.web;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.test.spittr.data.SpittleRepository;
@Controller@RequestMapping("/spittles")
public class SpittleController {
private SpittleRepository spittleRepository;
@Autowired
SpittleController(SpittleRepository spittleRepository) {
this.spittleRepository = spittleRepository;
}
@RequestMapping(method = RequestMethod.GET)
public String spittles(Model model) {
model.addAttribute(
spittleRepository.findSpittles(Long.MAX_VALUE, 20));
return "spittles";
}
}
Model對象本質上是一個Map,spittles方法負責填充數據,然后跟視圖的邏輯名稱一起回傳給DispatcherServlet。在調用addAttribute方法的時候,如果不指定key字段,則key字段會從value的類型推導出,在這個例子中默認的key字段是spittleList。
如果你希望顯式指定key字段,則可以按照如下方式指定:
@RequestMapping(method = RequestMethod.GET)
public String spittles(Model model) {
model.addAttribute("spittleList",
spittleRepository.findSpittles(Long.MAX_VALUE, 20));
return "spittles";
}
另外,如果你希望盡量少使用Spring規定的數據類型,則可以使用Map代替Model。
還有另一種spittles方法的實現,如下所示:
@RequestMapping(method = RequestMethod.GET) public List<Spittle> spittles() {
return spittleRepository.findSpittles(Long.MAX_VALUE, 20));
}
這個版本和之前的不同,並沒有返回一個邏輯名稱以及顯式設置Model對象,這個方法直接返回Spittle列表。在這種情況下,Spring會將返回值直接放入Model對象,並從值類型推導出對應的關鍵字key;然后從路徑推導出視圖邏輯名稱,在這里是spittles。
無論你選擇那種實現,最終都需要一個頁面——spittles.jsp。JSP頁面使用JSTL庫的<c:forEach>標簽獲取model對象中的數據,如下所示:
<c:forEach items="${spittleList}" var="spittle" ><li id="spittle_<c:out value="spittle.id"/>" >
<div class="spittleMessage"><c:out value="${spittle.message}" /></div><div><span class="spittleTime"><c:out value="${spittle.time}" /></span><span class="spittleLocation">
(<c:out value="${spittle.latitude}" />,
<c:out value="${spittle.longitude}" />)</span></div></li></c:forEach>
盡管SpittleController還是很簡單,但是它比HomeController復雜了一點,不過,這兩個控制器都沒有實現的一個功能是處理表單輸入。接下來將擴展SpittleController,使其能夠處理表單上輸入。
5.3 訪問request輸入
Spring MVC提供了三種方式,可以讓客戶端給控制器的handler傳入參數,包括:
- 查詢參數(Query parameters)
- 表單參數(Form parameters)
- 路徑參數(Path parameters)
5.3.1 獲取查詢參數
Spittr應用需要一個頁面顯示spittles列表,目前的SpittleController僅能返回最近的所有spittles,還不能提供根據spittles的生成歷史進行查詢。如果你想提供這個功能,首先要提供用戶一個傳入參數的方法,從而可以決定返回歷史spittles的那一個子集。
spittles列表是按照ID的生成先后倒序排序的:下一頁spittles的第一條spittle的ID應正好在當前頁的最后一條spittle的ID后面。因此,為了顯示下一頁spttles,應該能夠傳入僅僅小於當前頁最后一條spittleID的參數;並且提供設置每頁返回幾個spittles的參數count。
- before參數,代表某個Spittle的ID,包含該ID的spittles集合中所有的spittles都在當前頁的spittles之前發布;
- count參數,代表希望返回結果中包含多少條spittles。
我們將改造5.2.3小節實現的spittles()方法,來處理上述兩個參數。首先編寫測試用例:
@Test public void shouldShowRecentSpittles_NORMAL() throws Exception {
List<Spittle> expectedSpittles = createSpittleList(50);
SpittleRepository mockRepository = mock(SpittleRepository.class);
when(mockRepository.findSpittles(238900, 50))
.thenReturn(expectedSpittles);
SpittleController controller = new SpittleController(mockRepository);
MockMvc mockMvc = standaloneSetup(controller)
.setSingleView(new InternalResourceView("/WEB-INF/views/spittles.jsp"))
.build();
mockMvc.perform(get("/spittles?max=238900&count=50"))
.andExpect(view().name("spittles"))
.andExpect(model().attributeExists("spittleList"))
.andExpect(model().attribute("spittleList",
hasItems(expectedSpittles.toArray())));
}
這個測試用例的關鍵在於:為請求"/spittles"傳入兩個參數,max和count。這個測試用例可以測試提供參數的情況,兩個測試用例都應該提供,這樣可以覆蓋到所有測試條件。改造后的spittles方法列舉如下:
@RequestMapping(method = RequestMethod.GET)
public List<Spittle> spittles( @RequestParam("max") long max, @RequestParam("count") int count) {
return spittleRepository.findSpittles(max, count);
}
如果SpittleController的handle方法需要默認處理同時處理兩種情況:提供了max和count參數,或者沒有提供的情況,代碼如下:
@RequestMapping(method = RequestMethod.GET)
public List<Spittle> spittles( @RequestParam(value = "max", defaultValue = MAX_LONG_AS_STRING) long max, @RequestParam(value = "count", defaultValue = "20") int count) {
return spittleRepository.findSpittles(max, count);
}
其中MAX_LONG_AS_STRING是Long的最大值的字符串形式,定義為:private static final String MAX_LONG_AS_STRING = Long.MAX_VALUE + "";
,默認值都給定字符串形式,不過一旦需要綁定到參數上時,就會自動轉為合適的格式。
5.3.2 通過路徑參數獲取輸入
假設Spittr應用應該支持通過指定ID顯示對應的Spittle,可以使用@RequestParam給控制器的處理方法傳入參數ID,如下所示:
@RequestMapping(value = "/show", method = RequestMethod.GET)
public String showSpittle( @RequestParam("spittle_id") long spittleId, Model model) {
model.addAttribute(spittleRepository.findOne(spittleId));
return "spittle";
}
這個方法將處理類似/spittles/show?spittle_id=12345
的請求,盡管這可以工作,但是從基於資源管理的角度並不理想。理想情況下,某個指定的資源應該可以通過路徑指定,而不是通過查詢參數指定,因此GET請求最好是這種形式:/spittles/12345
。
首先編寫一個測試用例,代碼如下:
@Test public void testSpittle() throws Exception {
Spittle expectedSpittle = new Spittle("Hello", new Date());
SpittleRepository mockRepository = mock(SpittleRepository.class);
when(mockRepository.findOne(12345)).thenReturn(expectedSpittle);
SpittleController controller = new SpittleController(mockRepository);
MockMvc mockMvc = standaloneSetup(controller).build();
mockMvc.perform(get("/spittles/12345"))
.andExpect(view().name("spittle"))
.andExpect(model().attributeExists("spittle"))
.andExpect(model().attribute("spittle", expectedSpittle));
}
該測試用例首先模擬一個repository、控制器和MockMvc對象,跟之前的幾個測試用例相同。不同之處在於這里構造的GET請求——/spittles/12345,並希望返回的視圖邏輯名稱是spittle,返回的模型對象中包含關鍵字spittle,且與該key對應的值為我們創建的測試數據。
為了實現路徑參數,Spring MVC在@RequestMapping注解中提供占位符機制,並在參數列表中通過@PathVariable("spittleId")獲取路徑參數,完整的處理方法列舉如下:
@RequestMapping(value = "/{spittleId}", method = RequestMethod.GET)
public String showSpittle( @PathVariable("spittleId") long spittleId, Model model) {
model.addAttribute(spittleRepository.findOne(spittleId));
return "spittle";
}
@PathVariable注解的參數應該和@RequestMapping注解中的占位符名稱完全相同;如果函數參數也和占位符名稱相同,則可以省略@PathVariable注解的參數,代碼如下所示:
@RequestMapping(value = "/{spittleId}", method = RequestMethod.GET)
public String showSpittle( @PathVariable long spittleId, Model model) {
model.addAttribute(spittleRepository.findOne(spittleId));
return "spittle";
}
這么寫確實可以使得代碼更加簡單,不過需要注意:如果要修改函數參數名稱,則要同時修改路徑參數的占位符名稱。
5.4 處理表單
Web應用通常不僅僅是給用戶顯示數據,也接受用戶的表單輸入,最典型的例子就是賬號注冊頁面——需要用戶填入相關信息,應用程序按照這些信息為用戶創建一個賬戶。
關於表單的處理有兩個方面需要考慮:顯示表單內容和處理用戶提交的表單數據。在Spittr應用中,需要提供一個表單供新用戶注冊使用;需要一個SpitterController控制器顯示注冊信息。
package org.test.spittr.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller @RequestMapping("/spitter") public class SpitterController {
@RequestMapping(value = "/register", method = RequestMethod.GET) public String showRegistrationForm() {
return "registerForm";
}
}
修飾showRegistrationForm()方法的@RequestMapping(value = "/register", method = RequestMethod.GET)注解,和類級別的注解一起,表明該方法需要處理類似"/spitter/register"的GET請求。這個方法非常簡單,沒有輸入,且僅僅返回一個邏輯名稱——"registerForm"。
即使showRegistrationForm()方法非常簡單,也應該寫個單元測試,代碼如下所示:
@Testpublic void shouldShowRegistrationForm() throws Exception {
SpitterController controller = new SpitterController();
MockMvc mockMvc = standaloneSetup(controller).build();
mockMvc.perform(get("/spitter/register"))
.andExpect(view().name("registerForm"));
}
為了接受用戶的輸入,需要提供一個JSP頁面——registerForm.jsp,該頁面的代碼如下所示:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %><%@ page contentType="text/html;charset=UTF-8" language="java" %><html><head><title>Spittr</title></head><body><h1>Register</h1><form method="POST">
First Name: <input type="text" name="firstName" /><br/>
Last Name: <input type="text" name="lastName"/><br/>
Username: <input type="text" name="username"/><br/>
Password: <input type="password" name="password" /><br/><input type="submit" value="Register" /></form></body></html>
上述JSP頁面在瀏覽器中渲染圖如下所示:

因為<form>標簽並沒有設置action參數,因此,當用戶單擊submit按鈕的時候,將向后台發出/spitter/register的POST請求。這就需要我們為SpitterController編寫對應的處理方法。
5.4.1 編寫表單控制器
在處理來自注冊表單的POST請求時,控制器需要接收表單數據,然后構造Spitter對象,並保存在數據庫中。為了避免重復提交,應該重定向到另一個頁面——用戶信息頁。
按照慣例,首先編寫測試用例,如下所示:
@Testpublic void shouldProcessRegistration() throws Exception {
SpitterRepository mockRepository = mock(SpitterRepository.class);
Spitter unsaved = new Spitter("Jack", "Bauer", "jbauer", "24hours");
Spitter saved = new Spitter(24L, "Jack", "Bauer", "jbauer", "24hours");
when(mockRepository.save(unsaved)).thenReturn(saved);
SpitterController controller = new SpitterController(mockRepository);
MockMvc mockMvc = standaloneSetup(controller).build();
mockMvc.perform(post("/spitter/register")
.param("firstName", "Jack")
.param("lastName", "Bauer")
.param("username", "jbauer")
.param("password", "24hours"))
.andExpect(redirectedUrl("/spitter/jbauer"));
//Verified save(unsaved) is called atleast once
verify(mockRepository, atLeastOnce()).save(unsaved);
}
顯然,這個測試比之前驗證顯示注冊頁面的測試更加豐富。首先設置好SpitterRepository對象、控制器和MockMvc對象,然后構建一個POST請求——/spitter/register,且該請求會攜帶四個參數,用於模擬submit的提交動作。
在處理POST請求的最后一般需要利用重定向到一個新的頁面,以防瀏覽器刷新引來的重復提交。在這個例子中我們重定向到/spitter/jbaure,即新添加的用戶的個人信息頁面。
最后,該測試用例還需要驗證模擬對象mockRepository確實用於保存表單提交的數據了,即save()方法之上調用了一次。
在SpitterController中添加處理表單的方法,代碼如下:
@RequestMapping(value = "/register", method = RequestMethod.POST)
public String processRegistration(Spitter spitter) {
spitterRepository.save(spitter);
return "redirect:/spitter/" + spitter.getUsername();
}
shouldShowRegistrationForm()這個方法還在,新加的處理方法processRegistration()以Spitter對象為參數,Spring利用POST請求所攜帶的參數初始化Spitter對象。
現在執行之前的測試用例,發現一個錯誤如下所示:

我分析了這個錯誤,原因是測試用例的寫法有問題:verify(mockRepository, atLeastOnce()).save(unsaved);
這行代碼表示,希望調用至少保存unsave這個對象一次,而實際上在控制器中執行save的時候,參數對象的ID是另一個——根據參數新創建的。回顧我們寫這行代碼的初衷:確保save方法至少被調用一次,而保存哪個對象則無所謂,因此,這行語句改成verify(mockRepository, atLeastOnce());
后,再次執行測試用例就可以通過了。
注意:無論使用哪個框架,請盡量不要使用verify,也就是傳說中的Mock模式,那是把代碼拉入泥潭的開始。參見你應該更新的Java知識之常用程序庫
當InternalResourceViewResolver看到這個函數返回的重定向URL是以view標志開頭,就知道需要把該URL當做重定向URL處理,而不是按照視圖邏輯名稱處理。在這個例子中,頁面將被重定向至用戶的個人信息頁面。因此,我們還需要給SpitterController添加一個處理方法,用於顯示個人信息,showSpitterProfile()方法代碼如下:
@RequestMapping(value = "/{username}", method = RequestMethod.GET)
public String showSpitterProfile( @PathVariable String username, Model model) {
Spitter spitter = spitterRepository.findByUsername(username);
model.addAttribute(spitter);
return "profile";
}
showSpitterProfile()方法根據username從SpitterRepository中查詢Spitter對象,然后將該對象存放在model對象中,並返回視圖的邏輯名稱profile。
profile.jsp的頁面代碼如下所示:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %><%@ page contentType="text/html;charset=UTF-8" language="java" %><html><head><title>Your Profile</title></head><body><h1>Your Profile</h1><c:out value="${spitter.username}"/><br/><c:out value="${spitter.firstName}"/><br/><c:out value="${spitter.lastName}" /><br/></body></html>
上述代碼的渲染圖如下圖所示:

5.4.2 表單驗證
如果用戶忘記輸入username或者password就點了提交,則可能創建一個這兩個字段為空字符串的Spitter對象。往小了說,這是丑陋的開發習慣,往大了說這是會應發安全問題,因為用戶可以通過提交一個空的表單來登錄系統。
綜上所述,需要對用戶的輸入進行有效性驗證,一種驗證方法是為processRegistration()方法添加校驗輸入參數的代碼,因為這個函數本身非常簡單,參數也不多,因此在開頭加入一些If判斷語句還可以接受。
除了使用這種方法,換可以利用Spring提供的Java驗證支持(a.k.a JSR-303)。從Spring 3.0開始,Spring支持在Spring MVC項目中使用Java Validation API。
首先需要在pom文件中添加依賴:
<dependency><groupId>javax.validation</groupId><artifactId>validation-api</artifactId></dependency>
然后就可以使用各類具體的注解,進行參數驗證了,以Spitter類的實現為例:
package org.test.spittr.data;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
public class Spitter {
private Long id;
@NotNull@Size(min = 5, max = 16)
private String username;
@NotNull@Size(min = 5, max = 25)
private String password;
@NotNull@Size(min = 2, max = 30)
private String firstName;
@NotNull@Size(min = 2, max = 30)
private String lastName;
....
}
@NotNull注解表示被它修飾的字段不能為空;@Size字段用於限制指定字段的長度范圍。在Spittr應用的含義是:用戶必須填寫表單中的所有字段,並且滿足一定的長度限制,才可以注冊成功。
除了上述兩個注解,Java Validation API提供了很多不同功能的注解,都定義在javax.validation.constraints包種,下表列舉出這些注解:


在Spittr類的定義中規定驗證條件后,需要在控制器的處理方法中應用驗證條件。
@RequestMapping(value = "/register", method = RequestMethod.POST)
public String processRegistration( @Valid Spitter spitter, Errors errors) {
if (errors.hasErrors()) {
return "registerForm";
}
spitterRepository.save(spitter);
return "redirect:/spitter/" + spitter.getUsername();
}
如果用戶輸入的參數有誤,則返回registerForm這個邏輯名稱,瀏覽器將返回到表單填寫頁面,以便用戶重新輸入。當然,為了更好的用戶體驗,還需要提示用戶具體哪個字段寫錯了,應該怎么改;最好是在用戶填寫之前就做出提示,這就需要前端工程師做很多工作了。
5.5 總結
這一章比較適合Spring MVC的入門學習資料。涵蓋了Spring MVC處理web請求的處理過程、如何寫簡單的控制器和控制器方法來處理Http請求、如何使用mockito框架測試控制器方法。
基於Spring MVC的應用有三種方式讀取數據:查詢參數、路徑參數和表單輸入。本章用兩節介紹了這些內容,並給出了類似錯誤處理和參數驗證等關鍵知識點。