Spring MVC 笔记

Spring MVC 全称 Spring Web MVC,是 Struts2 的对标产品。Struts2 曾经是最流行的 MVC 框架,但由于存在过多安全漏洞,现在越来越多的开发团队选择了 Spring MVC,因为 Spring MVC 是 Spring Framework 家族中的一个,相比较 Struts2,Spring MVC 更容易与 Spring 框架相契合,上手难度也低于 Struts2。自从 Spring MVC 2.5 版本引入注解驱动功能后,Controller 已经不再需要继承任何接口,无需在 XML 配置文件中定义请求和 Controller 的映射关系,仅仅使用注解就可以让一个 POJO 具有 Controller 的全部功能,使得 Spring MVC 框架的易用性又得到了进一步的增强。在框架灵活性、易用性和扩展性上,Spring MVC 已经全面超越了其它的 MVC 框架,伴随着 Spring 一路高唱猛进,可以预见 Spring MVC 在 MVC 市场上的吸引力将越来越不可抗拒。

Spring MVC 介绍

SSM 三大框架是目前主流的 Java Web 开发框架,即 Spring、SpringMVC、MyBatis。其中 Spring 我们前面已经学习过了,现在我们紧接着开始学习 Spring MVC,学完 Spring MVC 后就学习 MyBatis 持久化框架。最后将这三大框架进行整合,变成 SSM 框架组合,然后就是项目练手了。

Spring MVC 和 Spring 框架是紧密结合的,先学习 Spring 的 IoC 容器,对 SpringMVC 的学习非常有帮助,在 Spring MVC 中,我们依旧会使用 Spring 的 IoC 容器,而且是必不可少的,Spring MVC 是建立在 Spring 的 IoC 容器之上的(Spring 的 IoC 容器是 Spring 框架的核心概念,必须熟练掌握)。

关于 MVC,我们再来熟悉一下相关的处理流程。首先客户端请求到达 Controller 控制器,Controller 负责处理用户发来的请求,然后将处理结果包装为 Model(POJO 对象),最后将 Model 对象传递给用来渲染页面的 View;View 一般就是 JSP,在 JSP 中,可以通过 EL 表达式来获取 Model 对象中的数据,然后渲染页面,最后返回给请求用户。

可以发现,Controller 的职责就是用来处理用户请求的,处理完一般都会有一个结果,为了方便传递,我们一般都会将处理完得到的数据封装到一个对象中(Bean/POJO),然后将这个结果对象通过 setAttribute() 方法存放到 request 对象中,然后将 request 交给 view 处理。

view 通常就是 JSP 文件,所以我们可以通过 EL 表达式来获取 request 对象中的 model 数据,在需要的时候还可以通过 jstl 来进行简单的逻辑判断,循环处理等,最后将渲染得到的页面返回给请求客户端,这样,一个 request 就算完成了它的使命了。

view 中的逻辑一般都很轻松,就是从 request 中读取 model 数据,然后渲染页面。而 controller 是处理请求的地方,为了避免 controller 过于庞大,一般我们的应用程序都不会直接在 controller 处理业务逻辑,而是将业务逻辑放到一个叫做 service 的地方,在大多数简单的情况下,controller 内部仅仅是调用对应的 service 类而已。所以 service 是我们写业务逻辑的地方;而又因为 service 通常又会与数据库进行交互,为了解耦,我们又抽象出了一个 dao 层,dao 就是 data access object,封装常用的数据库操作,方便 service 层调用。即 controller -> service -> dao -> 数据库系统。

这样我们的 web 应用又分为了 3 层(由外到内排列):web 层业务层持久层

  • web 层:controller + view + model,负责处理用户请求。
  • 业务层:service + service impl,负责业务逻辑实现。
  • 持久层:dao + dao impl,负责封装数据库访问。

Spring MVC 处理流程

pom.xml

classpath:classpath*: 加载类路径下的资源文件的区别
在 spring 中,我们经常需要加载类路径下的一些资源文件,比如 properties 属性文件,又比如 web.xml 中的 contextConfigLocation init-param 指定的 spring/springmvc 配置文件,它们通常为:classpath:conf/appContext.xml 或者 classpath*:conf/appContext.xml,当然有些时候也可以不带任何 classpath 前缀,即直接写 conf/appContext.xml,虽然大多数时候它都能够正常工作,但是不建议这么写,要么使用 classpath: 要么使用 classpath*:,它们几个都是用来从 jvm 的 classpath 路径下加载文件的,那么究竟有什么区别呢?classpath: 只会加载 classpath 路径中找到的第一个资源文件,如果一个都没找到则报错,如果找到多个,只会使用第一个(究竟使用哪个是不确定的);而 classpath*: 则会从 classpath 路径中查找所有名为 conf/appContext.xml 文件,即使一个都没有找到也不会报错(特别注意这个),如果找到多个则会将它们合并为一个文件来使用。

Spring MVC HelloWorld

Spring MVC 测试环境:

  • JDK 1.8
  • Maven 3.5.4
  • Tomcat 8.5.29
  • Spring 4.3.20

创建项目目录
mvn -B archetype:generate -DarchetypeArtifactId=maven-archetype-webapp -DgroupId=com.zfl9 -DartifactId=springmvc-learn

编辑 pom.xml

简单解释一下:

  • maven.test.skip 表示跳过测试步骤(HelloWorld 不需要什么测试,多此一举)。
  • maven.compiler.sourcemaven.compiler.target 用来指定 JDK 版本为 1.8。
  • org.springframework:spring-webmvc:4.3.20.RELEASE 引入 spring mvc 4.x 依赖。
  • 而 warName 和 outputDirectory 是用来将 war 包自动部署到 tomcat 的 webapp 目录的。

上面的 pom.xml 是单纯使用 Maven 管理的,其实如果使用 Idea 等 IDE 工具的话,完全不需要配置什么 warName、outputDirectory,因为直接 Shift + F10 就能运行了(Idea 配合 IdeaVim 插件简直无敌)。spring-webmvc 模块会依赖 spring-context、spring-web,所以我们并不需要在 pom.xml 中配置它们。

项目目录结构

  • web.xml:部署描述符文件
  • mvc.xml:spring mvc 配置文件
  • index.jsp:简单的 index.jsp 首页
  • com.zfl9.controller:Controller 包
  • WEB-INF/views:View 存放的目录(JSP)

mvc.xml 其实就是我们前面学习 Spring 时候的 beans.xml、spring.xml,即 Spring 配置文件。注意 view 文件存放的路径,我们并没有放到 webapp 目录下,因为这样客户端就能直接访问我们的 jsp 了,而因为这些 view 里面的 jsp 一般都需要读取经 controller 处理得到的 model 数据,所以直接访问它们通常都会出现错误,甚至可能被黑客利用,所以我们一般都会把 view 放到 WEB-INF 目录下,WEB-INF 是说所谓的“安全目录”,这样外部就不能访问这些 jsp 了,只能访问我们暴露出去的 url,这些 url 通常都是映射到某个 controller,只有经过 controller 处理后,才访问这些 jsp(forward 过去),这样就安全多了。

web.xml

  • encodingFilter 用来规范 request 和 response 的字符编码为 UTF-8。
  • DispatcherServlet 对应的 URL 为 /,即所有 url 都映射到 springmvc。
  • contextConfigLocation 初始化参数则用来指定 springmvc 配置文件的路径。
  • contextConfigLocation 默认路径 /WEB-INF/${servlet-name}-servlet.xml

注意 init-param 不能放在 load-on-startup 标签后面,idea 会报错,放到前面就没问题。

mvc.xml

通过前面的 web.xml 可以得知,Tomcat 收到的任何 HTTP 请求都将被路由给 springmvc,因为 springmvc 对应的 url-pattern 为 /。因为 springmvc 的 load-on-startup 值为 1,所以 springmvc 会在容器启动时进行初始化。springmvc 初始化过程中,会读取 mvc.xml 配置文件,初始化 IoC 容器,可以得知要扫描的 package 为 com.zfl9(包括所有子包)、对应的 View 视图路径为 /WEB-INF/views、视图后缀名为 .jsp。HTTP 请求到达 springmvc 后,springmvc 首先查询 Handler Mapping,获取 url 对应的 controller,然后将该 request 交给 controller 处理,controller 处理完后返回一个 ModelAndView 对象(携带 Model 数据的 View 对象),然后通过查询 View Resolver,获取 ModelAndView 对象对应的视图页面(通常为 JSP),接着将 model 数据传递给对应的 view 页面,在 view 页面渲染完成后,springmvc 将最终的响应结果传回给客户端。如下图所示:
Spring MVC 的工作流程

简单的说就是这几个步骤:

  1. 查询 Handler Mapping 得知处理此请求的 Controller;
  2. 将此请求交给对应的 Controller 处理,返回处理结果;
  3. 查询 View Resolver 得知生成响应结果的 View;
  4. 将此请求交给对应的 View 生成响应页面;
  5. Dispatcher 将响应结果返回给浏览器。

HelloWorldController

  • @Controller 注解表示对应的类是一个 Controller。
  • @RequestMapping 注解用来指定方法对应的 URL 路径。
  • @RequestParam 注解表示 name 参数对应的 url 查询参数。
  • new ModelAndView("helloworld") 的 helloworld 为 view 名。
  • mav.addObject("name", name) 方法用来添加 name-value 名值对。

很容易知道,Controller 中被 @RequestMapping 标注的方法都是用来处理用户请求的,每个请求处理方法都有两个必要元素,一个是 @RequestMapping 里面指定的 uri,发送给该 uri 的请求都将被该方法处理;另一个就是请求处理方法中返回的 view 名/对象。在 ModelAndView 中,构造函数的参数就是 view 名,View Resolver 会结合 prefix 和 suffix 来确定 view 的绝对路径,然后将 request 转发给 view 处理。

@Controller 是 @Component 的子接口,还记得吗?@Component 有 3 个子接口:

  • @Component:组件,最普通的 bean,当 bean 不好归类时使用可以使用这个注解;
  • @Controller:控制器,一般用在传统 Web 应用的控制层,是 @Component 的子注解;
  • @Service:代表业务组件,一般用在传统 Web 应用的业务层,是 @Component 的子注解;
  • @Repository:代表持久化组件,一般用在传统 Web 应用的持久层,是 @Component 的子注解。

这里我们提到了 控制层业务层持久层,注意不要和 MVC 模型搞混了,虽然它们都是所谓的“三层结构”。所谓控制层就是我们常说的 controller/action 层,而业务层就是 service 层,持久层就是 dao 层。此外我们还有一个 model 层(准确来说应该是包)。controller 层又被称为 web 层,web 层位于最外边,是与 web 服务器(tomcat)直接交互的一个层面,web 层除了 controller 外,还有 view。在 controller 中,不建议编写任何复杂的逻辑,基本上就是调用对应的 service 层,service 层才是我们编写具体业务代码的地方,通常我们的业务逻辑都需要与数据库进行交互,但我们并不会直接与数据库进行交互,而是通过 dao 层,dao 即 data access object(数据访问对象),所谓 dao 就是对数据库访问逻辑的封装。

调用层次为:controller -> service -> dao;controller 返回 model 给 view,最后返回浏览器。

