SpringMVC数据绑定
基本类型
对于基本类型是用int型还是Integer型?
如果传入int型参数:
1. 只能是int类型的,如果传入age="abc",如访问http://localhost/root?age=abc,会报400参数异常
2. int型:key是必传的,如果不传入,如访问http://localhost/root,会报500内部错误异常。
如果传入Integer类型,这是一个对int的包装类,本质上是一个对象:
可以不传key,那么访问http://localhost/root,则age属性为null即`age: null`
对于可能为空的数据,最好使用包装类型。还有一点,int型的默认值为0,Integer的默认值为null,如果数据库某字段允许NULL值,使用int型的话,存入的将是0而不是NULL。
举个例子,有这么一个方法。
@PostMapping("/getAge")
public String getAge(int age) {
return "age: " + age;
}
当我们不传age字段时,因为参数是基本数据类型,当然会报500内部错误;当表单传输的字段名为age时,会自动绑定到Controller方法中的同名参数。如果把参数名改一改,
@PostMapping("/getAge")
public String getAge(int a) {
return "age: " + a;
}
当表单传入age=66,此时在方法中找不到同名参数,自动绑定就会失效。如果像上面一样用的是int型,则会报500错误。并给出很贴心的错误提示如下
Optional int parameter 'age' is present but cannot be translated into a null value due to being declared as a primitive type. Consider declaring it as object wrapper for the corresponding primitive type.
大概意思是说不能将age转换成null值,因为它被声明为一个原始类型了。并提示我们可以使用其相应的包装类。好,按照提示改成Integer,再运行上面程序。
不报错了,响应如下内容,说明数据绑定失效,虽然传入了age=66,但找不到同名参数,不能绑定到方法参数a
上,所以参数a只好绑定null。而且,即使我们不传入age字段,也不会报错,响应内容依然和下面一样。(这就是推荐使用Integer而不使用int的原因之一)
age: null
可以使用@RequstParam将请求参数绑定到参数上,value属性明确指定表单字段的名称。
@PostMapping("/getAge")
public String getAge(@RequestParam(value = "age") Integer a) {
return "age: " + a;
}
用value来明确指定表单传入的字段名,这个注解的意思是,将名为age字段值绑定到参数a上,使用@RequestParam不要求方法参数名和字段名一样,使得数据绑定时候更加灵活。注意当只有一个value属性时,可省略value =
。该注解还有几个比较重要的属性
- name:是value的别名,用哪个都可以
- required:该字段是否必传,默认是true,如果不传会报错;改为false说明在前台不必传该子弹。
- defaultValue:字段的默认值
来试一下,
@PostMapping("/getAge")
public String getAge(@RequestParam(value = "age", required = false, defaultValue = "5") Integer a) {
return "age: " + a;
}
当访问http://localhost:8080/getAge,由于不必传age字段,而且指定了age的默认值为5,所以响应是
age: 5
数组
当传入的多个字段key都是同一个名称如下面的name,则会绑定到方法中的数组参数中。
Snipaste_2018-10-06_17-40-00@PostMapping("/array")
public String array(@RequestParam("name") String[] names) {
StringBuilder sb = new StringBuilder();
for (String name : names) {
sb.append(name).append(" ");
}
return sb.toString();
}
响应结果如下
abc def ghi
当然如果是GET方式请求,访问http://localhost/array?name=abc&name=def&name=ghi 也是可以的。
SpringMVC可以将同一个字段key的多个值映射到一个数组中,那自然也能映射到List中。将参数String[] names
改成List<String> names
,完全没问题。
对象以及多层级对象
SpringMVC可以将表单的字段与Controller方法参数中的对象属性进行映射,前提是两者的名称和类型一致。
比如表单中传入name=Jim&age=33
,方法参数中的User对象也恰好有String name与Integer age属性,SpringMVC将完成字段 --> 对象属性的映射并将数据绑定到User对象的对应属性上。
这里User类,有name和age字段,还有一个对象ContactInfo。
package com.shy.springmvcbingding.domain;
public class User {
private String name;
private Integer age;
private ContactInfo contactInfo;
// getter和setter方法省略
}
package com.shy.springmvcbingding.domain;
public class ContactInfo {
private String address;
private String phone;
// getter和setter方法省略
}
在Controller中,方法参数可以是对象
// Controller被@RestController修饰
@PostMapping("/user")
public User getUser(User user) {
return user;
}
如果我们传入以下参数
Snipaste_2018-10-06_20-46-17响应输出为
{
"name": "Jim",
"age": 22,
"contactInfo": {
"address": "China",
"phone": "13345678910"
}
}
SpringMVC将我们输入字段的类型、名称与方法参数中User对象中的属性一一对比,如果吻合,将会自动映射过去并封装成对象,如果某些子弹和对象的属性不能匹配,封装成的对象中该属性为null。比如上面不小心将contactInfo.address
写成了contactInfo.addr
,响应是下面这样的,可以看到contactInfo的address字段为null了。
{
"name": "Jim",
"age": 33,
"contactInfo": {
"address": null,
"phone": "13345678910"
}
}
对于多层级的对象,如User中的ContactInfo类,该类又有address和phone两个属性。对于这种多层级的对象,需要使用.
取到具体的属性。比如user.contactInfo.phone
表示user实体中cantactInfo中的phone字段。因此在请求getUser方法时,若要将字段值注入到user中contactInfo的phone属性,需要传入contactInfo.phone
。
拥有同属性的多个对象
假如现在有一个Admin类,拥有和User一样的属性
package com.shy.springmvcbingding.domain;
/**
* @author Haiyu
* @date 2018/10/6 21:39
*/
public class Admin {
private String name;
private Integer age;
private ContactInfo contactInfo;
// getter setter方法省略
}
同时新增一个方法
@PostMapping("/userAndAdmin")
public String getUser(User user, Admin admin) {
return user.toString() + " "+ admin.toString();
}
方法参数中既有User又有Admin,且两者拥有一样的属性。SpringMVC是如何进行绑定的呢?
User{name='Bob', age=22, contactInfo=ContactInfo{address='China', phone='13345678910'}} Admin{name='Bob', age=22, contactInfo=ContactInfo{address='China', phone='13345678910'}}
可以看到,它不做区分的给User和Admin进行了数据绑定。假如我们想有区分的进行绑定,要怎么做呢?可以使用@InitBinder注解
@InitBinder用于初始化WebDataBinder,在当前Controller类中有效。被@InitBinder注解的方法需要有WebDataBinder方法,且返回值一般是void,可以@RequstMapping方法调用之前,就对一些属性进行初始化。
如下是使用@InitBinder的例子
@InitBinder("user")
public void initUser(WebDataBinder binder) {
binder.setFieldDefaultPrefix("user.");
}
@InitBinder("admin")
public void initAdmin(WebDataBinder binder) {
binder.setFieldDefaultPrefix("admin.");
}
它有一个属性value,不指定的话,默认作用于当前Controller的所有表单属性和请求参数(可以认为是@ModelAttribute和@RequstParam),如果指定了value值,就像上面那样,那么只针对参数是value值的attribute和param生效。比如@InitBinder("user")
只对@ModelAttribute User user
有效,@InitBinder("admin")
只对@ModelAttribute Admin admin
有效。综上,这两个方法的意义在于给参数user、admin分别设置参数前缀user.
和admin.
这样不同的init-binder可以作用与不同的request param或model attribute
这下再来访问/userAndAdmin
,可以看到我们给user.name
和admin.name
赋予了不同的值
有了前缀的区分,SpringMVC精准的将字段绑定到对应的类对象上了。
User{name='Bob', age=22, contactInfo=null} Admin{name='Lucy', age=21, contactInfo=null}
现在有个问题,如果我们不配置init-binder,参数也传上面那样的呢?
User{name='null', age=null, contactInfo=null} Admin{name='null', age=null, contactInfo=null}
由于没有给参数配置前缀,SpringMVC不认识user.name
这样的属性了。
当然如果Admin的属性和User不一样,比如name改成adminName,age改成adminAge,那么是无需配置init-binder的。就像下面那样
Snipaste_2018-10-06_22-21-36响应是
User{name='Lucy', age=21, contactInfo=null} Admin{adminName='Bob', adminAge=22, contactInfo=null}
SpringMVC会将匹配的字段分别绑定到了User和Admin对象上。
List、Set、Map
List
要将数据绑定到List<Integer>
或者List<String>
,其实是可以像下面这样写的。
public String array(@RequestParam("name") List<String> names) {...}
public String array(@RequestParam("age") List<Integer> ages) {...}
在表单中只要提交相同的字段即可name=bob&name=jim&name=lucy
但是数据要绑定到List<User>
上,就不能这样写了因为User中还有其他属性。可以仿照上面User中的CantactInfo一样,用一个UserList类将List<User>
包裹起来,对list某个元素的访问可以使用list[index]
的形式。
package com.shy.springmvcbingding.domain;
import java.util.List;
public class UserList {
private List<User> users;
// getter和setter省略
}
相应的将Controller中的方法改成下面这样
@PostMapping("/list")
public UserList userList(UserList userList) {
return userList;
}
因为userList中的List<User>
其属性名是users,所以users[0]表示第一个user,users[1]表示第二个user,以此类推。
可以看到,在表单中传入字段时,我们故意跳过了users[1],会报空指针异常吗?
{
"users": [
{
"name": "vv",
"age": 33,
"contactInfo": null
},
{
"name": null,
"age": null,
"contactInfo": null
},
{
"name": "F",
"age": 44,
"contactInfo": null
}
]
}
从响应中得知,被跳过的索引被设置成null了,并不会导致空指针。
Set
对于Set类型的数据绑定。先试着仿照List的做法。
package com.shy.springmvcbingding.domain;
import java.util.Set;
public class UserSet {
Set<User> users;
// getter和setter方法省略
}
Controller中新增方法set
@PostMapping("set")
public UserSet userSet(UserSet userSet) {
return userSet;
}
表单中还是和List一样传入相同的字段。运行一下会发现报错了
Invalid property 'users[0]' of bean class [com.shy.springmvcbingding.domain.UserSet]: Cannot get element with index 0 from Set of size 0, accessed using property path 'users[0]'
追踪源码
else if (value instanceof Set) {
// Apply index to Iterator in case of a Set.
Set<Object> set = (Set<Object>) value;
int index = Integer.parseInt(key);
if (index < 0 || index >= set.size()) {
throw new InvalidPropertyException(getRootClass(), this.nestedPath + propertyName,
"Cannot get element with index " + index + " from Set of size " +
set.size() + ", accessed using property path '" + propertyName + "'");
}
这里用到了set.size()
是0,说明Set中没有任何对象。好吧,我那们就手动add几个进去。
给UserSet新增一个构造方法
public UserSet() {
users = new LinkedHashSet<>();
users.add(new User());
users.add(new User());
users.add(new User());
}
再运行程序就会得到和List一样结果。但是Set的主要功能是去重,当name和age一样时,就认为这两个对象是相等的,有个好的建议是对于自定义对象的Set和Map都必须重写hashCode方法equals方法。
在User类中,重写这两个方法alt + insert让IDEA自动帮我们生成。
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(name, user.name) &&
Objects.equals(age, user.age);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
再运行程序,又报错了!原因还是越界了,因为在UserSet方法中连续add了三次User,但由于我们重写了hashCode和equals方法,这三个user是重复的。三次add后set的实际size是1。现在啊只传users[0].name=vv&users[0].age=33
程序就不会报错了。
{
"users": [
{
"name": "vv",
"age": 33,
"contactInfo": null
}
]
}
Map
和上面一样先写UserMap
package com.shy.springmvcbingding.domain;
import java.util.Map;
public class UserMap {
private Map<String,User> users;
// getter和setter方法省略
}
在Controller中
@PostMapping("map")
public UserMap userMap(UserMap userMap) {
return userMap;
}
然后用map['key']
的形式传入以下参数即可。
响应内容为
{
"users": {
"X": {
"name": "Jack",
"age": 22,
"contactInfo": null
},
"Y": {
"name": "Lucy",
"age": 20,
"contactInfo": null
}
}
}
Json&XML
使用注解@RequstBody可以处理contentType:"application/json"和“application/xml”编码的内容,并将json或者xml中的数据绑定到参数对象上。
先来测试Json,把contentType改成application/json,然后访问/json
,
@PostMapping("/json")
public User userJson(@RequestBody User user) {
return user;
}
在request body中传入以下json内容
{
"name":"bob",
"age": 16
}
响应内容是
{
"name": "bob",
"age": 16,
"contactInfo": null
}
同理,对于XML,contentType改成application/xml。在User类中,加几个xml绑定的注解。
package com.shy.springmvcbingding.domain;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import java.util.Objects;
@XmlRootElement(name = "user")
public class User {
private String name;
private Integer age;
@XmlElement(name = "name")
public String getName() {
return name;
}
@XmlElement(name = "age")
public Integer getAge() {
return age;
}
}
@XmlElement可以将Java属性映射成xml节点。
- 在类上,加@XmlRootElement(name = "user"),表明xml的根元素名为user,如果不指定name,默认使用Java类名
- 在getter/setter方法上加@XmlElement,将该属性映射成xml的对应节点。同样的,果不指定name,默认使用Java属性名
然后访问/xml
,传入以下xml
<?xml version="1.0" encoding="UTF-8"?>
<user>
<nam>Bob</nam>
<age>22</age>
</user>
响应内容为
{
"name": "Bob",
"age": 22,
"contactInfo": null
}
注意,@XmlElement最好用来getter方法上,如果用在了属性(Field)上,会引发下面的异常。
// @XmlElement(name = "name")
// private String name;
类的两个属性具有相同名称 "name"
this problem is related to the following location:
at public java.lang.String com.shy.springmvcbingding.domain.User.getName()
at com.shy.springmvcbingding.domain.User
this problem is related to the following location:
at private java.lang.String com.shy.springmvcbingding.domain.User.name
at com.shy.springmvcbingding.domain.User
] with root cause
日期转换
PropertyEditor
如果要绑定的类型是Date型的,在表单中传入date=2018-10-01
,很明显会报错,说不能将String转换成Date型。
可以用init-binder为参数date注册一个PropertyEditor,其中有个子类CustomDateEditor
@InitBinder("date")
public void initDate(WebDataBinder binder) {
binder.registerCustomEditor(Date.class, new CustomDateEditor(new SimpleDateFormat("yyyy-MM-dd"), true));
}
@PostMapping("date")
public String getDate(@RequestParam("date") Date date) {
return date.toString();
}
其实就是将Date使用SimpleDateFormat来解析(parse)和格式化(format)。
来看下CustomDateEditor的源码,重点关注它重写的setAsText和getAsText方法
public CustomDateEditor(DateFormat dateFormat, boolean allowEmpty, int exactDateLength) {
this.dateFormat = dateFormat;
this.allowEmpty = allowEmpty;
this.exactDateLength = exactDateLength;
}
@Override
public void setAsText(@Nullable String text) throws IllegalArgumentException {
if (this.allowEmpty && !StringUtils.hasText(text)) {
// Treat empty String as null value.
setValue(null);
}
else if (text != null && this.exactDateLength >= 0 && text.length() != this.exactDateLength) {
throw new IllegalArgumentException(
"Could not parse date: it is not exactly" + this.exactDateLength + "characters long");
}
else {
try {
// 设置value,设置的是从String解析后的Date
setValue(this.dateFormat.parse(text));
}
catch (ParseException ex) {
throw new IllegalArgumentException("Could not parse date: " + ex.getMessage(), ex);
}
}
}
@Override
public String getAsText() {
// 取出value并格式化
Date value = (Date) getValue();
return (value != null ? this.dateFormat.format(value) : "");
}
public void setValue(Object value) {
this.value = value;
firePropertyChange();
}
public Object getValue() {
return value;
}
可以发现其实CustomDateEditor就是对DateFormat做了一个封装而已,参数allowEmpty表示是否允许text为空,该类会将空字符串当成null值。
现在运行程序,响应内容为
Mon Oct 01 00:00:00 CST 2018
成功将字符串2018-10-01转换成了Date型。
Formatter
在package org.springframework.format
下有这么一个接口
public interface Formatter<T> extends Printer<T>, Parser<T> {
}
Spring下有一个实现类DateFormatter,可以完成从字符串到日期的转换,直接使用即可。
不过需要在xml中引入FormattingConversionServiceFactoryBean并注入相应的property
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:mvc="http://www.springframework.org/schema/mvc"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd">
<mvc:annotation-driven conversion-service="dateFormatter"/>
<bean id="dateFormatter" class="org.springframework.format.support.FormattingConversionServiceFactoryBean">
<property name="formatters">
<set>
<bean id="formatter" class="org.springframework.format.datetime.DateFormatter"/>
</set>
</property>
</bean>
</beans>
定义好dateFormatter这个bean后,别忘了在mvc注解驱动中加上conversion-service,其属性值对应了FormattingConversionServiceFactoryBean的bean id。
<mvc:annotation-driven conversion-service="dateFormatter"/>
最后在Java Config中引入该xml文件
package com.shy.springmvcbingding.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
/**
* @author Haiyu
* @date 2018/10/7 22:33
*/
@Configuration
@ImportResource("classpath:bind.xml")
public class BindingConfig {
}
这样SpringBootApplication会扫描到这个配置文件并加载到上下文中。
Converter
FormattingConversionServiceFactoryBean这个类有两个重要的属性。
@Nullable
private Set<?> converters;
@Nullable
private Set<?> formatters;
可知它既可以实现Formatter也可以实现Converter。那么只需把注入的property属性改成converters即可。
<mvc:annotation-driven conversion-service="dateConverter"/>
<bean id="dateConverter" class="org.springframework.format.support.FormattingConversionServiceFactoryBean">
<!--<property name="formatters">-->
<!--<set>-->
<!--<bean id="formatter" class="org.springframework.format.datetime.DateFormatter"/>-->
<!--</set>-->
<!--</property>-->
<property name="converters">
<set>
<bean id="converter" class="com.shy.springmvcbingding.converter.DateConverter">
<constructor-arg name="pattern" value="yyyy-MM-dd" />
</bean>
</set>
</property>
</bean>
DateConverter是自定义类,实现了Converter<S,T>接口,可以将S类型转换成T类型。如下,DateConverter<String, Date>是一个将String类型的转换成Date类型的转换器。
package com.shy.springmvcbingding.converter;
import org.springframework.core.convert.converter.Converter;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* @author Haiyu
* @date 2018/10/7 22:59
*/
public class DateConverter implements Converter<String, Date> {
private String pattern;
public DateConverter(String pattern) {
super();
this.pattern = pattern;
}
@Override
public Date convert(String s) {
SimpleDateFormat sdf = new SimpleDateFormat(pattern);
Date date = null;
try {
date = sdf.parse(s);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
}
@DateTimeFormat
将字符串转换成日期最方便的方法其实是在参数上添加注解@DateTimeFormat。
还可以为其指定pattern,如下。
@PostMapping("date")
public String getDate(@DateTimeFormat(pattern = "yyyy-MM-dd") Date date) {
return date.toString();
}
比较
- PropertyEditor:局部使用,通常搭配WebDataBinder来使用
- Formatter:全局/局部使用(xml中通过Spring注入的方式实现了全局使用),只能将String类型转换成其它类型
- Converter:全局/局部使用(xml中通过Spring注入的方式实现了全局使用),可以将任意类型的转换成其他类型的。
- @DateTimeFormat,局部使用,用在Controller的方法参数上
RESTful扩展
REST(Representational State Transfer)即表现层状态转移,如果一个架构符合REST原则,就称为RESTful架构。
资源(resource)包括文本、图片、音频、服务等,资源呈现的形式称为表现层。
- 文本:txt、xml、json、html、binary
- 图片:png、jpg
资源表现的形式通过http协议的content-type和accept来指定。
http://www.bookstore/book
这个网址只代表资源本身,资源的展现形式是通过content-type和accept来描述的
Accept代表发送端(客户端)希望接受的数据类型。
比如:Accept:text/xml; 代表客户端希望接受的数据类型是xml类型Content-Type代表发送端(客户端|服务器)发送的实体数据的数据类型。Content-Type:text/html; 代表发送端发送的数据格式是html。
写一个方法查看请求的content-type。
// 方法1
@GetMapping("/contentType")
public String contentType(HttpServletRequest request) {
return request.getContentType();
}
// 方法2
@GetMapping("/contentType")
public String contentType() {
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = servletRequestAttributes.getRequest();
return request.getContentType();
}
在SpringMVC中,获取HttpServletRequest和HttpServletResponse有两种方法。
- 在方法的入参中直接定义,如上方法1
- 使用
RequestContextHolder.getRequestAttributes()
,然后调用其getRequest()和getResponse()就能获得HttpServletRequest和HttpServletResponse
HTTP请求方法中,最常用的是POST、DELETE、PUT、GET,可以认为分别对应了对资源的增删改查。再次之前西安介绍下HTTP中的幂等性:
幂等性:每次HTTP请求相同的参数,相同的URI,产生的结果是相同的。
- GET-获取资源。如http://www.bookstore/book/123,表示查询编号为123的书籍
- POST-创建资源,不具有幂等性。http://www.bookstore/book,表示新增一本书籍,请求多少次就会新增多少条记录,会响应不同的URI,即产生了不同的结果,所以认为POST不具有幂等性
- PUT-创建(更新)资源,具有幂等性。http://www.bookstore/book/123,创建或者更新编号为123的书籍,如果编号123书籍已经存在那么语义是更新;如果不存在就是新增。但是多次请求的结果都是对一个资源的更新,即多次请求后结果不变,只会响应有一个URI,所以认为PUT是幂等的
- DELETE-删除资源。http://www.bookstore/book/123,删除编号为123的书籍
以上4个请求方法中,POST不是幂等的,GET、DELETE、PUT是幂等的。
用RESTful风格写Controller,以上面的book为例
@GetMapping("/bookstore/book/{id}")
public String getBook(@PathVariable("id") Integer id) {
return "Query book " + id;
}
@PostMapping("/bookstore/book")
public String addBook(@RequestParam("id") Integer id) {
return "Add book " + id;
}
@PutMapping("/bookstore/book/{id}")
public String updateBook(@PathVariable("id") Integer id) {
return "Update book " + id;
}
@DeleteMapping("/bookstore/book{id}")
public String deleteBook(@PathVariable("id") Integer id) {
return "Delete book " + id;
}
注:@PathVariable可以将URL路径中{...}
的内容绑定到参数上。
RESTful总结:
- 每一个URI代表一个资源。
- 客户端和服务端之间,传递该资源的一种表现形式。
- 客户端通过不同的HTTP请求方法,对服务端的资源进行操作(增删改查等),实现表现层状态转移。
by @sunhaiyu
2018.10.8
网友评论