SpringMVC 全注解实现 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格式的输出),然后将请求和模型、视图名称一起发送回 DispatcherServlet4 )。

注意,控制器并不负责指定具体的视图,返回给 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相关包

打包成war部署到tomcat8上

访问:http://localhost:8080/SpringDemo/helloworld

__

AbstractAnnotationConfigDispatcherServletInitializer 这个类负责配置 DispatcherServlet 、初始化Spring MVC容器和Spring容器。 getRootConfigClasses() 方法用于获取Spring应用容器的配置文件,这里我们给定预先定义的 RootConfig.classgetServletConfigClasses 负责获取Spring MVC应用容器,这里传入预先定义好的 WebConfig.classgetServletMappings() 方法负责指定需要由 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 创建了 DispatcherServletContextLoaderListenergetServletConfigClasses() 返回的配置类定义了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 注解添加一个 ViewResolver bean,具体来说是 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_ASSTRING 是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();
}

如果用户输入的参数有误,则返回 registerForm 这个逻辑名称,浏览器将返回到表单填写页面,以便用户重新输入。当然,为了更好的用户体验,还需要提示用户具体哪个字段写错了,应该怎么改;最好是在用户填写之前就做出提示,这就需要前端工程师做很多工作了。

5.5 总结

这一章比较适合Spring MVC的入门学习资料。涵盖了Spring MVC处理web请求的处理过程、如何写简单的控制器和控制器方法来处理Http请求、如何使用mockito框架测试控制器方法。

基于Spring MVC的应用有三种方式读取数据:查询参数、路径参数和表单输入。本章用两节介绍了这些内容,并给出了类似错误处理和参数验证等关键知识点。


原文链接:https://www.cnblogs.com/duanxz/p/4875131.html