天天看點

SpringMVC 全注解實作 (1) servlet3.0以上的容器支援

一、 Spring MVC入門

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

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,具體的代碼列舉如下:

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.dxz.demo</groupId>
    <artifactId>SpringDemo</artifactId>
    <packaging>war</packaging>
    <version>0.0.1-SNAPSHOT</version>
    <name>SpringDemo Maven Webapp</name>
    <url>http://maven.apache.org</url>
    <dependencies>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>servlet-api</artifactId>
            <version>3.0-alpha-1</version>
        </dependency>
        <!-- test support -->
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-all</artifactId>
            <version>1.10.19</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-core</artifactId>
            <version>4.1.7.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>4.1.7.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context-support</artifactId>
            <version>4.1.7.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-web</artifactId>
            <version>4.1.6.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>4.1.6.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc-portlet</artifactId>
            <version>4.1.6.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-test</artifactId>
            <version>4.1.6.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-beans</artifactId>
            <version>4.1.6.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-aspects</artifactId>
            <version>4.1.6.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>3.8.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <finalName>SpringDemo</finalName>
    </build>
</project>      

java檔案

package com.dxz.demo.mvc;

import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;

public class SpringWebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {

    //根容器
    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class<?>[] { RootConfig.class };
    }
    
    //Spring mvc容器
    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class<?>[] { WebConfig.class };
    }

    //DispatcherServlet映射,從"/"開始
    @Override
    protected String[] getServletMappings() {
        return new String[] { "/" };
    }

}      
package com.dxz.demo.mvc;
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 = {"com.dxz.demo.mvc.controller"},
  //      excludeFilters = {@ComponentScan.Filter(type = FilterType.ANNOTATION, value = EnableWebMvc.class)})
public class RootConfig {
    
    private String name;

    @Override
    public String toString() {
        return "RootConfig [name=" + name + "]";
    }
}

package com.dxz.demo.mvc;

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("com.dxz.demo.mvc.controller") //必須在此種類中開啟包掃描,否則無效
public class WebConfig extends WebMvcConfigurerAdapter {

    @Override
    public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {

        //開啟靜态資源的請求轉發到預設servlet上,不配置頁面報錯404,(預設servlet不是DispatcherServlet!了解的)
        configurer.enable();

    }

    @Bean
    public ViewResolver viewResolver() {

        InternalResourceViewResolver resolver = new InternalResourceViewResolver();
        resolver.setPrefix("WEB-INF/views/");
        resolver.setSuffix(".html");
        resolver.setExposeContextBeansAsAttributes(true);
        return resolver;

    }

}

package com.dxz.demo.mvc;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
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.mvc.Controller;
import org.springframework.web.servlet.view.InternalResourceViewResolver;

@Configuration
@EnableWebMvc
// 掃描控制器
@ComponentScan(includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, value = Controller.class))
public class SpringMvcConf extends WebMvcConfigurerAdapter {

    // 配置視圖解析器
    // html解析
    @Bean
    public ViewResolver htmlResolver() {
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setPrefix("/WEB-INF/view/");
        viewResolver.setSuffix(".html");
        return viewResolver;
    }

    // 靜态資源處理
    // 當DisptacherServlet接收到了他比對的請求,但是找不到相應的Controller,就會把這個請求傳回給預設的處理(比如交給tomcat處理)
    @Override
    public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
        configurer.enable();
    }
}      

WEB-INF\views\hello.html

<html>
<head>
<title>Index</title>
</head>
<body>
    <h1>hello to Index</h1>
</body>
</html>      

工程結構:

spring相關包

SpringMVC 全注解實作 (1) servlet3.0以上的容器支援

打包成war部署到tomcat8上

SpringMVC 全注解實作 (1) servlet3.0以上的容器支援

通路:http://localhost:8080/SpringDemo/helloworld

SpringMVC 全注解實作 (1) servlet3.0以上的容器支援

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;

    @Before
    public void setupMock() {
        HomeController controller = new HomeController();
        mockMvc = standaloneSetup(controller).build();
    }

    @Test
    public 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 {
    @Autowired
    private WebApplicationContext context;

    MockMvc mockMvc;

    @Before
    public void setupMock() {
        //HomeController controller = new HomeController();
        //mockMvc = standaloneSetup(controller).build();
        mockMvc = webAppContextSetup(context).build();
    }

    @Test
    public 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;
    }

    @Override
    public boolean equals(Object obj) {
        return EqualsBuilder.reflectionEquals(this, obj,
                new String[]{"message","latitude", "longitude"});
    }

    @Override
    public 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 {
    @Test
    public 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()方法非常簡單,也應該寫個單元測試,代碼如下所示:

@Test
public 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對象,并儲存在資料庫中。為了避免重複送出,應該重定向到另一個頁面——使用者資訊頁。

按照慣例,首先編寫測試用例,如下所示:

@Test
public 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();
}           

5.5 總結