再次强调,controller 不建议编写任何与业务相关的逻辑,大多数简单的情况下,controller 内部就是调用对应的 service 类,仅此而已。service 层才是编写业务逻辑的地方,而 dao 层就是编写数据库访问逻辑的地方,一定要记住它们的职责,不要搞混了。一般情况下,为了解耦,我们的 service 层会分为 service 接口和 service impl 实现,对应的,dao 层也会分为 dao 接口和 dao imple 实现。

典型的 web 应用程序结构为:

helloworld.jsp

  • ${name} 是一个 EL 表达式,用来从 request 中获取 addObject() 传递的对象。

index.jsp

编译、运行

applicationContext.xml vs dispatcher-servlet.xml

在有些 Spring MVC 教程中,会出现这两个配置文件,它们贴出来的 web.xml 像这样:

对比 HelloWorld 里面的 web.xml,可以发现这里的 web.xml 多了个 ContextLoaderListener 上下文加载监听器,还有就是多了个 context-param 上下文初始化参数。ContextLoaderListener 会从 contexxt-param 中读取 Spring 的配置文件路径(如果省略 context-param,那么默认路径就是 /WEB-INF/applicationContext.xml),ContextLoaderListener 会初始化一个 IoC 容器。

那么问题来了,ContextLoaderListener 和 DispatcherServlet 都会初始化对应的 IoC 容器,也就意味这 web 应用中有两个 Spring IoC 容器,一个是由 ContextLoaderListener 管理的,一个是由 DispatcherServlet 管理的。这两个 IoC 容器有什么区别呢?来看官方的解答:

Spring 允许您在父子层次结构中定义多个 ApplicationContext 上下文。applicationContext.xml 是 web 应用的 root 上下文(根上下文);${servlet-name}-servlet.xml 是 DispatcherServlet 中的上下文,在同一个 web 应用中运行有多个 DispatcherServlet 实例,每个 DispatcherServlet 都有属于自己的上下文。有关 MVC 的配置需要放到对应的 DispatcherServlet 上下文配置文件中,而 ApplicationContext 中的 bean 可以被所有 DispatcherServlet 上下文所共享,但是 ApplicationContext 不能获取 DispatcherServlet 上下文中的 bean(这很好理解)。

在大多数简单的情况下,applicationContext.xml 上下文是不必要的。除非你需要在多个 servlet 上下文中共享相同的 bean 实例,否则你完全不需要配置 ContextLoaderListener 和 applicationContext.xml。在绝大多数情况下,我们只需要配置一个 DispatcherServlet 和对应的 mvc.xml 就行了。我们的 bean 可以配置在 mvc.xml 中,在程序中可以通过 context 来获取这些 bean。

Model、ModelMap、ModelAndView

我们来回顾一下 HelloWorld 中的 Controller 代码:

@Controller 是 @Component 的子注解,表示对应的 POJO 类是一个 Controller 控制器。
@RequestMapping 注解的作用则是用来告诉 Handler Mapping,当前方法对应的 uri 路径。
当 dispatcher 收到 uri 为 /helloworld 的请求时,将交给 helloworld() 方法去处理。
helloworld() 是自定义的 POJO 方法,返回值为 ModelAndView,接收一个 http 请求参数。
name 参数上使用了 @RequestParam 注解,SpringMVC 会自动注入对应的请求参数到此参数上。
@RequestParam 的 name 为请求参数名,required 为是否为请求的,defaultValue 为默认值。
ModelAndView 是 Model 模型和 View 页面的合体类型,传入的构造参数是对应的 view 页面名。
mav 对象和 request 对象一样,可以存入 name-value 对,它等同于 request.setAttribute()。
本质上,ModelAndView 中的 addObject() 方法内部就是调用 request.setAttribute() 方法而已。
也因为如此,我们可以在 view 页面中,如 JSP,使用 EL 表达式来获取在 ModelAndView 中设置的值。

在 HelloWorld 例子中,我们使用的是 ModelAndView 传递 model 数据,其实有三种常用方式来传递 model 数据,分别是 Model、ModelMap、ModelAndView,据说前两者在内部都会转换为 ModelAndView 形式:

  • Model:常用方法 model.addAttribute(name, value)
  • ModelMap:常用方法 modelMap.addAttribute(name, value)
  • ModelAndView:常用方法 modelAndView.addObject(name, value)

Model 是一个接口,接口声明如下:

ModelMap 是 LinkedHashMap<String, Object> 的子类,方法与 Model 相似,如下:

ModelAndView 是最原始的传值方式,但也是最强大的,因为 ModelAndView 不仅仅是传值,还有其它操作。

其中,Model 和 ModelMap 对象可以放在方法参数中,Spring MVC 会自动注入对应的实例,而 ModelAndView 需要自己 new 出来,我们改写一下前面的 HelloWorld 控制器,如下:

注意,helloA()、helloB()、helloC() 方法返回的 view 页面都为 helloworld,结合 mvc.xml 中的 prefix 和 suffix,Spring MVC 就可以知道 view 页面的绝对路径为 /WEB-INF/views/helloworld.jsp:

测试没问题。分别返回 Hello, WorldAHello, WorldBHello, WorldC

更正,ModelAndView 对象其实也是可以让 Spring MVC 生成的,例子:
HelloController.java

hello.jsp

测试结果:

注意,我们前面说了,是通常情况下,有上面这三种返回模型数据的方法,但其实我们还有其它几个方法:

1、返回 java.util.Map,view 名为 @RequestMapping 中的名称(/WEB-INF/views/helloD.jsp):

2、返回 void,view 名为 @RequestMapping 中的名称(/WEB-INF/views/helloD.jsp):

3、返回 String,其实就是前面 Model、ModelMap 的简化版,返回的 String 就是对应的 view 名:

4、返回 String 作为响应体(不需要 view,直接返回字符串作为响应体),使用 @ResponseBody 注解:

5、返回 null,会发生什么结果呢?答案是得到 404 Not Found 响应!!!面试题哦!!!

@RequestMapping 详解

@RequestMapping 可以用在 Controller 上或者 Controller 中的方法上,如果用在 Controller 上,则相当于给所有方法上的 @RequestMapping 加上了父路径,比如在类上的 uri 为 /home,方法上的 uri 为 /list,则该方法实际映射到的 uri 为 /home/list,例子,访问地址为 /wmyskxz/hello

基本上我们可以认为,放在 Controller 上面的 @RequestMapping 要么是像上面那样提供一个父路径,要么就是给 Controller 中的方法提供默认值,这个默认值可以被方法中的 @RequestMapping 的注解所覆盖。

  • String name:@RequestMapping 的名称,不常用
  • String[] value:映射的 uri,支持 ant 风格模式
  • String[] path:映射的 uri,支持 ant 风格模式,v4.2+
  • RequestMethod[] method:限定请求方法,如 GET、POST
  • String[] params:限定请求的查询参数,见后面的表达式语法
  • String[] headers:限定请求的请求头部,见后面的表达式语法
  • String[] consumes:限定请求的ContentType,见后面的表达式语法
  • String[] produces:限定请求的 Accept 类型,见后面的表达式语法

所谓 ant-style pattern 就是这三个通配符:

  • ?:匹配任意单个字符
  • *:匹配任意长度字符
  • **:匹配任意深度目录

method 属性则是用来限定对应方法可以处理的 HTTP 请求方法的,默认是全部都可以处理,如果需要我们可以将限定为只处理 GET 请求。从 Spring 4.3 版本开始,提供了 4 个方便的注解,分别用来指定 GET、POST、PUT、DELETE 请求方法的 @RequestMapping(实际就是子注解,属性相同,除了没有 method),分别为:

  • @PutMapping:@ReqestMapping 的子注解,HTTP PUT 方法
  • @GetMapping:@RequestMapping 的子注解,HTTP GET 方法
  • @PostMapping:@RequestMapping 的子注解,HTTP POST 方法
  • @DeleteMapping:@RequestMapping 的子注解,HTTP DELETE 方法

params 属性表示只有符合指定条件的请求才会被对应方法处理,后面的 headers、consumes、produces 都是差不多的作用,用来进行条件限定的。如 param=value 表示请求必须带有 param 参数且值为 value,否则不会被当前方法处理;param!=value 则与它相反;param 表示请求必须带有 parm 参数,不限定它的值;而 !param 则与它相反。

headers 属性的作用和 params 属性的作用相似,只不过这是用来限定请求头字段的,语法为 header=valueheader!=valueheader!header,对于 MIME 类型,支持 * 通配符。

