整理自《Spring实战》
五、构建Spring Web应用程序
5.1 Spring MVC起步
5.1.1 请求跟踪
5.1.2 搭建Spring MVC
配置DispatcherServlet
DispatcherServlet是Spring MVC的核心。在这里请求会第一次接触到框架,它要负责将请求路由到其他的组件之中。
按照传统的方式,像DispatcherServlet这样的Servlet会配置在web.xml文件中,这个文件会放到应用的WAR包里面。当然,这是配置DispatcherServlet的方法之一。但是,借助于Servlet 3规范和Spring 3.1的功能增强,这种方式已经不是唯一的方案了,这也不是我们此处所使用的配置方法。
我们会使用Java将DispatcherServlet配置在Servlet容器中,而不会再使用web.xml文件。
要理解上述程序是如何工作的,我们可能只需要知道扩展AbstractAnnotationConfigDispatcherServletInitializer的任意类都会自动地配置Dispatcher-Servlet和Spring应用上下文,Spring的应用上下文会位于应用程序的Servlet上下文之中。
AbstractAnnotationConfigDispatcherServletInitializer剖析
在Servlet 3.0环境中,容器会在类路径中查找实现javax.servlet.ServletContainerInitializer接口的类,如果能发现的话,就会用它来配置Servlet容器。
Spring提供了这个接口的实现,名为SpringServletContainerInitializer,这个类反过来又会查找实现WebApplicationInitializer的类并将配置的任务交给它们来完成。Spring 3.2引入了一个便利的WebApplicationInitializer基础实现,也就是AbstractAnnotationConfigDispatcherServletInitializer。由于Spittr-WebAppInitializer扩展了该类,因此当部署到Servlet 3.0容器中的时候,容器会自动发现它,并用它来配置Servlet上下文。
在上面程序中,重写了AbstractAnnotationConfigDispatcherServletInitializer类的三个方法:
第一个方法是getServletMappings(),它会将一个或多个路径映射到DispatcherServlet上。在本例中,它映射的是“/”,这表示它会是应用的默认Servlet。它会处理进入应用的所有请求。
为了理解其他的两个方法,我们首先要理DispatcherServlet和一个Servlet监听器(也就是ContextLoaderListener)的关系。
当DispatcherServlet启动的时候,它会创建Spring应用上下文,并加载配置文件或配置类中所声明的bean。在上面程序中getServletConfigClasses()方法中,我们要求DispatcherServlet加载应用上下文时,使用定义在WebConfig配置类(使用Java配置)中的bean。
但是在Spring Web应用中,通常还会有另外一个应用上下文。另外的这个应用上下文是由ContextLoaderListener创建的。
我们希望DispatcherServlet加载包含Web组件的bean,如控制器、视图解析器以及处理器映射,ContextLoaderListener要加载应用中的其他bean。这些bean通常是驱动应用后端的中间层和数据层组件。
实际上,AbstractAnnotationConfigDispatcherServletInitializer会同时创建DispatcherServlet和ContextLoaderListener。GetServlet-ConfigClasses()方法返回的带有@Configuration注解的类将会用来定义DispatcherServlet应用上下文中的bean。getRootConfigClasses()方法返回的带有@Configuration注解的类将会用来配置ContextLoaderListener创建的应用上下文中的bean。
需要注意的是,通过AbstractAnnotationConfigDispatcherServletInitializer来配置DispatcherServlet是传统web.xml方式的替代方案。你也可以同时包含web.xml和AbstractAnnotationConfigDispatcherServletInitializer,但这其实并没有必要。
这种部署的唯一问题在于它只能部署到支持Servlet 3.0的服务器中才能正常工作,如Tomcat 7或更高版本。
启动Spring MVC
我们有多种方式来配置DispatcherServlet,与之类似,启用Spring MVC组件的方法也不仅一种。以前,Spring是使用XML进行配置的,你可以使用<mvc:annotation-driven>启用注解驱动的Spring MVC。这里我们会让Spring MVC的
搭建过程尽可能简单并基于Java进行配置。
我们所能创建的最简单的Spring MVC配置就是一个带有@EnableWebMvc注解的类:
这可以运行起来,它的确能够启用Spring MVC,但还有不少问题要解决:
- 没有配置视图解析器。如果这样的话,Spring默认会使用BeanNameView-Resolver,这个视图解析器会查找ID与视图名称匹配的bean,并且查找的bean要实现View接口,它以这样的方式来解析视图。
- 没有启用组件扫描。这样Spring只能找到显式声明在配置类中的控制器。
- 这样配置的话,DispatcherServlet会映射为应用的默认
Servlet,所以它会处理所有的请求,包括对静态资源的请求,如图片和样式表(在大多数情况下,这可能并不是你想要的效果)。
如下程序清单中的WebConfig解决了上面所述的问题。
补充后:
- 添加了@Component-Scan注解,因此将会扫描spitter.web包来查找组件。
- 添加了一个ViewResolver bean。它会查找JSP文件,在查找的时候,它会在视图名称上加一个特定的前缀和后缀。
- 扩展了WebMvcConfigurerAdapter并重写了其configureDefaultServletHandling()方法。通过调用DefaultServlet-HandlerConfigurer的enable()方法,
我们要求DispatcherServlet将对静态资源的请求转发到Servlet容器中默认的Servlet上,而不是使用DispatcherServlet本身来处理此类请求。
WebConfig已经就绪,接下来是RootConfig。
唯一需要注意的是RootConfig使用了@ComponentScan注解。这样我们就有很多机会用非Web的组件来充实完善RootConfig。
5.2 编写基本的控制器
在Spring MVC中,控制器只是方法上添加了@RequestMapping注解的类,这个注解声明了它们所要处理的请求。
一个能处理对"/"的请求并渲染应用首页的最简单的控制器:
/WEB-INF/views/home.jsp:
测试控制器最直接的办法可能就是构建并部署应用,然后通过浏览器对其进行访问,但是自动化测试可能会给你更快的反馈和更一致的独立结果。所以,让我们编写一个针对HomeController的测试。
5.2.1 测试控制器
从Spring 3.2开始,我们可以按照控制器的方式来测试Spring MVC中的控制器了,而不仅仅是作为POJO进行测试。Spring现在包含了一种mock Spring MVC并针对控制器执行HTTP请求的机制。这样的话,在测试控制器的时候,就没有必要再启动Web服务器和Web浏览器了。
测试中,发起了对"/"的GET请求,并断言结果视图的名称为home。它首先传递一个HomeController实例到MockMvcBuilders.standaloneSetup()并调用build()来构建MockMvc实例。然后它使用MockMvc实例来执行对“/”的GET请求并设置期望得到的视图名称。
5.2.2 定义类级别的请求处理
现在,已经为HomeController编写了测试,那么我们可以做一些重构,并通过测试来保证不会对功能造成什么破坏。我们可以做的一件事就是拆分@RequestMapping,并将其路径映射部分放到类级别上。
当控制器在类级别上添加@RequestMapping注解时,这个注解会应用到控制器的所有处理器方法上。处理器方法上的@RequestMapping注解会对类级别上的@RequestMapping的声明进行补充。
当我们在修改@RequestMapping时,还可以对HomeController做另外一个变更。@RequestMapping的value属性能够接受一个String类型的数组。到目前为止,我们给它设置的都是一个String类型的“/”。但是,我们还可以将它映射到对“/homepage”的请求,只需将类级别的@RequestMapping改为如下所示:
5.2.3 传递模型数据到视图中
大多数的控制器并不是这么简单。在Spittr应用中,我们需要有一个页面展现最近提交的Spittle列表。因此,我们需要一个新的方法来处理这个页面。
首先,需要定义一个数据访问的Repository。为了实现解耦以及避免陷入数据库访问的细节之中,我们将Repository定义为一个接口,并在稍后实现它(第10章中)。此时,我们只需要一个能够获取Spittle列表的Repository,如下所示的SpittleRepository功能
findSpittles()方法接受两个参数。其中max参数代表所返回的Spittle中,Spittle ID属性的最大值,而count参数表明要返回多少个Spittle对象。
Spittle类:我们让它尽可能简单,其属性包括消息内容、时间戳以及Spittle发布时对应的经纬度。
就大部分内容来看,Spittle就是一个基本的POJO数据对象——没有什么复杂的。唯一要注意的是,我们使用Apache Common Lang包来实现equals()和hashCode()方法。这些方法除了常规的作用以外,当我们为控制器的处理器方法编写测试时,它们也是有用的。
为新的控制器编写测试:
新的SpittleController:Model实际上就是一个Map(也就是key-value对的集合),它会传递给视图,这样数据就能渲染到客户端了。当调用addAttribute()方法并且不指定key的时候,那么key会根据值的对象类型推断确定。在本例中,因为它是一个List<Spittle>,因此,键将会推断为spittleList。
spittles方法的多种替代方案:
注意: 这个版本与其他的版本有些差别。它并没有返回视图名称,也没有显式地设定模型,这个方法返回的是Spittle列表。当处理器方法像这样返回对象或集合时,这个值会放到模型中,模型的key会根据其类型推断得出(在本例中,也就是spittleList)。而逻辑视图的名称将会根据请求路径推断得出。因为这个方法处理针对“/spittles”的GET请求,因此视图的名称将会是spittles(去掉开头的斜线)。
不管你选择哪种方式来编写spittles()方法,所达成的结果都是相同的。模型中会存储一个Spittle列表,key为spittleList,然后这个列表会发送到名为spittles的视图中。按照我们配置InternalResourceViewResolver的方式,视图的JSP将会是“/WEB-INF/views/spittles.jsp”。
JSP文件省略,浏览器效果省略……
最后,SpittleController和HomeController都没有处理任何形式的输入。现在,让我们扩展SpittleController,让它从客户端接受一些输入。
5.3 接受请求的输入
Spring MVC允许以多种方式将客户端中的数据传送到控制器的处理器方法中,包括:
- 查询参数(Query Parameter)。
- 表单参数(Form Parameter)。
- 路径变量(Path Variable)。
5.3.1 处理查询参数
在Spittr应用中,我们可能需要处理的一件事就是展现分页的Spittle列表。在现在的SpittleController中,它只能展现最新的Spittle,并没有办法向前翻页查看以前编写的Spittle历史记录。如果你想让用户每次都能查看某一页的Spittle历史,那么就需要提供一种方式让用户传递参数进来,进而确定要展现哪些Spittle集合。
在确定该如何实现时,假设我们要查看某一页Spittle列表,这个列表会按照最新的Spittle在前的方式进行排序。因此,下一页中第一条的ID肯定会早于当前页最后一条的ID。所以,为了显示下一页的Spittle,我们需要将一个Spittle的ID传入进来,这个ID要恰好小于当前页最后一条Spittle的ID。另外,你还可以传入一个参数来确定要展现的Spittle数量。
为了实现这个分页的功能,我们所编写的处理器方法要接受如下的参数:
- before参数(表明结果中所有Spittle的ID均应该在这个值之前)。
- count参数(表明在结果中要包含的Spittle数量)。
为了实现这个功能,我们将spittles()方法替换为使用before和count参数的新spittles()方法。我们首先添加一个测试,这个测试反映了新spittles()方法的功能。
新的控制器:
处理器要同时处理有参数和无参数得场景, 同时如果这些参数在请求中不存在的话, 就使用默认值Long.MAX_VALUE和20。
现在,如果max参数没有指定的话,它将会是Long类型的最大值。因为查询参数都是String类型的,因此defaultValue属性需要String类型的值。因此,使用Long.MAX_VALUE是不行的。我们可以将Long.MAX_VALUE转换为名为MAX_LONG_-AS_STRING的String类型常量:
尽管defaultValue属性给定的是String类型的值,但是当绑定到方法的max参数时,它会转换为Long类型。
5.3.2 通过路径参数接受输入
假设我们的应用程序需要根据给定的ID来展现某一个Spittle记录。其中一种方案就是编写处理器方法,通过使用@RequestParam注解,让它接受ID作为查询参数。
这将会形成如“/spittles/show?spittle_id=12345”这样的请求。尽管这也可以正常工作,但是从面向资源的角度来看这并不理想。在理想情况下,要识别的资源(Spittle)应该通过URL路径进行标示,而不是通过查询参数。对“/spittles/12345”发起GET请求要优于对“/spittles/show?spittle_id=12345”发起请求。前者能够识别出要查询的资源,而后者描述的是带有参数的一个操作——本质上是通过HTTP发起的RPC。
既然已经以面向资源的控制器作为目标,那我们将这个需求转换为一个测试。它会断言控制器中对面向资源请求的处理。
测试中对“/spittles/12345”发起GET请求,然后断言视图的名称是spittle,并且预期的Spittle对象放到了模型之中。因为我们还没有为这种请求实现处理器方法,因此这个请求将会失败。但是,我们可以通过为SpittleController添加新的方法来修正这个失败的测试。
到目前为止,在我们编写的控制器中,所有的方法都映射到了(通过@RequestMapping)静态定义好的路径上。但是,如果想让这个测试通过的话,我们编写的@RequestMapping要包含变量部分,这部分代表了Spittle ID。
为了实现这种路径变量,Spring MVC允许我们在@RequestMapping路径中添加占位符。占位符的名称要用大括号(“{”和“}”)括起来。路径中的其他部分要与所处理的请求完全匹配,但是占位符部分可以是任意的值。
我们可以看到,spittle()方法的spittleId参数上添加了@PathVariable(“spittleId”)注解,这表明在请求路径中,不管占位符部分的值是什么都会传递到处理器方法的spittleId参数中。
因为方法的参数名碰巧与占位符的名称相同,因此我们可以去掉@PathVariable中的value属性:
如果@PathVariable中没有value属性的话,它会假设占位符的名称与方法的参数名相同。
最后, 如果传递请求中少量的数据,那查询参数和路径变量是很合适的。但通常我们还需要传递很多的数据(也许是表单提交的数据),那查询参数显得有些笨拙和受限了。下面让我们来看一下如何编写控制器方法来处理表单提交。
5.4 处理表单
大多数的Web应用允许用户填充表单并将数据提交回应用中,通过这种方式实现与用户的交互。像提供内容一样,Spring MVC的控制器也为表单处理提供了良好的支持。
使用表单分为两个方面:展现表单以及处理用户通过表单提交的数据。在Spittr应用中,我们需要有个表单让新用户进行注册。SpitterController是一个新的控制器,目前只有一个请求处理的方法来展现注册表单。
该方法针对“/spitter/register”的GET请求,将会使用“/WEB-INF/ views/registerForm.jsp”这个JSP来渲染注册表单。
showRegistrationForm()方法的测试 :
现在,让我们回到视图上。因为视图的名称registerForm,所以JSP的名称需要是registerForm.jsp。这个JSP必须要包含一个HTML<form>标签,在这个标签中用户输入注册应用的信息。如下就是我们现在所要使用的JSP。
需要注意的是:这里的\<form>标签中并没有设置action属性。在这种情况下,当表单提交时,它会提交到与展现时相同的URL路径上。也就是说,它会提交到“/spitter/register”上。
这就意味着需要在服务器端处理该HTTP POST请求。现在,我们在Spitter-Controller中再添加一个方法来处理这个表单提交。
5.4.1 编写处理表单的控制器
当处理注册表单的POST请求时,控制器需要接受表单数据并将表单数据保存为Spitter对象。最后,为了防止重复提交(用户点击浏览器的刷新按钮有可能会发生这种情况),应该将浏览器重定向到新创建用户的基本信息页面。这些行为通过下面的shouldProcessRegistration()进行了测试。
显然,这个测试比展现注册表单的测试复杂得多。在构建完SpitterRepository的mock实现以及所要执行的控制器和MockMvc之后,shouldProcess-Registration()对“/spitter/register”发起了一个POST请求。作为请求的一部分,用户信息以参数的形式放到request中,从而模拟提交的表单。
在处理POST类型的请求时,在请求处理完成后,最好进行一下重定向,这样浏览器的刷新就不会重复提交表单了。在这个测试中,预期请求会重定向到“/spitter/jbauer”,也就是新建用户的基本信息页面。
最后,测试会校验SpitterRepository的mock实现最终会真正用来保存表单上传入的数据。
处理表单提交的控制器方法 :
请注意新创建的processRegistration()方法,它接受一个Spitter对象作为参数。这个对象有firstName、lastName、username和password属性,这些属性将会使用请求中同名的参数进行填充。
当InternalResourceViewResolver看到视图格式中的“redirect:”前缀时,它就知道要将其解析为重定向的规则,而不是视图的名称。在本例中,它将会重定向到用户基本信息的页面。例如,如果Spitter.username属性的值为“jbauer”,那么视图将会重定向到“/spitter/jbauer”。
需要注意的是,除了“redirect:”,InternalResourceViewResolver还能识别“forward:”前缀。当它发现视图格式中以“forward:”作为前缀时,请求将会前往(forward)指定的URL路径,而不再是重定向。
最后, 添加一个处理器方法,用来处理对基本信息页面的请求。
5.4.2 校验表单
如果用户在提交表单的时候,username或password文本域为空的话,那么将会导致在新建Spitter对象中,username或password是空的String。至少这是一种怪异的行为。如果这种现象不处理的话,这将会出现安全问题,因为不管是谁只要提交一个空的表单就能登录应用。
同时,我们还应该阻止用户提交空的firstName和/或lastName,使应用仅在一定程度上保持匿名性。有个好的办法就是限制这些输入域值的长度,保持它们的值在一个合理的长度范围,避免这些输入域的误用。
有种处理校验的方式非常初级,那就是在processRegistration()方法中添加代码来检查值的合法性,如果值不合法的话,就将注册表单重新显示给用户。
与其让校验逻辑弄乱我们的处理器方法,还不如使用Spring对Java校验API(Java Validation API,又称JSR-303)的支持。从Spring 3.0开始,在Spring MVC中提供了对Java校验API的支持。在Spring MVC中要使用Java校验API的话,并不需要什么额外的配置。只要保证在类路径下包含这个Java API的实现即可,比如Hibernate Validator。
Java校验API定义了多个注解,这些注解可以放到属性上,从而限制这些属性的值。所有的注解都位于javax.validation.constraints包中。下表列出了这些校验注解。
除了上表中的注解,Java校验API的实现可能还会提供额外的校验注解。同时,也可以定义自己的限制条件。
请考虑要添加到Spitter域上的限制条件,似乎需要使用@NotNull和@Size注解。我们所要做的事情就是将这些注解添加到Spitter的属性上。
我们已经为Spitter添加了校验注解,接下来需要修改processRegistration()方法来应用校验功能。启用校验功能的processRegistration()如下所示:Spitter参数添加了@Valid注解,这会告知Spring,需要确保这个对象满足校验限制。
在Spitter属性上添加校验限制并不能阻止表单提交。即便用户没有填写某个域或者某个域所给定的值超出了最大长度,processRegistration()方法依然会被调用。这样,我们就需要处理校验的错误,就像在processRegistration()方法中所看到的那样。
如果有校验出现错误的话,那么这些错误可以通过Errors对象进行访问,现在这个对象已作为processRegistration()方法的参数。(很重要一点需要注意,Errors参数要紧跟在带有@Valid注解的参数后面,@Valid注解所标注的就是要检验的参数。)processRegistration()方法所做的第一件事就是调用Errors.hasErrors()来检查是否有错误。
如果有错误的话,Errors.hasErrors()将会返回到registerForm,也就是注册表单的视图。这能够让用户的浏览器重新回到注册表单页面,所以他们能够修正错误,然后重新尝试提交。现在,会显示空的表单,但是在下一章中,我们将在表单中显示最初提交的值并将校验错误反馈给用户。