SpringMVC详解(二)

SpringMVC详解(二)

SpringMVC开发中,控制器的开发是最为重要的一步,而开发控制器的第一步就是让控制器的方法能够与请求的URL对应起来,这就是注@RequestMappi吨的功能,所以我们从这个注解开始讨论。

控制器

处理器映射

正如前面所提及的,如果Web工程使用SpringMVC,那么它在启动阶段就会将注解@RequestMapping所配置的内容保存到处理器映射(HandlerMapping)机制中去,后等待请求的到来,通过拦截请求信息与HandlerMapping进行匹配,找到对应的处理器(它包含控制器的逻辑),并将处理器及其拦截器保到HandlerExecutionChain对象中,返回给DispatcherServlet,这样DispatcherServlet就可以运行它们了。

从论述中可以看到,HandlerMapping的主要任务是将请求定位到具体的处理器上。关于@RequestMapping的配置项并不多,这里通过源码来学习

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Target({ElementTypeMETHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
public @interface RequestMapping{
//配置请求映射名称
String name() default ""
//通过路径映射@AliasFor(”path")
String[] value()default{};
//通过路径映射回path配置项目
@AliasFor(”value”)
String[] path() default{};
//限定只响应HTTP请求类型,如GET,POST、HEAD、OPTIONS、PUT、TRACE等
//默认的情况下,可以响应所有的请求类型
RequestMethod[] method() default{};
//当存在对应的HTTP参数时才响应请求
String[] params() default{};
//限定请求头存在对应的参数时才响应
String[] headers() default{};
//限定HTTP请求体提交类型,如”applicat工on/〕son”、Htext/html”
String[] consumes() default{};
//限定返回的内容类型,仅当HTTPi苛求头中的(Accept)类型中包含该指定类型时才返回
String[] produces() default{};

这里可以通过配置项value或者path来设置请求URL。从而让对应的请求映射到控制器或其方法上,在此基础上还可以通过其他配置项来缩小请求映射的范围。

当然,配置项value和path也可以通过正则式来让方法匹配多个请求。

但是从现实的角度来说,如果不是有必要,尽量不要这么做。因为这样请求的匹配规则就复杂了,会对后续开发造成一定的困扰。

因此在能够明确场景下,都建议一个路径对应一个方法或者让正则式的匹配规则简单明了,这样就能够提高程序的可读性,以利于后续的维护和改造。

路径是必需的配置项,这里的method配置项可以限定HTTP的请求类型,这是最常用的配置项,可以区分HTTP的GET或者POST等不同的请求。

只是在Spring4.3的版本之后,为了简化method配置项的配置新增了几个注解,如@GetMapping、@PostMapping、@PatchMapping、@PutMapping和@DeleteMapping。

获取控制器参数

处理器是对控制器的包装,在处理器运行的过程中会调度控制器的方法,只是它在进入控制器方法之前会对HTTP的参数和上下文进行解析,将它们转换为控制器所需的参数。这一步是处理器首先需要做的事情,只是在大部分的情况下不需要自己去开发这一步,因为SpringMVC已经提供了大量的转换规则,通过这些规则就能非常简易地获取大部分的参数。正如之前章节一样,在大部分情况下,我们并没有太在意如何获取参数,那是因为之前的场景都比较简单,在实际的开发中可能遇到一些复杂的场景,这样参数的获取就会变得复杂起来。例如,可能前端传递一个格式化的日期参数,又如需要传递复杂的对象给控制器,这个时候就需要对SpringMVC参数的获取做进一步的学习了。

在无注解下获取参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RequestMapping("/my")
@Controller
public class MyController {
/**
* 在无注解下获取参数,要求参数名称和HTTP请求参数名称一致.
*
* @param intVal
* -- 整数
* @param longVal
* -- 长整形
* @param str
* --字符串
* @return 响应JSON参数
*/
// HTTP GET请求
@GetMapping("/no/annotation")
@ResponseBody
public Map<String, Object> noAnnotation(Integer intVal, Long longVal, String str) {
Map<String, Object> paramsMap = new HashMap<>();
paramsMap.put("intVal", intVal);
paramsMap.put("longVal", longVal);
paramsMap.put("str", str);
return paramsMap;
}
}

然后打开浏览器,输入:http://localhost:8888/my/no/annotation?intVal=10&longVal=200进行测试。

这里的表达很明确,就接收参数是什么,那填写的信息就必须是什么,这样的做法,在如今前后端分离的情况下非常不具有优势,很可能前后和后端需要的参数不同,所以,我们一般会使用注解的方式,来表达我们的值。

在注解的情况下获取参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@RequestMapping("/my")
@Controller
public class MyController {

/**
* 通过注解@RequestParam获取参数
*
* @param intVal
* -- 整数
* @param longVal
* -- 长整形
* @param str
* --字符串
* @return 响应JSON数据集
*/
@GetMapping("/annotation")
@ResponseBody
public Map<String, Object> requestParam(@RequestParam("int_val") Integer intVal,
@RequestParam("long_val") Long longVal, @RequestParam("str_val") String strVal) {
Map<String, Object> paramsMap = new HashMap<>();
paramsMap.put("intVal", intVal);
paramsMap.put("longVal", longVal);
paramsMap.put("strVal", strVal);
return paramsMap;
}
}

代码中可以看到,在方法参数处使用了注解@RequestParam,目的是指定HTTP参数和方法参数的映射关系,这样处理器就会按照其配的映射关系来得到参数,然后调用控制器的方法。

启动SpringBoot应用后,在浏览器地址栏输入http://localhost:8888/my/annotation?intval=l&longva1=2&strval=str,就能够看到请求的结果了。

但如果把个HTTP参数中的任意一个删去,就会得到异常信息,因为在默认的情况下@RequestPara标注的参数是不能为空的,为了让它能够为空,可以配置其属性required为false。

传递数组

除了传递一些简单的值之外,还能够传递数组

1
2
3
4
5
6
7
8
9
@GetMapping("/requestArray")
@ResponseBody
public Map<String, Object> requestArray(int[] intArr, Long[] longArr, String[] strArr) {
Map<String, Object> paramsMap = new HashMap<>();
paramsMap.put("intArr", intArr);
paramsMap.put("longArr", longArr);
paramsMap.put("strArr", strArr);
return paramsMap;
}

在页面上得到的数值的值,是通过逗号隔开的。

传递JSON

JSON已经成为当今我的数据规范了,所以,我们来展示一下如何表达:前后端互相传递JSON的情况。

HTML页面

首先需要建立一个表单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<!-- 加载Query文件-->
<script src="https://code.jquery.com/jquery-3.2.0.js">
</script>
<script type="text/javascript">
$(document).ready(function() {
$("#submit").click(function() {
var userName = $("#userName").val();
var note = $("#note").val();
if ($.trim(userName)=='') {
alert("用户名不能为空!");
return;
}
var params = {
userName : userName,
note : note
};
$.post({
url : "./insert",
// 此处需要告知传递参数类型为JSON,不能缺少
contentType : "application/json",
// 将JSON转化为字符串传递
data : JSON.stringify(params),
// 成功后的方法
success : function(result) {
if (result == null || result.id == null) {
alert("插入失败");
return;
}
alert("插入成功");
}
});
});
});
</script>
</head>
<body>
<div style="margin: 20px 0;"></div>
<form id="insertForm">
<table>
<tr>
<td>用户名称:</td>
<td><input id="userName" name="userName"></td>
</tr>
<tr>
<td>备注</td>
<td><input id="note" name="note"></td>
</tr>
<tr>
<td></td>
<td align="right"><input id="submit" type="button" value="提交" /></td>
</tr>
</table>
</form>
</body>
</html>

这里定义了一个简易的表单,它使用了jQuery进行Ajax提交。

注意它的JS代码,它指定了提交的请求地址Curl)、数据(data)、提交类型(contentType)和事后事件(success)。

从代码来看,这里先组织了一个JSON数据集,而且把提交类型也设置为了JSON类,然后才提交到控制器。

这样控制器就可以得到一个JSON数据集的请求体了为了打开这个表单,需要在UserController中编写一个add方法,它将返回一个字符串,映射到这个表单上,这样就能通过视图解析器(ViewResolver)找到它了。

然后再写一个相应新增用户的请求insert方法,它将从HTTP请求体中读出这个JSON。

add和insert方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Controller
@RequestMapping("/user")
public class UserController {
// 注入用户服务类
@Autowired
private UserService userService = null;

/**
* 打开请求页面
*
* @return 字符串,指向页面
*/
@GetMapping("/add")
public String add() {
return "add";
}

/**
* 新增用户
*
* @param user
* 通过@RequestBody注解得到JSON参数
* @return 回填id后的用户信息
*/
@PostMapping("/insert")
@ResponseBody
public User insert(@RequestBody User user) {
userService.insertUser(user);
return user;
}

这样通过请求add方法,就能请求到对应的HTML表单。接着录入表单,点击提交按钮,这样通过JavaScript脚本提交JSON消息,就可以请求到控制器的insert方法。

这个方法的参数标注为@RequestBody,意味着它将接收前提交的JSON请求体,而在JSON请求体与User类之间的属性名称是保持一致的,这样SpringMVC就会通过这层映射关系将JSON请求体转换为User对象。

通过URL传递参数

当然讲到传递参数,之前也提到了以REST风格的方式去传递参数,那么我们在这里就列出来先吧。

1
2
3
4
5
6
7
8
9

// {...}代表占位符,还可以配置参数名称
@GetMapping("/{id}")
// 响应为JSON数据集
@ResponseBody
// @PathVariable通过名称获取参数
public User get(@PathVariable("id") Long id) {
return userService.getUser(id);
}

这样在浏览器中直接输入 user/1 浏览器就会自动将这个 “1” 识别为id的值。

获取格式化参数

一些应用中,往往需要格式化数据,其中最为典型的当属日期和货币。例如,在一些系统中日期格式约定为yyyy-MM-dd,金额约定为货币符号和用逗号分隔,如100万美元写作$1,000,000.00等。

同样地,SpringMVC也对此提供了良好的支持。对日期和数字类型的转换注解进行处理,分别是@DateTimeFormat和@NumberFormato其中@DateTimeFormat是针对日期进行格式化的,@NumberFormat则是针对数字进行格式化的。

测试日期表单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>格式化</title>
</head>
<body>
<form action="./commit" method="post">
<table>
<tr>
<td>日期(yyyy-MM-dd)</td>
<td><input type="text" name="date" value="2017-08-08" /></td>
</tr>
<tr>
<td>金额(#,###.##)</td>
<td><input type="text" name="number" value="1,234,567.89" /></td>
</tr>
<tr>
<td colspan="2" align="right"><input type="submit" value="提交" />
</td>
</tr>
</table>
</form>
</body>
</html>

表单中有两个文本框,一个日期,一个金额。

控制器打开页面和提交
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 映射HTML页面
@GetMapping("/form")
public String showFormat() {
return "formatter";
}

// 获取提交参数
@PostMapping("/commit")
@ResponseBody
public Map<String, Object> format(Date date,
@NumberFormat(pattern = "#,###.##") Double number) {
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("date", date);
dataMap.put("number", number);
return dataMap;
}

这里的showFormat方法,是将请求映射到HTML表单上。format方法的代码使用了注解@DateTimeFormat和@NumberFormat,它们配置了格式化所约定的格式,所以Spring会根据约定的格式把数据转换出来,这样就可以完成参数的转换。

启动SpringBoot后,请求http://localhost:8080/my/form,就可以看到图所示的表单。

提交表单后,就可以看到对应的JSON数据集输出,这样就可以获取那些格式化的参数了。

在SpringBoot中,日期参数的格式化也可以不使用@DateTimeFormat,而只在配置文件application.properties中加入如下配置项即可:

1
spring.mvc.date-format=yyyy-MM-dd

自定义参数转换规则

根据上面所表述的内容,我们很容易就能够得出一个规律,传递参数的方法,很多时候仅仅只需要更换不同的注解,就能够实现相应的需求。

那么这些注解是怎么运作的呢?或者说,SpringMVC所实现的参数转换规则,是怎么样的呢?

HTTP的请求包含请求头(Header)、请求体CBody)、URL和参数等内容,服务器还包含其上下文环境和客户端交互会话(Session)机制,而这里的消息转换是指请求体的转换。下面我们讨论SpringMVC是如何从这些HTTP请求中获取参数的。

处理器获取参数逻辑

一个请求来到时,在处理器执行的过程中,它首先会从HTTP请求和上下文环境来得到参数。如果是简易的参数它会以简单的转换器进行转换,而这些简单的转换器是SpringMVC自身己经提供了的。

但是如果是转换HTTP请求体(Body),它就会调用HttpMessageConverter接口的方法对请求体的信息进行转换,首先它会先判断能否对请求体进行转换,如果可以就会将其转换为Java类型。以下是对HttpMessageConverter接口的探讨。

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface HttpMessageConverter<T>{
//是否可读,其中clazz为Java型,ediaType为HTTP请求类型
boolean canRead(Class<?> clazz,MediaType mediaType);
//判断clazz类型是否能够转换为mediaT_rpe媒体类型
//其中clazzjava类型,mediaType为HTTP响应类型
boolean canWrite(Class<?> clazz,MediaType mediaType);
//可支持的媒体类型列表
List<MediaType> getSupportedMediaTypes();
//当canRead验证通过后,读入HTTP求信息
T read(Class<?extendsT> clazz,HttpinputMessage inputMessage) throws IOException,HttpMessage NotReadableException;
//当canWrite方法验证通过后,写入响应
void write(Tt,MediaType contentType,HttpOutputMessage outputMessage) throws IOException,HttpMessage NotWritableException;
}

代码中控制器方法的参数标注了@RequestBody,所以处理器会采用请求体(Body)的内容进行参数转换。

而前端的请求体为JSON类型,所以首先它会调用canRead方法来确定请求体是否可读。

如果判定可读后,接着就是使用read方法,将前端提交的用户JSON类型的请求体转换为控制器的用户(User)类参数,这样控制器就能够得到参数了。

上面的HttpMessageConverter接口只是将HTTP的请求体转换为对应的Java对象,而对于HTTP参数和其他内容,还没有进行讨论。

例如,以性别参数来说前端可能传递给控制器的是一个整数,而控制器参数却是一个枚举,这样就需要提供自定义的参数转换规则。

为了讨论自定义的参数规则,很有必要先了解处理器转换参数的过程。在SpringMVC中,是通过WebDataBinder机制来获取参数的,它的主要作用是解析HTTP请求的上下文,然后在控制器的调用之前转换参数并且提供验证的功能,为调用控制器方法做准备。

处理器会从HTTP请求中读取数据,然后通过三种接口来进行各类参数转换,这种接口是Converter、Formatter和GenericConverter。

在SpringMVC的机制中这三种接口的实现类都采用了注册机的机制,默认的情况下SpringMVC己经在注册机内注册了许多的转换器,这样就可以实现大部分的数据类型的转换,所以在大部分的情况下无须开发者再提供转换器,

同样地,需要自定义转换规则时,需要在注册机上注册自己的转换器就可以了。

实际上,WebDataBinder机制还有一个重要的功能,那就是验证转换结果。关于验证机制,后面会再讨论。有了参数的转换和验证,最终控制器就可以得到合法的参数。得到这些参数后,就可以调用控制器的方法了。

这个图严格来说是请求体转换的全流程,但是有些时候SpringMVC并不会走完全程,而是根据现实情况来处理消息的转换。

根据上面的讨论,可以看到控制器的参数是处理器通过Convert町、Formatter和GenericConverter这三个接口转换出来的。

这里先谈谈这三个接口的不同之处。

  1. 首先,Converter是一个普通的转换器,例如,有一个Integer类型的控制器参数,而从HTTP对应的为字符串,对应的Converter就会将字符串转换为Integer类型:
  2. 其次,Formatter则是一个格式化转换器,类似那些日期字符串就是通过它按照约定的格式转换为日期的;
  3. 最后,GenericConverter转换器则将HTTP参数转换为数组。

这就是上述例子可以通过比较简单的注解就能够得到各类参数的原因。

对于数据类型转换,SpringMVC提供了→个服务机制去管理,它就是ConversionService接口。在默认的情况下,会使用这个接口的子类DefaultFormattingConversionService对象来管理这些转换类。

其关系如图所示:

从图可以看出,Converter、Formatter和GenericConverter可以通过注册机接口进行注册,这样处理器就可以获取对应的转换器来实现参数的转换。上面讨论的是普通SpringMVC的参数转换规则,而在SpringBoot中还提供了特殊的机制来管理这些转换器。SpringBoot的自动配置类WebMvcAutoConfiguration还定义了一个内部类WebMvcAuto­ConfigurationAdapter。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//注册各类转换器,registry实际为DefaultFormattingConversionService对象
@Override
public void addFormatters(FormatterRegistryregistry)
{
//遍历IoC容器,找到Converter类型的Bean注册到服务类中
for(Converter<?,?> converter:getBeansOfType(Converter.class)) {
registry.addConverter(converter);
//遍历IoC容器,找到GenericConverter类型的Bean注册到服务类中
for(GenericConverter converter:getBeansOfType(GenericConverter.class)){
registry.addConverter(converter);
//遍历IoC容器,找到Formatter类型的Bean注册到服务类中
for(Formatter<?> formatter:getBeansOfType(Formatter.class)){
registry.addFormatter(formatter);
}
}

代码中加入了中文注释以利于理解,通过这个方法,可以看到在SpringBoot的初始化中,会将对应用户自定义的Converter、Formatter和GenericConverter的实现类所创建的SpringBean自动地注册到DefaultFormattingConversionService对象中。

这样对于开发者只需要自定义Converter、Formatter和GenericConverter的接口的Bean,SpringBoot就会通过这个方法将它们注册到ConversionService对象中。只是格式化Formatter接口,在实际开发中使用率比较低,所以不再论述。

一对一转换器

Converter是一对一的转化器,也就是从一种类型转换为另外一种类型,其接口定义十分简单:

1
2
3
public  interface  Converter<S ,  T>  {
T convert(S source );
}

这个接口的类型有源类型CS)和目标类型(T)两种,它们通过conv创方法进行转换。

例如,HTTP的类型为字符串(String)型,而控制器参数为Long型,那么就可以通过Spring内部提供的StringToNumber<TextendsNumber>进行转换。

假设前端要传递一个用户的信息,这个用户信息的格式是{id}-{userName}”{note},而控制器的参数是User类对象。

因为这个格式比较特殊,Spring当前并没有对应的Converter进行转换,因此需要自定义转换器。这里需要的是一个从String转换为User的转换器:

字符串用户转换器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 自定义字符串用户转换器
*/
@Component
public class StringToUserConverter implements Converter<String, User> {
/**
* 转换方法
*/
@Override
public User convert(String userStr) {
User user = new User();
String []strArr = userStr.split("-");
Long id = Long.parseLong(strArr[0]);
String userName = strArr[1];
String note = strArr[2];
user.setId(id);
user.setUserName(userName);
user.setNote(note);
return user;
}
}

这里的类标注为@Component,并且实现了Converter接口,这样Spring就会将这个类扫描并装配到IoC容器中。对于SpringBoot,之前分析过它会在初始化时把这类自动地注册到转换机制中,所以注册这步并不需要人再处理。这里泛型指定为String和User,这样SpringMVC就会通过HTTP的参数类型(String)和控制器的数类型(User)进行匹配,就可以从注册机制中发现这个转换类,这样就能够将参数转换出来。下面写一个控制器方法对其进行验证:

1
2
3
4
5
@GetMapping("/converter")
@ResponseBody
public User getUserByConverter(User user) {
return user;
}

然后打开浏览器,输入:http://localhost:8888/user/converter?user=9-谢广坤-路人

可以发现,我们自动把这个字符串长段,转化为了User对象。

GenericConverter集合和数组转换

GenericConverter是数组转换器。因为SpringMVC自身提供了一些数组转换器,需要自定义的不多,所以这里只介绍SpringMVC自定义的数组转换器。

假设需要同时新增多个用户,这样便需传递一个用户列表(List<User>)给控制器。此时SpringMVC会使用StringToCollectionConverter转它,这个类实现了GenericConverter接口,并且是SpringVC内部己经注册的数组转换器。

它首会把字符串用逗号分隔为个个的子宇符串,然后根据原类型泛型为String、目标类型泛型为User。

1
2
3
4
5
@GetMapping("/list")
@ResponseBody
public List<User> list(List<User> userList) {
return userList;
}

实验方法和上面的一样,多个用户只需要使用逗号隔开就行了。