consumes 属性和 params/headers 属性差不多,但这是用来限定请求的 Context-Type 类型的,比如 text/html 表示只处理请求 MIME 为 text/html 的请求,其它类型的请求不进行处理,MIME 中可以出现通配符,如 text/*,也可以出现 ! 取反符号,如 !text/plain 表示出了 text/plain 外的都会处理。produces 属性和 consumes 属性相似,但 produces 是用来限定请求头中的 Accept 字段的。

例一:(实现效果同下,不推荐)

例二:(实现效果同上,推荐)

例三:RESTFul API 风格:

@RequestParam 详解

用在方法参数上,表示该参数将接受对应的 HTTP 请求参数(查询参数、表单数据、文件上传等),属性有:

  • String value:name 属性的别名,默认同参数名;
  • String name:要绑定的请求参数名称,默认同参数名;
  • boolean required:请求参数是否是必选的,默认 true;
  • String defaultValue:当请求参数未提供时,设置该默认值。
  • 如果设置了 defaultValue,则 required 属性会被设为 false。
  • 如果没有提供请求参数或者查询参数值为空,则触发 defaultValue。

例子,我们可以将上面的 HelloWorld 改为这样(效果一样):

扩展:如果处理方法的参数与查询参数同名,那么即使没有 @RequestParam 注解也会被注入对应的值:

测试一下,没带参数就是打印 Hello, WorldD!,带参数就是打印 Hello, ${name}!

虽然可行,但最好还是使用 @RequestParam 注解标明一下,就如同少了 @Override 注解一样能工作一样。

@ModelAttribute 注解

上一节提到了 @RequestParam 注解,它的作用是将请求参数注入到方法参数中;而 @ModelAttribute 注解的作用就如同它的名字一样,将请求参数直接注入到 Model 对象中(addAttribute 方法),什么意思呢?

前面我们说了,ModelAndView 是最古老的传值方式,而 Model 是较新的传值方式(ModelMap 和 Model 很相似,暂时忽略,一般用的最多的就是 Model 和 ModelAndView 两种),ModelAndView 需要自己手动在方法中 new 出来,而 Model 对象则是 Spring 自动注入的,不需要我们 new 出来。

现在假设这么一个情形,/queryUser 接收两个参数,username 和 password,分别表示用户的用户名和密码,为了方便,我们使用一个 User 类来表示一个用户,这个 User 类有两个私有成员,username 和 password,且这两个私有成员都有自己对应的公共 getter/setter 方法,如下:

用我们上面学的知识,我们可以这样写一个控制器方法:

queryUser.jsp 内容:

执行结果,没问题:

但我们其实可以让 Spring 自动将与 User 类的属性同名的查询参数注入到 User 类的 setter 方法,并且将 User 类的实例注入到 Model 对象中(调用 addAttribute() 方法),如下(@ModelAttribute 注解):

注意到我们的 @ModelAttribute 注解,它的作用就是自动将 User 实例注入到 Model 对象中,name 即使 User 类的首字母小写格式,value 就是 user 实例,User 实例会被 Spring 自动创建(无参构造函数),然后会根据同名查询参数,将参数值存放到对应的 setter 方法。与我们前面的代码是一样的。测试结果相同。

当然,你会发现去掉 @ModelAttribute 注解也能正常工作,如下,但是这不建议,可读性不如上面的这种:

当然,我们还可以改写一下这个例子,将 User 实例的注入放到一个单独的方法中:

需要注意的是,被 @ModelAttribute 注解的方法会在每个控制器方法前执行,要慎用。

发送 302 跳转

其实就是一个特殊的 view name 而已,即 redirect:/path/to/target/url

访问静态资源

注意我们前面的 web.xml 配置:

我们将所有 url 都映射到了 springmvc 这个 servlet。springmvc 基本上是这样工作的,当 springmvc 收到一个请求时,都会向 Handler Mapping 查询对应的 Controller 处理器(所谓请求处理器就是 Controller 里面的处理方法),如果找到了对应的请求处理器,就将请求交给它处理,如果没找到那就返回 404 错误。

但是如果我们要访问静态资源,就会出现问题了,比如我们在根目录下下有一个 /images/site.png 图片资源,通常我们想直接通过 http://localhost/$contextPath/images/site.png 来访问它,而不是先定义一个 Controller 处理器来访问,怎么办呢?如果不进行任何配置,当你访问这个 url 的时候就会得到一个 404 Not Found 错误。

这时候你就会感到奇怪了,为什么我们的 Hello World 例子中的根目录下的 index.jsp 能直接访问呢?是因为 jsp 有什么特殊待遇么?是的,经过我测试,Spring MVC 不认为 JSP 是静态资源,所以能直接访问(当然是除了 WEB-INF 目录下的文件)。

那么对于其他静态资源,如果想要直接访问,该怎么处理呢?别慌,Spring MVC 提供两个解决办法:

  • <mvc:default-servlet-handler/>:最简单的方式,将静态资源交给 default Servlet 处理。
  • <mvc:resources mapping="${uri}" location="${path}"/>:手动进行 resources 资源映射。

第一种方式最简单,即将静态资源交给 default 这个 Servlet 去处理,动态资源(包括 JSP)则交给 SpringMVC 这个 Servlet 去处理,配置如下:

第二种方式也很好理解,mapping 表示映射出去的 uri 路径,而 location 表示实际对应的目录:

项目上下文问题

注意,如果你喜欢使用绝对路径,你可以在 jsp 页面中这样做:

其中 ctx 变量就是我们的上下文路径,注意即使是根路径,也要记得加上 /,否则会变成空字符串!!!

进阶版 HelloWorld

目录结构:

一个员工管理系统,目前只有一个功能,那就是列出所有员工的信息。

Employee.java

EmployeeDao.java

EmployeeDaoImpl.java

EmployeeService.java

EmployeeServiceImpl.java

EmployeeController.java

Employee_ListAll.jsp

index.html

测试结果:Spring MVC Hello World 进阶版

简单解析:我们采用了文章开头的 web层、业务层、控制层这样的三层结构,其中 Service 和 Dao 层都采用了面向接口编程的方式,注意几个特殊的注解,我们在 Controller 类上使用了 @Controller 注解,在 ServiceImpl 类上使用了 @Service 注解,在 DaoImpl 类上使用了 @Repository 注解,然后我们再 ServiceImpl 类中使用 @Autowired 来自动装配 DaoImpl 实现类,同理,我们在 Controller 类中使用 @Autowired 来自动装配 ServiceImpl 实现类,所以我们的控制处理器方法就是直接调用 serviceImpl 的 getAllEmployee 方法,然后将得到的 list 存放到 request 域中。在 jsp 文件中,我们使用 jstl 的 forEach 标签来遍历 List 中的元素,打印一个表格,最终效果如上。

一个忠告:在 WEBAPP 中,尽量使用相对路径,不要使用绝对路径,不要使用绝对路径,不要使用绝对路径,就如同上面的 HTML 文件中的 employee/listAll 路径一样,相对路径是兼容性最好的,如果使用绝对路径,那么当你将 webapp 放到非 ROOT 目录时就会出现 404 错误,因为它不会自动加上 context 的路径前缀!!!

数据绑定相关的注解

在这之前,我们先来学习几个常用的 数据绑定 相关的注解。
根据处理的 Request 的不同部分,将它们分为四类(常用的):

  • 处理 requet uri(不含 queryString)的注解:@PathVariable
  • 处理 request header 的注解:@RequestHeader@CookieValue
  • 处理 request body 的注解:@RequestParam@RequestBody@RequestPart
  • 处理 attribute 类型的注解:@ModelAttribute@SessionAttribute@SessionAttributes

注意,html 表单的编码方式为 application/x-www-form-urlencoded,可以使用 GET 和 POST 两种提交方式,编码方式是一样的,只不过 GET 方式是将 param 附着在 uri 的查询参数上,而 POST 则是作为请求体发送到服务端;但是无论如何,Servlet-API 解析 application/x-www-form-urlencoded 数据的方式是一致的,与 GET 还是 POST 无关。所以上面的分类其实还可以细分为 5 类,即处理 queryString 的注解为 @RequestParam,不过其实 @RequestParam 可以解析 quertString、get/post 提交的表单数据、multipart 文件上传等类型的请求数据,大家理解就行。

说到 @PathVariable 注解,就不得不先提一下 @RequestMapping(衍生的 @GetMapping、@PostMapping、@PutMapping、@PatchMapping、@DeleteMapping 同理,这个就不用多说了吧),@RequestMapping 里面有一个 path 属性,指定对应的处理器映射到的 uri 路径,这个 uri 有一个特殊模式。@RequestMapping 是一个用来处理请求地址映射的注解,可用于类或方法上。用于类上,表示类中的所有响应请求的方法都是以该地址作为父路径。@RequestMapping 有 6 个属性,我们先来详细了解 path/value 属性,分为 3 种:

  • /employees:普通字符串,对应的 uri 为 /employees
  • /employees/{id}:包含变量定义的字符串,变量名为 id,匹配 uri 上对应的字符串;
  • /employees/{id:\d{1,5}}:包含变量定义的正则字符串,变量名为 id,匹配对应正则匹配的字符串。

@PathVariable 注解关心的就是后两种 uri 类型,说是说两种,其实就是一种,包含变量的 path 而已,只不过我们可以接一个 : 然后写上 regex 匹配模式而已。id 就是匹配到的字符串的变量名,待会有用。

那么 @PathVariable 怎么使用呢?当然是用在方法参数上了,接收匹配到的字符串啦,注解有一个常用属性 value,表示对应参数要绑定到的 path variable 变量名,当参数名和变量名相同时可以省略,建议这么做。

@RequestHeader 注解用来从 http request 中绑定对应的 header 头部值。@CookieValue 注解用来绑定 http request 中对应的 cookie 键值对,使用例子:

@RequestParam 注解可以用来解析 query parameters(get), form data(post), and parts in multipart requests. 简单的说就是用来解析表单数据和文件上传相关的(解析文件上传需要一些额外的工作),我们暂且不关心 multipart 请求(即文件上传),@RequestParam 通常用来绑定简单类型的数据(或者说标量数据),即从 String 转换为目标类型,如 String、整数、小数、日期等。所以对于 MIME 为 application/jsonapplication/xml 的请求时(通常是 RESTful 请求),那么就不能使用 @RequestParam 来解析,而是要用 @RequestBody 来解析(很好理解,其实)。

@RequestParam 还有一个用法没有提到,如果对应的参数是 Map<String, String>MultiValueMap<String, String>,并且注解属性 value 没有指定,那么 Spring 会将所有 parameters 放到 map 中;如果指定了 value 属性,则只存放指定的那个 param,例子:

@RequestPart@RequestParam 都可以处理 multipart 请求(即文件上传),但是 @RequestPart 是专门为了处理 multipart 请求而生的,而 @RequestParam 可以处理表单数据、查询字符串、multipart,基本上两者没差别,可以互换,一般情况下,使用 @RequestParam 就行了,所以 @RequestPart 基本可忽略。

@ModelAttribute 可以注解控制器方法,也可以注解控制器方法的参数。

如果用在方法上,则这个方法会在所有 @RequestMapping 方法前执行,这个方法可以拥有与 @RequestMapping 相同的方法参数(自动注入),该方法如果返回一个对象,则这个对象会存储到 Model 作用域中(其实就是 request 作用域),name 默认是类名的首字母小写形式,当然也可以在注解上指定 name。

如果用在方法参数上,则 @ModelAttribute 会先解析 quertString、form-data 中的 params 参数,然后 new 一个参数对象,然后与 setter 方法同名的 param 将会被自动注入到该对象中,最后将这个对象存储到 model/request 作用域中。所以我们可以直接在 jsp 中使用 ${name.attr} 来访问被 @ModelAttribute 注解的参数对象。默认 name 的规则同上,也可手动指定。

@SessionAttributes 注解用来注释 Controller 类,用来将 model/request 作用域中的对象存储到 session 作用域中,可以指定多个 name,这些 name 对应的对象都会被存储到 session 作用域中。

@SessionAttribute 注解用来注释控制器方法的参数,用来将 session 作用域中的对象绑定到参数对象上。注意,@ModelAttribute 和 @SessionAttribute 都可以注解方法参数,而且都是将 request/session 作用域中的对象绑定到参数对象上,但是它们有一个区别,@ModelAttribute 是先从请求中读取参数,然后 new 出参数对象,然后使用 setter 注入 params,最后才是将对象存放到 request 作用域,但是 @SessionAttribute 仅仅是从 session 作用域中读取对应 name 的对象而已。不要搞错了。

@ModelAttribute 注解就不用多说了,已经演示过很多次,我们只要了解 @SessionAttritebute 和 @SessionAttributes 两个注解(虽然就差一个字符,但是区别还是很大的),@SessionAttributes 注解用在 Type 上,@SessionAttribute 注解用在 Parameter 上!前者是将 request 中的指定对象存储到 session 作用域,后者是从 session 作用域读取指定对象然后绑定到参数上。

不过我测试的时候 @SessionAttributes 注解貌似不能处理方法参数中的 @ModelAttribute 注解,只能识别方法级别上的 @ModelAttribute,不过官方 javadoc 貌似建议 HttpSession 对象好一点,暂时就这样吧。

再次说明:ModelAttribute 用来绑定 request 作用域上的对象,SessionAttribute 用来绑定 session 作用域上的对象,请牢记在 Spring MVC 中,Model 和 Request 基本上是同义词(这个词混淆度太高了)。

在不给定注解的情况下,参数是怎样绑定的?

  • 若要绑定的对象时简单类型:调用 @RequestParam 来处理的。
  • 若要绑定的对象时复杂类型:调用 @ModelAttribute 来处理的。

这里的简单类型指 ConversionService 可以直接 String 转换成目标对象的类型。如 int、String、Date。虽然可以省略注解,但是强烈建议加上注解,这样可读性强,也更不容易出错!!在此说明一下,Spring 很聪明,我们可以在处理方法的参数中放入很多与 Servlet 相关的参数,比如 HttpServletRequest、HttpServletResponse、HttpSession,Spring MVC 会自动注入合适的对象!

更新,在 Controller 方法中,可以使用 @Autowired 来自动装配 ServletContext 等组件,其实原理很简单,就是从 IoC 容器中注入而已。不过要注意,因为 Controller 是单例模式(默认就是这样,IoC 容器),所以成员变量中设置的 Autowired 属性应该是符合单例模式条件的,比如 ServletContext,这是安全的。

@RestController 注解

@RestController 注解等价于 @ResponseBody + @Controller 注解一起使用,将当前 Controller 作为 RESTful 服务时很有用,这样就不需要在每个 RESTful 方法中打上 @ResponseBody 注解了。在 Spring MVC 4.0 的时候引入的,方便用于 RESTful 的控制器:

等价于:

等价于:

员工管理 CRUD

我们再来写一个简单程序,但是要让它实用一点,用到 MySQL 数据库,之前我们什么都没用到。所谓员工管理系统就是四个操作:CRUD,创建,读取,更新,删除,我们使用 Spring MVC 来实现它,巩固前面学的知识。

项目结构

pom.xml

web.xml

mvc.xml

Employee

EmployeeDao

EmployeeDaoImpl

EmployeeService

EmployeeServiceImpl

EmployeeController

employee-list.jsp

employee-edit.jsp

Select Employee
Create Employee
Update Employee

有两个值得注意的地方:

1、Employee 这个 Value Object 对象中,id 字段我用的是 Integer 类型而不是 int 类型,这是有原因的,主要问题在于 employee-edit.jsp 视图上面,里面有一个隐藏字段,即 id,设置的 value 是 ${employee.id},这个视图会有两个 Controller forword 过来,一个是 /employees/new,一个是 /employees/edit/id,其中只有 edit 有 employee 对象,new 是没有这个对象的,所以这个 value 会变成 "" 空字符串值,而当 save 这个处理器解析时,无法将空字符串转换为 int 类型(报错),而将它改为 Integer 类型就没问题了,空串默认转换为 null 空指针。

2、employee-edit.jsp 视图里面的 <core:url var="save" value="/employees/save"/> 元素,这个标签唯一的作用就是会根据上下文路径的不同,转换出正确的 /employees/save 路径,因为 new 和 edit 两个视图的 url 是不一样的,所以不能直接通过“相对路径”来完成,只能这样做,当然也可以使用 jstl 的 if 来做判断,但还是这种方法最方便。var 属性就是对应的变量名,value 就是 url 路径。

其实 Spring MVC 和 Struts2 一样,提供了自己的 taglib 库,方便 jsp 视图的开发(如自动转换为正确的 url 路径,加上 context-path 路径),待会我们会学习它,别急。

Spring 标签库

Spring MVC 提供两套标签库,一套是 spring,一套是 form,因为 spring 这套标签库不太常用也不太实用,所以本文重点讲述 spring 提供的表单 taglib 库。jsp 文件头声明如下,前缀一般设为 form:

input 标签
例一:注意我们使用 path 变量来自动绑定 model 里面的数据(当然可以没有 command 这个 model)

如果 Model 中存在一个属性名称为 command 的 javaBean,而且该 javaBean 拥有属性 name 和 age 的时候,在渲染上面的代码时就会取 command 的对应属性值赋给对应标签的值。假设 Model 中存在一个属性名称为 command 的 javaBean,且它的 name 和 age 属性分别为 Zhangsan36 时,生成的代码如下:

form 标签会自动绑定 Model(其实就是 request)中的 command 属性(getAttribute("command")),那么如果我们要绑定的对象不是 command 怎么办呢?对于这种情况,Spring 给我们提供了一个 commandName 属性,我们可以通过该属性来指定要绑定的对象名称,除了 commandName 属性外,modelAttribute 属性也可以达到相同的效果(这两个属性是等价的)。

除 input 标签外,支持所有 html form 里面的标签,举几个常用的,passwordhiddentextarea

支持 get/post/put/patch/delete 方法(RESTful)

但是我们知道,html 的 form 明确指定了,只支持 get 和 post 两种请求方法,难道 spring form 还有什么黑科技?我们来看看生成的 html 是什么样子的:

原来只是多了一个 hidden 标签,name 为 _method,value 为 delete(请求方法名),另外 form 的 method 为 post,这不就是增加了一个请求属性吗,这有什么作用,又和其他 restful 方法有什么关联呢?

是不是按照上面这样做就能直接使用 PUT、PATCH、DELETE 方法呢?当然不是的,因为实际请求方法为 POST,所以 Spring 提供了一个 Filter,这个 Filter 会处理带有 _method 隐藏字段的请求,怎么处理呢?转换 HTTP 请求报文吗?并不是,Spring 采用了一个巧方法,使用一个 RequestWrapper 对象替换了原来的 Request 对象,并且在 RequestWrapper 对象的 getMethod() 方法中,重写了它,返回 _method 指定的方法名。所以在处理 Controller 处理器的时候,获取到的就是 PUT、PATCH、DELETE 这些方法了。

所以我们还需要配置 web.xml,添加一个 filter,用来处理这种情况:

另外需要注意的是在有 Multipart 请求处理的时候,HiddenHttpMethodFilter 需要在 Multipart 处理之后执行,因为在处理 Multipart 时需要从 POST 请求体中获取参数。所以我们通常会在 HiddenHttpMethodFilter 之前设立一个 MultipartFilter。MultipartFilter 默认会去寻找一个名称为 filterMultipartResolver 的 MultipartResolver bean 对象来对当前的请求进行封装。所以当你定义的MultipartResolver的名称不为filterMultipartResolver 的时候就需要在定义 MultipartFilter 的时候通过参数 multipartResolverBeanName 来指定。

现在我们来使用 spring-form 标签改写 CRUD 例子中的 jsp 视图,如下:

EmployeeController.java

employee-edit.jsp

不同于 EL,Spring 的 form 标签中的 model 属性必须存在,否则报 500 错误,而 EL 表达式则不会。

注意,其实我们可以不用 jstl 的 url 标签,也能输出 contextPath 的路径,那就是使用 EL 表达式:

JSON 支持

第一种方式是使用 JSON 工具将对象序列化成 json 字符串,常用工具有 Jackson,fastjson,gson。

第二种方式,在 mvc.xml 中配置 <mvc:annotation-driven/>,添加 jackson-databind.jar 依赖:

借助 @ResponseBody,我们可以直接返回一个 Bean/Pojo 对象,Spring 会自动序列化为 json 字符串:

文件上传

Spring MVC 支持文件上传(multipart 请求),需要在 pom.xml 中引入 commons-fileupload 依赖:

然后配置 mvc.xml,注册 MultiPart 请求的解析处理器,web.xml 配置如下:

然后编写我们的上传表单,以及上传成功的消息页面:
fileUpload.jsp

uploadSuccess.jsp

FileUploadController.java

再议重定向

在处理器方法中,我们可以返回 "redirect:/path/to/redirect" 来发送 302 临时重定向,其实还有一个特殊用法,那就是 "forward:/path/to/forward" 来触发 Servlet 的 forward 机制(注意它和直接返回 view 名的不同,forward 会触发 uri 对应的 controller 方法)。

forward 会保留 request 里面的对象,因为这只是发生在 servlet context 内部,对外部是透明的,所以浏览器地址栏也不会有变化。而 redirect 是发送 HTTP 协议定义的重定向响应,浏览器会发起新的请求。

但是,在实际应用中,我们常常有这样一个需求,我需要发送 redirect 重定向给浏览器,但是我又希望能够传递数据给重定向后的处理器方法。该怎么做呢?其实 Spring MVC 提供了相应的解决办法。不过在这之前,我们先自己思考一下,如果要我们自己来实现,该如何做?

因为 redirect 是 HTTP 协议层面的事情,浏览器收到 redirect 响应后,会发起一个全新的 HTTP 请求到目标服务器,所以,如果要携带数据,只能将数据作为 queryString 加到 url 中来传递。但是又因为 queryString 会直接暴露在浏览器地址栏,不安全,所以这种方式不太建议。

虽然不建议使用,不过难免会有用到的时候,我们来看下如何实现这种方式的 redirect 数据携带:

  • 原始方式:手动拼接 redirect 的 url,加上 queryString(太简单,不演示)
  • 使用 Spring MVC 提供的 RedirectAttributes 参数,该对象提供两种传值方式

RedirectAttributes 接口提供两个常用的传值方式:

其中,addAttribute 是利用普通的 url 传参方式进行传递,而 addFlashAttribute 不同于前者,它是将 value 存放到 session 中,然后我们可以在目标处理器方法上,使用 @ModelAttribute 来读取这个 value,从而完成优雅的数据传递。注意,之所以称为 flash attribute,是因为这些 value 在使用 @ModelAttribute 接收后就会被删除,即闪存的本意。

纠错:addFlashAttribute 方法会将属性暂存到 session 中,我们可以在目标方法上通过 Model、ModelMap、@ModelAttribute 等方式来接收,本质都是从 request 作用域 中读取 flash 属性,注意,这些属性只在 redirect 后的第一次请求中有效,只要你刷新一下页面,属性就没了,这就是易失属性的特性,也是其名字的由来。

测试例子:

测试结果:

注意,如果使用浏览器测试,是没有 ;SessionID 的,因为浏览器支持 Cookie。另外,如果你被重定向到 test03 页面后,刷新一下浏览器,会发现数据是空的,说明确实是 flash attribute。另外,通过这种方式传递过来的数据只能通过 Model、ModelMap、@ModelAttribute 来读取!

拦截器

Spring MVC 和 Struts2 一样,支持“拦截器”概念,拦截器的英文是:Interceptor。Interceptor 和 Filter 很相似,都可以用来定义 预处理后处理 操作,不同的是,Filter 的实现原理是 函数调用链,而 Interceptor 的实现原理是 JDK 动态代理,虽然实现原理不同,但是它们之间却有很多相似的地方,甚至我们可以说,拦截器就是 SpringMVC/Struts2 提供的“过滤器”在框架中的实现!Filter 和 Interceptor 都可以有多个,它们之间的执行顺序由 web.xml、mvc.xml 的出现顺序定义,并且先定义的 Filter/Interceptor 的预处理方法先执行,而后处理方法则与定义顺序相反,先定义的后执行,后定义的先执行。可以说,除了实现原理不同,其他的特征都是一样的。

在 Spring MVC 中,如果要定义拦截器,有两种常见方式:

  • 一种方法是实现 HandlerInterceptor 接口,这个接口里面有 3 个方法,分别是 preHandle()postHandle()afterCompletion(),分别表示:在 Controller 方法之前处理、在 Controller 方法之后处理,在 View 视图返回后处理(请求完成后)。
  • 另一种方法是继承 HandlerInterceptorAdapter 抽象类,这个抽象类存在的作用是为了让我们实现 pre-only/post-only 类型的拦截器更加简单,该抽象类实现了 HandlerInterceptor 接口,并且还添加了一个方法,但我们一般不需要关心这个,其他三个方法默认都是空实现,可以按需 Override 对应的方法。

我们先来看看 HandlerInterceptor 接口的三个方法定义:

注意 preHandle 方法,它返回的是一个 boolean 值,其他方法没有返回值。这个 boolean 值的意义是这样的,如果返回 true,则继续调用下一个拦截器的 preHandle 方法,或者调用 Controller 的处理方法(如果是最后一个拦截器的话),如果返回 false,则表示当前请求就到此为止了,preHandle 方法会返回一个响应结果给请求客户端(一般是检测到异常或者权限不足或者是其他请求时,会这么做)。

来实现一个简单的日志记录拦截器,分别在 preHandle、postHandle、afterCompletion 位置记录日志:

这个 handler 对象其实是 org.springframework.web.method.HandlerMethod 的一个实例,打印出来的结果就是 Controller 方法的签名而已,一般情况下没什么很大的作用。接下来我们来看看如何配置拦截器。很显然,因为拦截器的执行顺序与定义顺序有关系,所以只能使用 mvc.xml 配置文件来配置(不过好像也可以使用 Java-Based 形式来配置),拦截器配置在 <mvc:interceptors> 元素中,如下:

放在 interceptors 元素下的拦截器,会匹配所有请求。如果只想拦截指定路径下的请求,可以这么做:

拦截器的执行顺序与他们在 xml 中定义的先后顺序相同,先定义的先执行,这一点和 Filter 是一样的。

输出结果如下:

数据校验

所谓数据校验就是对客户端提供的数据进行有效性校验,有两个作用,一是防止无效数据或非法数据存储到我们的系统中,二是避免攻击者利用非法数据攻击我们的系统,所以数据校验的重要性不言而喻。通常我们的 Web 应用会进行两重校验,首先是浏览器端的 JavaScript 校验,然后是后台系统(controller)的数据校验。你可能会问,服务端的校验是不是多余的呢?其实不是的,虽然 JavaScript 校验能够避免大部分无效数据传入后台系统,但是浏览器的 JS 是可以被禁用的,而且非法分子可能直接利用 curl 等客户端来绕开 JS 校验,所以服务端上的校验也是必不可少的。

Spring MVC 提供了一个简单实用的机制来帮助我们校验提交过来的数据,Spring MVC Framework 默认支持 JSR-303 规范,我们只需要在 Spring MVC 应用程序中添加 JSR-303 规范接口及其 JSR-303 实现类依赖项就行(JSR-303 只是定义了一些规范,提供对应的注解,但是并没有提供对应的实现,hibernate validation 提供了 JSR-303 的实现,并且还提供了一些额外的校验注解)。Spring MVC 还提供了 @Validator 注解和 BindingResult 类,通过 BindingResult,我们可以在请求处理方法中获取 Validator 实现引发的错误。

对于任何一个应用而言在客户端做的数据有效性验证都不是安全有效的,这时候就要求我们在开发的时候在服务端也对数据的有效性进行验证。Spring MVC 自身对数据在服务端的校验有一个比较好的支持,它能将我们提交到服务端的数据按照我们事先的约定进行数据有效性验证,对于不合格的数据信息 Spring MVC 会把它保存在错误对象中,这些错误信息我们也可以通过 Spring MVC 提供的标签在前端 JSP 页面上进行展示。

Spring MVC 提供两种数据校验方式,一种是基于 Validator 接口,另一种是使用 JSR-303 注解。Validator 接口需要我们自己去实现,而 JSR-303 注解是可以开箱即用的(添加对应依赖即可)。

基于 Validator 接口进行数据验证
Validator 接口是 Spring 提供的,方便我们定义验证类来对实体类进行数据验证。假设我们存在这样一个 User 类,我们需要对其中的 username 和 password 字段进行验证,避免非法数据进入我们的系统:

那么当我们需要使用 Spring MVC 提供的 Validator 接口来对该实体类进行校验的时候该如何做呢?这个时候我们应该提供一个 Validator 实现类,实现 Validator 接口的 supports() 方法和 validate() 方法。supports() 方法用于判断当前的 Validator 实现类是否支持校验对应的实体类,只有当 supports() 方法的返回结果为 true 的时候,该 Validator 接口实现类的 validate() 方法才会被调用,来对当前需要校验的实体类进行校验。

首先创建一个 package,存储我们的 Validator 验证器,然后编写 User 类对应的 UserValidator 类:

注意我在 validate 方法中使用了两种不同的方式来进行字段的验证,这样做的目的仅仅是为了举例子而已。

我们已经定义了一个对 User 类进行校验的 UserValidator 了,但是这个时候 UserValidator 还不能对 User 对象进行校验,因为我们还没有告诉 Spring 应该使用 UserValidator 来校验 User 对象。在 SpringMVC 中我们可以使用 DataBinder 来设定当前 Controller 需要使用的 Validator。对应的方法需要使用 @InitBinder 注解进行标注,如下:

@Validated 注解的对象会被 Spring MVC 执行数据校验,@Validated 注解和 BindingResult 是成对出现的,如果有多个对象需要被校验,请成对放在 Controller 方法的参数列表中。BindingResult 是对应对象的校验结果,我们可以通过它的 hasErrors() 方法来判断是否校验成功,如果出现校验不正确的情况,则返回 true,因为我们是使用 redirect 来重定向到 login 登录表单,所以需要使用 RedirectAttribtues 的 flashAttribute 来添加易失属性,然后 login 表单才会正确显示 model 对象和 error 对象(使用 spring 的 form 标签库),如果不是 redirect,那么是不需要添加什么 flashAttribute 的,直接 return "login-failure" 就行了。注意错误对象的 name,最后一个字符串 user 是对应的 model 对象的名称(BindingResult 是 Errors 的子类,所以可以使用 result 对象替代 errors 对象)。

注意,你可能会从别的教程中看到,使用 @Valid 注解替代 @Validated 注解的情况,不要惊讶,@Valid 注解是 JSR-303 中定义的,而 @Validated 注解是 Spring 定义的,后者对前者进行了扩展,后者支持分组校验的特性,而前者不支持,它们基本上是可以互换的。之所以我没有使用 @Valid 注解,是因为我没有添加 JSR-303 的依赖项,所以就使用 @validated 注解了。

我们知道在 Controller类中通过 @InitBinder 标记的方法只有在请求当前 Controller 的时候才会被执行,所以其中定义的 Validator 也只能在当前 Controller 中使用,如果我们希望一个 Validator 对所有的 Controller 都起作用的话,我们可以在 SpringMVC 的配置文件中通过 mvc:annotation-driven 的 validator 属性指定全局的 Validator。代码如下所示:

那么我们该如何在 JSP 页面中展示验证错误的信息呢?很简单,Spring 提供了一个 form 标签库,里面有一个 errors 标签,该标签会读取我们上面指定的 errors/result 对象,该标签有一个 path 属性,属性值如果为 * 则用来显示所有的错误信息,如果是对应的字段名,则显示对应的字段的错误信息。

login-form.jsp

login-success.jsp

基于 JSR-303 Validation 的数据验证
JSR-303 是一个数据验证的规范,JSR-303 只是一个规范,而 Spring 也没有对这一规范进行实现,那么当我们在 SpringMVC 中需要使用到 JSR-303 的时候就需要我们提供一个对 JSR-303 规范的实现,Hibernate Validator 是实现了这一规范的,这里将它作为 JSR-303 的实现来讲解 SpringMVC 对 JSR-303 的支持。

JSR-303 的校验是基于注解的,它内部已经定义好了一系列的验证注解,我们只需要把这些注解标记在需要验证的实体类的属性上或是其对应的 getter 方法上。看下需要验证的实体类 User 的代码(一般用在属性上):

其中 message 是错误提示,然后去掉 UserController.java 里面的 @InitBinder 方法,结果是一样的:

常用注解有这些:

  • @Null:必须为 null
  • @NotNull:必须不为 null
  • @AssertTrue:必须为 true
  • @AssertFalse:必须为 false
  • @Min:最小值为 value(数值、数值字符串)
  • @Max:最大值为 value(数值、数值字符串)
  • @Range:数值大小必须在指定范围内(数值、数值字符串)
  • @Size:字段长度必须在指定范围内(字符串、数组、集合)
  • @Past:被注释的元素必须是一个过去的日期
  • @Future:被注释的元素必须是一个将来的日期
  • @Email:被注释的字符串必须是有效的电子邮箱地址
  • @NotEmpty:字段的的长度不能为零(字符串、数组、集合)
  • @NotBlank:字符串的长度不能为零(trim() 后的字符串)
  • @Length:字符串的长度必须在指定范围
  • @Pattern:字符串必须被正则表达式匹配

自定义数据验证的注解
除了 JSR-303 原生支持的验证注解外,我们也可以定义自己的验证注解(并且用法完全一致)。定义自己的验证注解有两个步骤,第一步是定义一个注解,第二步是定义一个 ConstraintValidator 的实现类。注解和注解处理类,它们是一对的,单单定义一个注解是不行的,因为注解仅仅是存放了元数据,我们必须定义一个注解处理程序,而数据验证注解的处理程序就是一个实现了 javax.validation.ConstraintValidator 接口的类。

定义一个数据验证注解,@Username,规定 username 的正确格式:

定义一个数据验证注解,@Password,规定 password 的正确格式:

注意 @Constraint(validatedBy = UsernameValidator.class) 元注解,它用来指定处理当前验证注解的实现类,然后就是 message 属性,我们为它设置一个默认的错误提示信息,其它两个属性我们可以暂时不管。注意,无论何时,Constraint 注解的元素必须有上面三个,即 message、groups、payload。除此之外我们还可以定义其他属性,比如 value、min、max、pattern 等等。如果我们在属性上设置了默认值,而又想在实现类上引用它,直接在 initialize 方法中使用 annotationObj.value() 方法获取就行(其他的同理)。

UsernameValidator.java

PasswordValidator.java

Controller 和 View 都不用动,我们重载一下应用程序,测试 username 和 password 的验证是否正常。

Spring 的 @Validated 注解的分组验证
所谓分组验证就是,有些时候,我们需要对同一个实体类进行多种验证,比如 id 字段,创建时是不需要验证的,默认为 null,而更新时则是需要验证的,不能为 null。那么我们该怎么办呢?别慌,我们前面说了,JSR 自带的 @Valid 不支持分组验证功能,但是 Spring 提供的 @Validated 注解扩展了 JSR 的注解,支持分组验证功能。所以一般情况下,我们使用 @Validated 注解会比使用 @Valid 注解更好一些。

分组验证听起来很复杂,其实不然,很简单,只是定义两个接口而已,它们都是空接口(标记接口),我们会利用它们的 Class 对象来进行分组,一般情况下,我们会把这些注解放到实体类内部,即作为静态内部接口。

Student.java

StudentController.java

测试结果

相关注解复习

web 应用的三层结构

  • @Component:组件,最普通的 bean,当 bean 不好归类时使用可以使用这个注解;
  • @Controller:控制器,一般用在传统 Web 应用的控制层,是 @Component 的子注解;
  • @Service:代表业务组件,一般用在传统 Web 应用的业务层,是 @Component 的子注解;
  • @Repository:代表持久化组件,一般用在传统 Web 应用的持久层,是 @Component 的子注解。

被这些注解标注的类会被 Spring 的 IoC 容器实例化,放到 bean 容器中进行管理。我们可以使用 @Autowired@Resource 注解来自动装配这些 bean。目的是为了“控制反转”,降低类与类之间的依赖度。

JSP 页面中的绝对 url

建议将开头三行代码放入 jsp 的文件模版中,这样就不用每次编写 jsp 文件都重复编写这些内容了。

Model、ModelMap、ModelAndView
虽然它们都叫做 Model*,但其实我们可以认为它们都是 request 对象的封装类,ModelAndView 是最原始的传值方式(这里说的传值就是传统意义上的 request.setAttribute()),基本上现在已经可以不用 ModelAndView 对象了。通常,我们都是使用 Model 或者 ModelMap 对象,这三个对象都可以用来向 view 页面传递 request 属性(注意,Model* 对象不同于 HttpServletRequest 对象!!!)。

Model 是一个接口,定义了一些 addAttribute() 方法,而 ModelMap 则是 LinkedHashMap 的子类,ModelMap 暴露的方法和 Model 是一样的,基本上没区别,可以根据自己的喜好,使用任意一个对象来进行 request 传值,我个人的话,比较喜欢使用 Model 对象。

注意,虽然 ModelAndView 是最原始的传值方式,但是在 Spring MVC 实现层面,Model 和 ModelMap 依旧会被封装成 ModelAndView 对象来进行处理,我们可以认为 Model/ModelMap 是 ModelAndView 的封装。

@RequestMapping、@GetMapping、@PostMapping、@PutMapping、@PatchMapping、@DeleteMapping
这些注解都是用来将 Controller 方法映射到指定 url 的,最开始只有 @RequestMapping,不过后来为了方便开发 RESTful 风格的服务,Spring 又提供了 @RequestMapping 的 GET、POST、PUT、PATCH、DELETE 方法的特定注解,这些注解除了没有 method 属性,其他的特征与 @RequestMapping 注解是完全一样的(当然还有一点区别就是,@RequestMapping 可以用在 Controller 类上,而 @GetMapping、@PostMapping、@PutMapping、@PatchMapping、@DeleteMapping 这些只能用来标注 Controller 方法)。

@RequestMapping 如果用在 Controller 类上,则该 Controller 中的所有方法映射到的 uri 都是以类上的 uri 为上下文的(即父路径),这在做 RESTful API 服务的时候很有用。如果不指定 value/path 属性,则默认为 "" 空字符串。比如 Controller 上使用 @RequestMapping("/employees") 标注,而 Get 方法上使用 @GetMapping 标注,则表示该 Get 方法的 url 为 /employees,没有 / 分隔符哦。

有必要强调一下,@RequestMapping 可以用在 Controller 类上,此时表示,该 Controller 类中的所有处理方法都将继承该 @RequestMapping 上的属性值(所有属性都是这样,方法上的注解会继承这些属性值)。

@RequestMapping 的 6 个属性:

请求路径、请求方法

  • path/value:请求 uri,支持 ?*** 等 Ant 通配符。支持多个 uri。
  • method:请求方法,如 GET、POST、PUT、PATCH、DELETE。支持多个 method。

请求参数、请求头部

  • params:支持 name=valuename!=valuename!name 字符串模式
  • headers:支持 header=valueheader!=valueheader!header 字符串模式

提交的 MIME 类型、期望的 MIME 类型:

  • consumes:限定 Content-Type 头部,如 text/html!text/plain!application/* 字符串模式
  • produces:限定       Accept 头部,如 text/html!text/plain!application/* 字符串模式

注意 produces 属性,该属性还有一个副作用,那就是它会将匹配到的 MIME 类型写入到 response 头中,组合起来,该属性的作用就是:只会响应与 request 的 Accept 头部相匹配的 MIME 请求,并且还会修改 response 中的 Content-Type 头部,将其设为当前生效的 Content-Type。什么意思呢?举个栗子:

如果不指定 charset=UTF-8 编码,那么默认会变为 Latin-1 编码,虽然我们设置了 CharsetFilter!

@RequestParam、@RequestBody、@RequestPart,解析请求数据

  • @RequstParam:解析 application/x-www-form-urlencoded 请求,表单数据(含 url 参数)
  • @RequestBody:解析 application/jsonapplication/xml 等请求,在 RESTful API 中常用
  • @RequestPart:解析 multipart/form-data 请求(文件上传),@RequestParam 也支持这种请求

@RequestParam 注解的参数应该为基本类型、基本类型的包装类、String、Date 等简单类型,或者叫原语。而 @RequestPart 注解的参数应该为一个 bean/pojo 对象,spring 会自动根据 bean/pojo 对象的 setter 方法和 json 的同名字段来进行 json 到 object 之间的转换(object 到 json 的转换原理也是类似的)。

虽然 @RequestParam 也支持 multipart/form-data 请求,但尽量使用 @RequestPart 来替代,符合语意。对于 @RequestParam 注解,如果对应的方法参数为 Map 类型,则所有的请求参数都会存入到这个 Map 中。@RequestPart 和 @RequestParam 如果是用来处理 multipart 请求,则参数类型一般为 MultipartFile。

@PathVariable、@RequestHeader、@CookieValue,解析请求元数据

  • @PathVariable:绑定 mapping uri 中的 {id}{id:\w+} 变量
  • @RequestHeader:绑定 request headers 中的指定 header 的 value
  • @CookieValue:绑定 request cookie header 中的指定 cookie 的 value

@ModelAttribute、@RequestAttribute、@SessionAttribute、@SessionAttributes

  • @ModelAttribute:用来标注 Controller 方法参数、Controller 方法(返回值),详解在下面
  • @RequestAttribute:用来标注 Controller 方法参数,绑定 HttpServletRequest 中的对象
  • @SessionAttribute:用来标注 Controller 方法参数,绑定 HttpSession 作用域中的对象
  • @SessionAttributes:用来标注 Controller 类,同步 Model* 中的对象到 HttpSession

@RequestAttribute 和 @SessionAttribute 很好理解,就是字面意思,将 request 和 session 作用域中的指定 attribute 绑定到被注解的方法参数中,如果没有找到对应的则会报错,当然可以将它们的 required 属性设为 false 来避免这种情况。

@SessionAttributes 注解是用在 Controller 类上,用来同步 Model、ModelMap、ModelAndView 中设置的 model 属性,同步到 session 作用域,所以我们可以在 jsp 页面中,通过 request 和 session 都能访问这些 attribute 对象。

@ModelAttribute 的工作原理是这样的,首先它会对参数对象执行 new 操作,创建一个对象出来,然后查找 request params(@RequestParam)中的与 setter 方法同名的 param,然后将其注入到 setter 方法,最后,@ModelAttribute 注解还会将这个 bean/pojo 保存到 Model* 中,而 Spring MVC 会自动将 Model* 中的数据同步到 httpServletRequest 对象中,所以可以在 jsp 中通过 request 作用域访问这些对象。

@ModelAttribute 用在方法上时(实际是用在方法返回值上),这个方法会在所有 @RequestMapping 方法之前执行(包括 Get/Post/Put/Patch/Delete 子注解),并且这个方法可以有 @RequestMapping 中的所有参数类型(Spring 会自动注入),作用是将方法返回值存入到 Model* 作用域中,因为会在所有请求处理方法之前执行,所以在这里处理一些所有方法共享的 Model 对象是一个最佳实践。

@ModelAttribute、@RequestBody 注解的 bean/pojo 类,里面如果有 short/int/long 等基本类型,请改为对应的 Short/Integer/Long 包装类,否则,如果 request params 中的 param 的 value 为空字符串,那么 Spring MVC 会报告转换错误,因为空字符串无法转换为 short/int/long 等类型,而将它们改为对应的包装类后,传递空字符串的 value 和不传递这个 param 的效果是一样的,即对应的字段会被设为 null 值。但是要注意,如果字段类型为 String,那么传递空字符串就是空字符串,不传递的时候才会被设为 null 值。

异常处理

不管是在 dao 层、service 层、controller 层,都有可能抛出异常,默认情况下,Spring MVC 会给客户端返回一个 500 响应,并且附带一个错误页面(通常是异常对象的堆栈跟踪信息),在开发环境中,这种默认处理方式或许还能够接受,因为堆栈跟踪信息中通常有很多有用的信息,帮助我们排错。但是在实际生产环境中,如果用户收到这样一个丑陋难懂的错误页,大概都会觉得系统很 low,而且这些堆栈跟踪信息还可能被攻击者研究,然后入侵我们的系统。

所以我们很有必要学习一下 Spring MVC 中的异常处理机制,Spring 提供三种异常处理方式,分别是:

  • 使用 @ResponseStatus 注解:默认情况下,当我们访问一个不存在的 uri 时,Spring MVC 内部会抛出 NoSuchRequestHandlingMethodException 异常,而这个异常会被 Spring MVC 自动映射为 404 Not Found 响应。像这样的可以自动映射为指定响应码的异常类,Spring MVC 提供了很多个,当然,如果默认的异常类不足以满足我们的要求,我们也可以自定义一个异常(通常扩展 RuntimeException,因为不需要显式声明这个异常),然后使用 @ResponseStatus(status, reason) 注解标注这个异常类,那么当我们在 controller/service/dao 的任何一个地方抛出这个异常时,Spring MVC 都会自动将其映射为对应的 status 和 reason,非常方便。PS:@ResponseStatus 还可以用在 Controller 处理方法上,标注后,对应处理方法会返回指定的 status 和 reason。
  • 实现 HandlerExceptionResolver 接口:如果我们在 Bean 容器中实例化了 HandlerExceptionResolver 接口的实现类对象,那么就表示 Spring 中发生的异常将统一由该异常处理器来处理,Spring MVC 默认提供了一个 SimpleMappingExceptionResolver 简单异常处理器,大部分情况下,使用该 SimpleMappingExceptionResolver 简单异常处理器就能够满足统一处理系统中发生的所有异常的要求了,当然,如果还不能满足要求,我们也可以自己实现一个 HandlerExceptionResolver 异常处理器,通常情况下,我们会在这里面区分系统定义的异常(如空指针异常)和自定义的异常(业务异常),对于系统异常,我们简单的描述一下异常信息就行,对于自定义异常,可以携带一些业务数据给错误处理页面(通常为 error.jsp)。
  • 使用 @ExceptionHandler 注解和 @ControllerAdvice 注解:@ExceptionHandler 注解可以用在 Controller 的方法上,被该注解标注的方法会被当作当前 Controller 类的异常处理方法,同一个 Controller 中,可以有多个这样的异常处理方法,该注解只有一个元素,类型为 Class<? extends Throwable>[],表示当前异常处理方法只会处理里面列出的异常类型,如果留空,那么该处理方法会处理方法参数中出现的异常类型。同时,我们还可以使用 @ResponseStatus 来注释异常处理方法,此时异常处理方法的方法体应该为空,因为此时表示对应的异常将会被转换为指定的 status 和 reason,所以方法体没有意义,应该留空。异常处理方法的参数签名中可以有异常对象、HttpServletRequest/HttpServletResponse 对象、HttpSession 对象、Model 对象;异常处理方法的返回值可以是:void、String、Model、ModelAndView、HttpEntity、ResponseEntity;注意,参数中的 Model 没有任何预填充的属性,它的作用仅仅是用来传递属性给异常处理页面。而 String、ModelAndView 返回值表示该异常处理方法会返回一个错误处理页面。不过,因为在 Controller 中的被 @ExceptionHandler 注解的方法只能处理当前 Controller 方法中抛出的异常,如果我们像统一处理所有 Controller 中抛出的异常该怎么办呢?很简单,Spring 提供了两个注解,@ControllerAdvice@RestControllerAdvice,它们的区别和 Controller 和 RestController 的区别是一样的,方便我们省略 @ResponseBody 注解而已,没有其他特别的。被这两个注解标注的类会被作为所有 Controller/RestController 类的增强类,我们可以在这个类里面编写 @ExceptionHandler 注解的异常处理方法,他将处理系统中所有的 Controller/RestController 方法中抛出的异常。

虽然有 3 大方法可以用来处理异常,不过第二种方法貌似有点过时了,所以我们一般情况下,只要合理利用 @ResponseStatus + 自定义异常类@ExceptionHandler + @ControllerAdvice 两种方式就行了?你可能会想了,这两种方式有没有冲突呢?比如一个异常已经被 @ResponseStatus 标注了,我们在 Controller 方法中抛出了这个异常,那么它究竟会被转换为对应的 status + reason 还是被 @ExceptionHandler 异常处理方法给处理呢?经过测试,如果定义了 @ExceptionHandler 异常处理方法,并且与指定异常相匹配,那么会被该异常处理方法给处理,而不会转换为对应的响应状态码。所以推荐用 @ControllerAdvice 和 @ExceptionHandler 方式来统一处理系统抛出的所有异常。

不过,虽然建议使用最后一种方式来统一处理异常,但是前两种异常处理方法我们还是要接触一下的。

@ResponseStatus + 自定义异常

当我们访问 /test 时,将会得到 404 响应,错误提示 message 为 Resource Not Found,很简单。

SimpleMappingExceptionResolver 简单异常处理器
前面说了,只要在 bean 容器中注册了 HandlerExceptionResolver 接口的实现类的实例,那么 Spring MVC 就会将这个异常处理器作为全局异常处理器,现在我们来配置一下 SimpleMappingExceptionResolver:

defaultErrorView 是默认错误页面,这里我设为了 error-system(会结合 view 的 prefix 和 suffix)
exceptionMappings 里面可以设置多个自定义的异常错误页面,对应异常将会被 forward 到指定的错误页面

ExceptionController.java

error-system.jsp 错误页,其他的两个差不多,没什么新意:

自定义异常处理器,太无聊,就照搬别人的吧

使用 @ExceptionHandler 异常处理方法

如果要处理全部异常,可以将这些异常处理方法放到 @ControllerAdvice 注解的类中:

Spring 在异常处理方面提供了一如既往的强大特性和支持,那么在应用开发中我们应该如何使用这些方法呢?以下提供一些经验性的准则:

  • 不要在 @Controller 中自己进行异常处理逻辑。应使用 @ExceptionHandler 异常处理方法
  • 对于自定义的异常,可以对其加上 @ResponseStatus 注解,将其转换为 HTTP 响应
  • 使用 @ControllerAdvice 处理通用异常(例如资源不存在、资源存在冲突等)

国际化/本地化

国际化又叫做 I18N,本地化又叫做 L10N,所谓 18 和 10 都是对应的英文单词的长度而已。Spring MVC 对国际化提供了很好的支持,通过几个简单的配置就能直接使用。Spring MVC 中有 3 种实现国际化的方式:

  • AcceptHeaderLocaleResolver:默认方式,默认启用无需配置,通过读取请求中的 Accept-Language 头部来确定 locale 区域。如果要改变 locale,只能改变 Accept-Language 头部来改变,不太灵活。
  • CookieLocaleResolver:通过 Cookie 保存 locale 区域设置,支持 queryString 参数改变区域。
  • SessionLocaleResolver:通过 Session 保存 locale 区域设置,支持 queryString 参数改变区域。

Spring MVC 的国际化是建立在 Java 的国际化的基础上的,我们来回顾一下 Java 国际化的配置步骤:

  • 首先,我们需要定义 <basename>_zh_CN.properties(中文)、<basename>_en_US.properties(英文)、<basename>.properties(默认)等资源文件(根据优先级和匹配度选择具体的资源文件,每个资源文件都代表一个不同的 Locale,basename 是资源文件的名称,如果找不到匹配的资源文件,则使用 <basename>.properties 默认资源文件),资源文件的格式很简单,key = value:键值对,key 区分大小写,value 的前导空格将被忽略,value 中可使用 \t\n 等转移序列;key 和 value 都可以有中文,但是必须使用 unicode 字符,JDK 提供了 native2ascii 工具用于 unicode 的转换。value 支持位置参数,如 hello, {0}! goodbye {1}! 中的 {N},N 为索引值,从 0 开始,spring 的 message 标签可以传递参数值,稍后会解释。
  • 然后,在程序中实例化一个 ResourceBundle,指定 basename,然后使用 resourceBundle 的 getString() 方法可以获取指定 key 对应的 value(根据当前的 Locale 区域)。总体很简单。

首先定义 properties 资源文件
messages_en.properties

messages_zh.properties

然后配置 mvc.xml,添加资源束:

因为默认 Locale 实现方式为 Accept-Language 头部,所以不需要其他额外的设置了,直接编写 jsp:

使用 spring 提供的 message 标签,code 就是 key,arguments 为参数,默认分隔符为 , 即英文分号。

cookie 实现方式
配置 mvc.xml,具体如下:

cookie 和 session 形式需要配置 localeChangeInterceptor 拦截器,该拦截器可以实现通过 queryString 参数改变 locale 设置,默认参数名为 locale,这里我们将它改为了 language。

第一次访问时因为没有设置 language 这个 cookie,所以使用默认的 locale,即 en,当我们传递 ?language=zh 后,发现 locale 已经转换为了 zh,然后去掉这个参数,刷新页面依旧是 zh 简体中文,然后传递 ?language=en 可以将其转换为 en 英文,刷新后依旧是英文,使用调试工具可以看到 Spring MVC 设置了一个名为 language 的 cookie,value 就是 zh 或 en,这也是为什么可以记忆 locale 的原因了。

session 实现方式

总体上和 cookie 实现方式没多大区别,只不过是将 language 属性放到了服务器上,而不是 cookie 上。

读取 Cookie
读取 Cookie 很简单,使用 @CookieValue 注解来标注我们的 Controller 方法参数就可以了,Spring 会自动将对应的 Cookie 值绑定到参数上,@CookieValue 注解支持的属性有这么几个:

  • name/value:要绑定的 cookie 名称,value 是 name 的别名
  • required:对应 cookie 是否是请求的,默认为 true,如果设置了 default,则自动变为 false
  • defaultValue:当请求的 cookie 中不存在指定的 cookie 时,使用此默认值来绑定到方法参数上

可以看到,@CookieValue 注解的属性和 @RequestParam 注解的属性时完全一模一样的。测试:

设置 Cookie
Spring MVC 没有提供什么神奇的设置 Cookie 的注解或方法,因为 Servlet-API 中的 response.addCookie 已经很好用了,我们知道,在 Servlet 编程中,使用一个 javax.servlet.http.Cookie 对象表示一个 cookie,cookie 的两个基本属性就是 name 和 value,分别表示 cookie 的名称和 cookie 的值,注意,为了符合 cookie name 和 value 的字符规范,建议对 name 和 value 做 base64 或 url 编码处理。一个好的方法是,name 使用正常的英文字母,这样就不需要编码处理,而 value 则进行编码处理,比如 base64 编码或 url 编码。

删除 Cookie
Cookie 的删除很简单,发送一个 maxAge 为 0 的同名 cookie 给浏览器就行了,value 可以设为 null:

读取/添加/删除 session
读取 session 也很简单,使用 @SessionAttribute 注解标注方法参数,该参数就会自动绑定到对应的 session attribute 了。@SessionAttribute 注解有两个属性,即 name/value,表示 session 属性的名称,而 required 属性表示该属性是否是请求的,默认为 true,即如果没有对应的 session 属性,Spring 会抛出异常。可以设置为 false,这样,当该属性不存在时,参数将指向 null。

@SessionAttributes 注解的作用以及用法
根据前面的学习,SessionAttributes 注解是用来同步 Model 中的 attribute 到 Session 中的,@SessionAttributes 注解用在 Controller 类上,有两个属性,names/value,字符串数组,同名的 model 属性会被自动存储到 session 中,而 types 属性时 Class 数组,对应类型的 model 属性也会被自动存储到 session 中,两个参数可以同时指定,它们是一个并集关系。官方 javadoc 文档是这样说的,这个注解是用来临时存储 model 数据到 session 中用的,一旦 Controller 方法指定 session 会话完成(调用 SessionStatus 的 setComplete 方法可将会话标记为已完成,这时候这些 session 属性就会被清除),Spring 将会自动删除这些属性,所以对于持久性的 session,请使用 HttpSession.setAttribute 方法。

RESTful API

所谓 RESTful API 就是:使用 URL 定位资源,使用 Method 描述操作。典型的 CRUD 操作:

  • restful uri 不使用 / 结尾。
  • 建议使用 json 作为数据交互格式。
  • 建议使用复数名词,严禁混用单复数。
  • 对可选/复杂的参数,使用查询字符串。
  • 2xx 操作成功,4xx 客户端错误,5xx 服务端错误。
  • 使用驼峰命名法,如 yearOfBirth,不要使用下划线。
  • 在 URL 中加入版本号,如 /v1/employees,不要使用小数。
  • 提供分页信息,如 /employees?offset=30&limit=15,30~45。
  • RESTful 只是一个规范,并不是强制要求,具体怎么做还是取决于开发者自己。
  • PUT 是替换资源,需要提供完整的资源信息。而 PATCH 是更新资源,可以局部更新。

如果想要了解有关 RESTful API 的更多信息,可参考之前的 RESTful API 感想 一文。

Spring MVC 对 RESTful API 提供了非常良好的支持,不仅有编写 RESTful API 服务端的类库,还提供了一个 RestTemplate 客户端帮助类,便于我们编写 Java 代码,测试我们编写的 RESTful API 是否工作正常。

不过,本文暂时不介绍 RestTemplate 类的使用,再说 RestTemplate 的使用其实很简单,没什么可讲的,直接看几个官方的 case 就行了,就如同 JdbcTemplate 模板类一样,简单易用,容易上手。这里我就使用 Postman 来进行 RESTful API 的 CRUD 测试,当然也可以使用 curl 命令行工具进行测试。

前面说了,RESTful API 通常情况下,都是使用 JSON 作为数据交互格式,因为 JSON 和 Java 对象之间的转换非常简单,兼容性非常强,而且 JSON 的两大数据类型:数组和对象,和 Java 中的 Bean/POJO、集合对象基本上都可以进行很好的互相转换操作,我们来回顾一下 JSON 是什么,以及 JSON 的数据类型:

JSON(JavaScript Object Notation,JS 对象表示法),是一种由 道格拉斯·克罗克福特 构想设计、轻量级的数据交换格式,以文本为基础,且易于让人阅读。尽管 JSON 是 Javascript 的一个子集,但 JSON 是独立于语言的文本格式,并且采用了类似于 C 语言家族的一些习惯。

JSON 数据格式与语言无关,脱胎于 JavaScript,但目前很多编程语言都支持 JSON 格式数据的生成和解析。JSON 的官方 MIME 类型是 application/json,文件扩展名是 .json

JSON 建构于两种结构:

  • 键值对的集合。不同的语言中,它被理解为对象(object),纪录(record),结构(struct),字典(dictionary),哈希表(hash table),有键列表(keyed list),或者关联数组(associative array)。
  • 值的有序列表。在大部分语言中,它被理解为数组(array),在 Java 中,JS 数组表示为 Collection 集合。

这两种结构分别对应 JavaScript 中的 对象数组。注意,JSON 只是一个字符串!是一个纯文本!

  • 对象{k1: v1, k2: v2, ..., kN: vN},key 必须显式得加上双引号
  • 数组[e1, e2, e3, ..., eN],JS 数组其实就是对象,其 key 是隐式的

值(即对象中的 value、数组中的 element)可以是以下类型:

  • null:空指针
  • true/false:布尔值
  • number:数值(十进制)
  • string:字符串(双引号)
  • array:数组
  • object:对象

number 只支持十进制的整数、浮点数。其中浮点数支持科学记数法,即 1.3E4 表示 13000(E 大小写不敏感)。

string 必须使用双引号包围,包括 object 中的 key,这是为了适应 C/C++、Java 中的”单引号为字符,双引号为字符串”语法。此外,还支持一些转义序列:

  • \":双引号
  • \\:反斜杠
  • \/:正斜杠
  • \b:退格符
  • \t:制表符
  • \r:回车符
  • \n:换行符
  • \f:换页符
  • \uhhhh:UTF-16 code-unit

编写 RESTful API 的一个关键点就是,Controller 方法接收 json 数据,同时,Controller 方法返回的也是 json 数据,我们知道 json 其实就是一个字符串,那么在 Spring 中,我们如何接收 json 请求体,并且又如何返回 json 响应呢?别慌,Spring MVC 提供了一系列机制,来方便我们编写 RESTful API 应用。

回顾前面的 JSON 支持一节,我们只需要在 pom.xml 中添加 jackson-databind 依赖,然后配置 spring mvc 的 annotation-driven 注解驱动元素,Spring MVC 就会自动检测到 jackson-databind 的存在,然后使用 jackson 来进行 bean/pojo/集合对象/数组对象 到 json 字符串之间的转换(称之为序列化),当然 jackson 也可以将 json 字符串反序列化为 bean/pojo/集合对象/数组对象,总之就是无缝的转换。

我们已经知道 JSON 的两大底层数据类型,数组和对象,而 Java 中常见的数据类型就是:Array/List、HashMap 两种,List 和 Array 基本上可以看作一种类型,即 JSON 口中的数组,而 HashMap 就是对象,因为 JSON 中的对象其实就是键值对,也就是 Java 中的 Map;那么 Bean/Pojo 呢?bean 和 pojo 也都可以映射到 JSON 的对象,即键值对,key 就是对象的数据成员名称,value 就是对象的数据成员值,比如一个 Student 类,有 name 和 age 两个 private 属性,同时我们定义了它们的 getter、setter 方法,那么该 student 对象就可以序列化为 { "name": "Otokaze", "age": 20 },怎么样,是不是很形象。

Spring MVC 中接收 JSON 请求
在前面的注解复习一节中,我们接触了 @RequestParam@RequestBody@RequestPart 三个与请求数据绑定的注解,@RequestParam 是用来绑定表单数据的,@RequestBody 是用来绑定 json/xml 数据的,@RequestPart 是用来绑定文件上传的。所以很显然,在 RESTful 中,如果要绑定 json 数据(自动序列化为 Java 对象),那么就要使用 @RequestBody 注解,被注解的参数类型应该是一个 bean/pojo、list/map,这样 jackson-databind 才能将 json 字符串正确的序列化为 java 对象。

Spring MVC 中返回 JSON 数据
现在我们已经知道如何接收 JSON 请求数据了,接下来我们来看看如何响应 JSON 数据给客户端,因为之前的 Spring MVC example 中,我们返回的都是一个 String 或 ModelAndView,表示这个请求将被 forward 给对应的 view 视图进行进一步处理,处理完之后,http 响应才会被发往客户端,请求结束。不过在 RESTful 中,根本不需要什么 view 视图,我们需要的是直接在 Controller 方法中返回响应结果给客户端,而不经过 forward to view 这个步骤,这时候我们就需要使用 @ResponseBody 注解标注我们的 Controller 方法,这个注解的意思非常明了,意思就是说这个方法的返回值就是响应的结果。在 Spring MVC 4.0 之后,我们可以直接在 Controller 类上使用这个注解,此时表示 Controller 里面的所有方法都是 REST 方法,相当于给每个处理方法都标上了 @ResponseBody 注解,不过,Spring MVC 之后又提供了一个 @RestController 注解,它和 @Controller + @ResponseBody 注解一起使用的效果是等价的,可看作 @Controller 的子注解。

此外,我们也可以不使用任何 @ResponseBody、@RestController 注解,而是依旧使用原先的 @Controller 注解,然后我们的控制器方法返回值改为 ResponseEntity<T> 类,ResponseEntity 中文意思就是“响应实体”,它就是一个完整的 HTTP response 的抽象表示,由 method、url、header、body 四个部分表示,所以我们不需要 @ResponseBody 标注这些方法或控制器类,因为这个返回值就表示一个完整的 HTTP 响应。

大家可以自由的选择使用 @RestController注解 + 返回Object@Controller注解 + 返回ResponseEntity 两种形式,它们都可以用来编写 RESTful API 服务,不论哪种方式,Spring 都会使用 jackson-databind 对 Object/ResponseEntity 里面的 Object 进行序列化操作,转换为 JSON 字符串。一般情况下,使用前者就可以了,不过如果你需要设置 HTTP 响应头,那么使用 ResponseEntity 可能会方便一点。虽然 ResponseEntity 很灵活和很强大,但是不应该过度使用 ResponseEntity,而是应该更简单的传统方式,这样可读性更强。当然也不是说不能使用 ResponseEntity,如果有足够的理由使用,那就大胆的使用吧。

pom.xml

web.xml

mvc.xml

@RestController 方式
EmployeeRestController.java

使用 Postman 进行 API 接口测试,得到以下结果:

可以看到,List 被转换为了 JSON 数组,Employee 实体类被转换为了 JSON 对象。

ResponseEntity 方式
EmployeeRestController.java

使用 Postman 测试,结果是一样的,就不贴出来了。一般还是建议使用 @RestController + Pojo 形式。

题外话,因为提到了 @ResponseBody,那就再提一下 @ResponseStatus 注解吧,该注解有两个属性:

  • code/value:响应状态码,其类型为 HttpStatus 枚举类。
  • reason:响应提示字符串,即 Tomcat 响应页面中的 message。

@ResponseStatus 可以用来标记三个东西,它们的意思分别是:

  • 异常类:被该注解标注的异常类,如果在 controller/service/dao 中被抛出,那么 Spring MVC 会自动将该异常转换为 HTTP 响应,然后返回给客户端,这个在前面的全局异常处理一节中已经介绍过了。
  • Controller 方法:这个时候的作用很明了,就是该请求处理方法会返回 @ResponseStatus 中注解的 code 给客户端,如使用 @ResponseStatus(HttpStatus.NOT_FOUND) 标注了一个 Controller 处理方法,那么该方法的响应状态码就是 404 Not Found。
  • Controller 类:@ResponseStatus 注解也可以标注 Controller 类,表示里面的所有请求处理方法都会继承这个响应状态码,当然也可以在方法上再次使用 @ResponseStatus 注解来重写这个继承的状态码。

ResponseEntity 使用详解
虽然不建议使用 ResponseEntity,不过有时候使用 ResponseEntity 是真的方便,可以完全脱离 Servlet-API,比如设置 HTTP 响应状态码,虽然可以使用 @ResponseStatus 注解处理器方法,但是我们不能在方法内部动态的设置 Status Code,可能你会说可以通过 @ResponseStatus + 自定义异常类来完成这个需求,但是我并不想通过这种别扭的方式来返回指定响应状态,你可能又会说,可以使用 HttpServletResponse 来设置啊,暂时不争论这个,我们来学习一下 ResponseEntity 的常见用法吧。

ResponseEntity 的类签名

可以看到,这是一个泛型类,其中 T 是响应体的类型,比如 String、Employee、List<Employee>

我们来看一下 ResponseEntity 的构造方法:

关心一下 MultiValueMap<String, String> 类型,根据 javadoc 描述,这是一个可以存储多个 value 的 key-values 键值对数据结构,它是 java.util.Map<K, List<V>> 的子接口,我们来看一下它的签名:

MultiValueMap 的常用实现类就是 HttpHeaders,它的 K 和 V 都是字符串类型,表示 HTTP 头部。

ResponseEntity 的静态方法 since v4.1+

我们来看看 HeadersBuilder 内部接口(返回的是 HeadersBuilder):

来看看 BodyBuilder 内部接口(返回的是 BodyBuilder,BodyBuilder 是 HeadersBuilder 的子接口):

好了,我们来看几个 ResponseEntity 的用法,熟悉一下怎么用:

Employee RESTful API CRUD 例子
Employee.java

EmployeeDao.java

EmployeeDaoImpl.java

EmployeeService.java

EmployeeServiceImpl.java

EmployeeRestController.java

然后使用 Postman 或 curl 测试吧。

SpringMVC 日志配置

典型的 SSM 框架需要的组件有:

  1. Tomcat
  2. SpringMVC
  3. MyBatis

这 3 个组件都可以进行日志配置:

  1. Tomcat:Apache Tomcat 的内部日志记录使用 JULI,这是一个经过重命名的 Apache Commons Logging 分支,它使用 java.util.logging 框架进行了硬编码。这可确保 Tomcat 的内部日志记录和任何 Web 应用程序日志记录保持独立,即使 Web 应用程序使用 Apache Commons Logging 也是如此。也就是说,Tomcat 默认也是使用的 JCL 作为日志抽象门面,只不过为了避免和 Web 应用程序的日志记录产生冲突,Tomcat 对 JCL 库进行重命名。Tomcat 的运行日志文件有 3 个:catalina.log 为 tomcat 服务器的运行日志、localhost.log 为虚拟机主机的运行日志、localhost_access.log 为虚拟主机的访问日志。当然你还会发现另外一个日志文件,catalina.out,这个其实是 Tomcat 上运行的程序(Servlet、JSP、SpringMVC 等)调用的 System.out.println()、System.err.println() 的重定向日志,一般不建议在应用中将日志打印到 System.out、System.err,因为会被重定向到 catalina.out 文件,而是建议使用独立的日志库,如流行的 slf4j + logback,避免这个文件过大。
  2. SpringMVC:默认也是使用 JCL 作为日志门面框架,当然使用 Spring 的人可能更喜欢使用 SLF4J + Logback,而不是 JCL 这种老旧的东西;好在 SLF4J 提供了一个方便的 jcl-over-slf4j.jar,它实际上就是另一个 JCL 门面框架(包名相同,且提供的接口也是一样的),只不过它内部使用的是 SLF4J 的绑定机制,所以可以无缝的与 commons-logging.jar 进行替换,因为这两个 jar 包提供的接口都是一样的,包名也是一样的,只不过内在的实现机制不一样;具体做法也很简单,先在 spring-core 中排除 commons-logging.jar 依赖,然后添加 slf4j 提供的 jcl-over-slf4j.jar 依赖就行了,然后就是添加 logback-classic.jar 依赖,这样就可以无缝切换到 slf4j + logback。
  3. MyBatis 没有默认的日志框架,它使用的是一个动态查找的机制,按顺序依次查找 SLF4JJCLLog4j 2Log4j 1JDK logging。只要找到其中一个,就停止查找,然后使用这个日志框架来记录日志(比如记录发送的 sql 是什么),所以要使用 SLF4J + Logback 也很简单,直接添加 slf4j-api.jar 和 logback-classic.jar 就行了。

综上所述:tomcat 不需要动它,默认的就行了,catalina.log 就是 tomcat 的运行日志。而 springmvc 和 mybatis 我们都将它改为 slf4j + logback 组合方式。下面开始介绍如何配置 springmvc 的日志功能。

pom.xml

logback.xml

运行日志输出(spring + mybatis)

可以看到 spring mvc 的一些 info 日志正常显示出来了,而且 HikariCP 的 info 日志也正常显示出来了,还有就是 mybatis 的 debug 日志,可以看到发送的具体 sql 以及传递的参数。