SpringMVC 纯注解启动, 无 web.xml

主要内容

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

注意,控制器并不负责指定具体的视图,返回给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 {
    @Override
    protected Class[] getRootConfigClasses() { //根容器
        return new Class[] { RootConfig.class };
    }

    @Override
    protected Class[] getServletConfigClasses() { //Spring mvc容器
        return new Class[] { WebConfig.class };
    }

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

spitter这个单词是我们应用的名称,SpittrWebAppInitializer类是整个应用的总配置类。

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 文件中使用元素启动注解驱动的 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 的内容列举如下:

<%@<a href='http://bbs.itmayiedu.com/member/'></a> taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@<a href='http://bbs.itmayiedu.com/member/'></a> page contentType="text/html;charset=UTF-8" language="java" session="false" %>

    Spittr

    Welcome to Spittr
     ">Spittles
     ">Register

启动应用,然后访问http://localhost:8080/,Spittr 应用的主页如下图所示:

5.2.1 控制器测试

控制器的测试通过 Mockito 框架进行,首先在 pom 文件中引入需要的依赖库:


org.springframework spring-test org.mockito mockito-all ${mockito.version} junit junit ${junit.version}

然后,对应的单元测试用例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 findSpittles(long max, int count);
}

SpittleRepository接口中的findSpittles()方法有两个参数:max 表示要返回的Spittle对象的最大 ID;count 表示指定需要返回的Spittle对象数量。为了返回 20 个最近发表的Spittle对象,则使用List 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 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 createSpittleList(int count) {
        List spittles = new ArrayList();
        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 库,代码如下


org.hamcrest hamcrest-library 1.3

现在跑单元测试的话,必然会失败,因为我们还没有提供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 spittles() {
    return spittleRepository.findSpittles(Long.MAX_VALUE, 20));
}

这个版本和之前的不同,并没有返回一个逻辑名称以及显式设置 Model 对象,这个方法直接返回Spittle列表。在这种情况下,Spring 会将返回值直接放入 Model 对象,并从值类型推导出对应的关键字 key;然后从路径推导出视图逻辑名称,在这里是spittles

无论你选择那种实现,最终都需要一个页面——spittles.jsp。JSP 页面使用 JSTL 库的标签获取 model 对象中的数据,如下所示:


" > (, )

尽管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 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 spittles(
        @RequestParam("max") long max,
        @RequestParam("count") int count) {
    return spittleRepository.findSpittles(max, count);
}

如果SpittleController的 handle 方法需要默认处理同时处理两种情况:提供了 max 和 count 参数,或者没有提供的情况,代码如下:

@RequestMapping(method = RequestMethod.GET)
public List spittles(
        @RequestParam(value = "max", defaultValue = MAXLONGAS_STRING) long max, @RequestParam(value = "count", defaultValue = "20") int count) { return spittleRepository.findSpittles(max, count); } 

其中_MAXLONGAS_STRING_是 Long 的最大值的字符串形式,定义为:private static final String MAXLONGAS_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,该页面的代码如下所示:

<%@<a href='http://bbs.itmayiedu.com/member/'></a> taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@<a href='http://bbs.itmayiedu.com/member/'></a> page contentType="text/html;charset=UTF-8" language="java" %>

    Spittr

  Register
  
    First Name: 
    Last Name: 
    Username: 
    Password: 
    
  

上述 JSP 页面在浏览器中渲染图如下所示:

因为标签并没有设置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 的页面代码如下所示:

<%@<a href='http://bbs.itmayiedu.com/member/'></a> taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@<a href='http://bbs.itmayiedu.com/member/'></a> page contentType="text/html;charset=UTF-8" language="java" %>

    Your Profile

    Your Profile
    
    
    

上述代码的渲染图如下图所示:

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 文件中添加依赖:


javax.validation validation-api

然后就可以使用各类具体的注解,进行参数验证了,以 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://segmentfault.com/a/1190000004343063?_ea=575820