Spring 学习笔记(八)构建 Spring Web 应用程序
接受请求的输入
有些Web应用是只读的。人们只能通过浏览器在站点上闲逛,阅读服务器发送到浏览器中的内容。
不过,这并不是一成不变的。众多的Web应用允许用户参与进去,将数据发送回服务器。如果没有这项能力的话,那Web将完全是另一番景象。
Spring MVC允许以多种方式将客户端中的数据传送到控制器的处理器方法中,包括:
-查询参数(Query Parameter)。
-表单参数(Form Parameter)。
-路径变量(Path Variable)。
你将会看到如何编写控制器处理这些不同机制的输入。作为开始,我们先看一下如何处理带有查询参数的请求,这也是客户端往服务器端发送数据时,最简单和最直接的方式。
处理查询参数
在Spittr应用中,我们可能需要处理的一件事就是展现分页的Spittle列表。在现在的SpittleController中,它只能展现最新的Spittle,并没有办法向前翻页查看以前编写的Spittle历史记录。如果你想让用户每次都能查看某一页的Spittle历史,那么就需要提供一种方式让用户传递参数进来,进而确定要展现哪些Spittle集合。
在确定该如何实现时,假设我们要查看某一页Spittle列表,这个列表会按照最新的Spittle在前的方式进行排序。因此,下一页中第一条的ID肯定会早于当前页最后一条的ID。所以,为了显示下一页的Spittle,我们需要将一个Spittle的ID传入进来,这个ID要恰好小于当前页最后一条Spittle的ID。另外,你还可以传入一个参数来确定要展现的Spittle数量。
为了实现这个分页的功能,我们所编写的处理器方法要接受如下的参数:
-before参数(表明结果中所有Spittle的ID均应该在这个值之前)。
-count参数(表明在结果中要包含的Spittle数量)。
为了实现这个功能,我们将程序清单5.10中的spittles()方法替换为使用before和count参数的新spittles()方法。我们首先添加一个测试,这个测试反映了新spittles()方法的功能。
@Test
public void shouldShowPagedSpittles() 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())));
@RequestMapping(method=RequestMethod.GET)
public List<Spittle> spittles(@RequestParam("max") long max,@RequestParam("count") int count){
return spittleRepository.findSpittles(max,count);
}
这个测试方法与程序清单5.9中的测试方法关键区别在于它针对“/spittles”发送GET请求,同时还传入了max和count参数。它测试了这些参数存在时的处理器方法,而另一个测试方法则测试了没有这些参数时的情景。这两个测试就绪后,我们就能确保不管控制器发生什么样的变化,它都能够处理这两种类型的请求。
SpittleController中的处理器方法要同时处理有参数和没有参数的场景,那我们需要对其进行修改,让它能接受参数,同时,如果这些参数在请求中不存在的话,就使用默认值Long.MAX_VALUE和20。@RequestParam注解的defaultValue属性可以完成这项任务:
@RequestMapping(method=RequestMethod.GET)
public List<Spittle> spittles(@RequestParam(value="max",
defaultValue=MAX_LONG_AS_STRING) long max,
@ReuquestParam(value="count",defaultValue="20") int count){
return spittleRepository.findSpittles(max,count);
}
通过路径参数接受输入
假设我们的应用程序需要根据给定的ID来展现某一个Spittle记录。其中一种方案就是编写处理器方法,通过使用@RequestParam注解,让它接受ID作为查询参数:
@ReuqestMapping(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”这样的请求。尽管这也可以正常工作,但是从面向资源的角度来看这并不理想。在理想情况下,要识别的资源(Spittle)应该通过URL路径进行标示,而不是通过查询参数。对“/spittles/12345”发起GET请求要优于对“/spittles/show?spittle_id=12345”发起请求。*前者能够识别出要查询的资源,而后者描述的是带有参数的一个操作——本质上是通过HTTP发起的RPC。
既然已经以面向资源的控制器作为目标,那我们将这个需求转换为一个测试。程序清单5.12展现了一个新的测试方法,它会断言SpittleController中对面向资源 请求的处理。
@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("/spittle/12345"))
.andExpect(view().name("Spittles"))
.andExpect(model().attributeExists("spittle"))
.andExpect(model().attribute("spittle",expectedSpittle));
}
如果想让这个测试通过的话,我们编写的@RequestMapping要包含变量部分,这部分代表了Spittle ID。
为了实现这种路径变量,Spring MVC允许我们在@RequestMapping路径中添加占位符。占位符的名称要用大括号(“{”和“}”)括起来。路径中的其他部分要与所处理的请求完全匹配,但是占位符部分可以是任意的值。
@RequestMapping(value="/{spittleId}",method=RequestMethod.GET)
public String spittle(@PathVarible("spittleId") long spittleId,Model model){
model.addAttribute(spittleRepository.findOne(spittleIdd));
return "spittle";
}
我们可以看到,spittle()方法的spittleId参数上添加了@PathVariable("spittleId")注解,这表明在请求路径中,不管占位符部分的值是什么都会传递到处理器方法的spittleId参数中。如果对“/spittles/54321”发送GET请求,那么将会把“54321”传递进来,作为spittleId的值。
如果@PathVariable中没有value属性的话,它会假设占位符的名称与方法的参数名相同。这能够让代码稍微简洁一些,因为不必重复写占位符的名称了。但需要注意的是,如果你想要重命名参数时,必须要同时修改占位符的名称,使其互相匹配。
spittle()方法会将参数传递到SpittleRepository的findOne()方法中,用来获取某个Spittle对象,然后将Spittle对象添加到模型中。模型的key将会是spittle,这是根据传递到addAttribute()方法中的类型推断得到的。
这样Spittle对象中的数据就可以渲染到视图中了,此时需要引用请求中key为spittle的属性(与模型的key一致)。如下为渲染Spittle的JSP视图片段:
<div class="spittleView">
<div class="spittleMessage"><c:out value="$(spittle.message)"/></div>
<div>
<span class="spittleTime"><c:out value="${spittle.time}"/></span>
</div>
</div>
image.png
处理表单
Web应用的功能通常并不局限于为用户推送内容。大多数的应用允许用户填充表单并将数据提交回应用中,通过这种方式实现与用户的交互。像提供内容一样,Spring MVC的控制器也为表单处理提供了良好的支持。
使用表单分为两个方面:展现表单以及处理用户通过表单提交的数据。在Spittr应用中,我们需要有个表单让新用户进行注册。SpitterController是一个新的控制器,目前只有一个请求处理的方法来展现注册表单。
package spittr.web;
import static org.springframework.web.bind.annotation.RequestMethod.*;
import org.springframework.stereotype.Controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/spitter")
public class SpitterControllerRegist {
@RequestMapping(value="register",method=GET)
public String showRegistrationForm() {
return "registerForm";
}
}
showRegistrationForm()方法的@RequestMapping注解以及类级别上的@RequestMapping注解组合起来,声明了这个方法要处理的是针对“/spitter/register”的GET请求。这是一个简单的方法,没有任何输入并且只是返回名为registerForm的逻辑视图。按照我们配置InternalResourceViewResolver的方式,这意味着将会使用“/WEB-INF/ views/registerForm.jsp”这个JSP来渲染注册表单。
尽管showRegistrationForm()方法非常简单,但测试依然需要覆盖到它。因为这个方法很简单,所以它的测试也比较简单。
@Test
public void shouleShowRegistration() throws Exception{
SpitterController controller = new SpitterController();
MockMvc mockMvc = standaloneSetup(controller).build();
mockMvc.perform(get("/spitter/register")).andExcept(view().name("registerForm"));
}
渲染注册表单的JSP:
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
<%@ page session="false"%>
<html>
<head>
<title>Spitter</title>
<link rel="stylesheet" type="text/css" href="<c:url value="/resources/style.css"/>">
</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>
image.png
编写处理表单的控制器
当处理注册表单的POST请求时,控制器需要接受表单数据并将表单数据保存为Spitter对象。最后,为了防止重复提交(用户点击浏览器的刷新按钮有可能会发生这种情况),应该将浏览器重定向到新创建用户的基本信息页面。这些行为通过下面的shouldProcessRegistration()进行了测试。
@Test
public void shouldProcessRegistration() throws Exception{
SpitterRepository mockRepository = mock(SpitterRepository.class);
Spitter unsaved = new Spitter("jbauer","24hours","Jack","Bauer");
Spitter saved = new Spitter(24L,"jbauer","24hours","Jack","Bauer");
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"));
verify(mockRepository,atLeastOnce()).save(unsaved);
}
显然,这个测试比展现注册表单的测试复杂得多。在构建完SpitterRepository的mock实现以及所要执行的控制器和MockMvc之后,shouldProcess-Registration()对“/spitter/register”发起了一个POST请求。作为请求的一部分,用户信息以参数的形式放到request中,从而模拟提交的表单。
在处理POST类型的请求时,在请求处理完成后,最好进行一下重定向,这样浏览器的刷新就不会重复提交表单了。在这个测试中,预期请求会重定向到“/spitter/jbauer”,也就是新建用户的基本信息页面。最后,测试会校验SpitterRepository的mock实现最终会真正用来保存表单上传入的数据。
现在,我们来实现处理表单提交的控制器方法。通过shouldProcess-Registration()方法,我们可能认为要满足这个需求需要做很多的工作。但是,在如下的程序清单中,我们可以看到新的SpitterController并没有做太多的事情。
package spittr.web;
import static org.springframework.web.bind.annotation.RequestMethod.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import spittr.Spitter;
import spittr.data.SpitterRepository;
@Controller
@RequestMapping("/spitter")
public class SpitterController {
private SpitterRepository spitterRepository;
@Autowired
public SpitterContriller(SpitterRepository spitterRepository) {
this.spitterRepository = spitterRepository;
}
@RequestMapping(value="/register",method=GET)
public String showRegistrationForm() {
return "registerForm";
}
@RequestMapping(value="/register",method=POST)
public String processRegistration(Spitter spitter) {
spitterRepository.save(spitter);
return "redirect:/spitter/" + spitter.getUsername();
}
}
我们之前创建的showRegistrationForm()方法依然还在,不过请注意新创建的processRegistration()方法,它接受一个Spitter对象作为参数。这个对象有firstName、lastName、username和password属性,这些属性将会使用请求中同名的参数进行填充。
当使用Spitter对象调用processRegistration()方法时,它会进而调用SpitterRepository的save()方法,SpitterRepository是在Spitter-Controller的构造器中注入进来的。
processRegistration()方法做的最后一件事就是返回一个String类型,用来指定视图。但是这个视图格式和以前我们所看到的视图有所不同。这里不仅返回了视图的名称供视图解析器查找目标视图,而且返回的值还带有重定向的格式。
当InternalResourceViewResolver看到视图格式中的“redirect:”前缀时,它就知道要将其解析为重定向的规则。
校验表单
如果用户在提交表单的时候,username或password文本域为空的话,那么将会导致在新建Spitter对象中,username或password是空的String。至少这是一种怪异的行为。如果这种现象不处理的话,这将会出现安全问题,因为不管是谁只要提交一个空的表单就能登录应用。
同时,我们还应该阻止用户提交空的firstName和/或lastName,使应用仅在一定程度上保持匿名性。有个好的办法就是限制这些输入域值的长度,保持它们的值在一个合理的长度范围,避免这些输入域的误用。
有种处理校验的方式非常初级,那就是在processRegistration()方法中添加代码来检查值的合法性,如果值不合法的话,就将注册表单重新显示给用户。这是一个很简短的方法,因此,添加一些额外的if语句也不是什么大问题,对吧?
与其让校验逻辑弄乱我们的处理器方法,还不如使用Spring对Java校
验API(Java Validation API,又称JSR-303)的支持。
image.png
请考虑要添加到Spitter域上的限制条件,似乎需要使用@NotNull和@Size注解。我们所要做的事情就是将这些注解添加到Spitter的属性上。如下的程序清单展现了Spitter类,它的属性已经添加了校验注解。
package spittr.web;
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;
}
processRegistration():确保所提交的数据是合法的
@RequestMapping(value="/register",method=POST)
public String processRegisteration(@Valid Spitter spitter,Errors errors){
if(errors.hasErrors()){
return "registerForm";
}
spitterRepository.save(spitter);
return "redirect:/spitter/" + spitter.getUsername();
}
Spitter参数添加了@Valid注解,这会告知Spring,需要确保这个对象满足校验限制。
如果有校验出现错误的话,那么这些错误可以通过Errors对象进行访问,现在这个对象已作为processRegistration()方法的参数。(很重要一点需要注意,Errors参数要紧跟在带有@Valid注解的参数后面,@Valid注解所标注的就是要检验的参数。)processRegistration()方法所做的第一件事就是调用Errors.hasErrors()来检查是否有错误。
如果有错误的话,Errors.hasErrors()将会返回到registerForm,也就是注册表单的视图。这能够让用户的浏览器重新回到注册表单页面,所以他们能够修正错误,然后重新尝试提交。