共计 11952 个字符,预计需要花费 30 分钟才能阅读完成。
每篇一句
大魔王张怡宁:女儿,这堆金牌你拿去玩吧,但我的银牌不能给你玩。你要想玩银牌就去找你王浩叔叔吧,他那银牌多
前言
为了讲述好 Spring MVC
最为复杂的数据绑定这块,我前面可谓是做足了功课,对此部分知识此处给小伙伴留一个学习入口,有兴趣可以点开看看:聊聊 Spring 中的数据绑定 — WebDataBinder、ServletRequestDataBinder、WebBindingInitializer…【享学 Spring】
@InitBinder
这个注解是 Spring 2.5
后推出来,用于数据绑定、设置数据转换器等,字面意思是“初始化绑定器”。
关于数据绑定器的概念,前面的功课中有重点详细讲解,此处默认小伙伴是熟悉了的~
在 Spring MVC
的 web 项目中,相信小伙伴们经常会遇到一些前端给后端传值比较棘手的问题:比如最经典的问题:
-
Date
类型(或者 LocalDate 类型
)前端如何传?后端可以用Date
类型接收吗? - 字符串类型,如何保证前段传入的值两端没有空格呢?(99.99% 的情况下多余的空格都是木有用的)
对于这些看似不太好弄的问题,看了这篇文章你就可以 优雅的 搞定了~
说明:关于 Date
类型的传递,业界也有两个 通用的解决方案:
- 使用时间戳
- 使用
String
字符串(传值的万能方案)
使用者两种方式总感觉不优雅,且不够面向对象。那么本文就介绍一个黑科技:使用 @InitBinder
来便捷的实现 各种数据类型 的数据绑定(咱们 Java 是强类型语言且面向对象的,如果啥都用字符串,是不是也太 low 了~)
一般的 string, int, long 会自动绑定到参数,但是自定义的格式 spring 就不知道如何绑定了 . 所以要继承
PropertyEditorSupport
,实现自己的属性编辑器PropertyEditor
, 绑定到WebDataBinder (binder.registerCustomEditor)
,覆盖方法setAsText
@InitBinder
原理
本文先原理,再案例的方式,让你能够彻头彻尾的掌握到该注解的使用。
1、@InitBinder
是什么时候生效的?
这就是前面文章埋下的伏笔:Spring
在绑定请求参数到 HandlerMethod
的时候(此处以 RequestParamMethodArgumentResolver
为例),会借助 WebDataBinder
进行数据转换:
// RequestParamMethodArgumentResolver 的父类就是它,resolveArgument 方法在父类上
// 子类仅仅只需要实现抽象方法 resolveName,即:从 request 里根据 name 拿值
AbstractNamedValueMethodArgumentResolver:@Override
@Nullable
public final Object resolveArgument(...) {
...
Object arg = resolveName(resolvedName.toString(), nestedParameter, webRequest);
...
if (binderFactory != null) {
// 创建出一个 WebDataBinder
WebDataBinder binder = binderFactory.createBinder(webRequest, null, namedValueInfo.name);
// 完成数据转换(比如 String 转 Date、String 转... 等等)arg = binder.convertIfNecessary(arg, parameter.getParameterType(), parameter);
...
}
...
return arg;
}
它从请求 request 拿值得方法便是:request.getParameterValues(name)
。
2、web 环境使用的数据绑定工厂是:ServletRequestDataBinderFactory
虽然在前面功课中有讲到,但此处为了连贯性还是有必要再简单过一遍:
// @since 3.1 org.springframework.web.bind.support.DefaultDataBinderFactory
public class DefaultDataBinderFactory implements WebDataBinderFactory {
@Override
@SuppressWarnings("deprecation")
public final WebDataBinder createBinder(NativeWebRequest webRequest, @Nullable Object target, String objectName) throws Exception {WebDataBinder dataBinder = createBinderInstance(target, objectName, webRequest);
// WebBindingInitializer initializer 在此处解析完成了 全局生效
if (this.initializer != null) {this.initializer.initBinder(dataBinder, webRequest);
}
// 解析 @InitBinder 注解,它是个 protected 空方法,交给子类复写实现
// InitBinderDataBinderFactory 对它有复写
initBinder(dataBinder, webRequest);
return dataBinder;
}
}
public class InitBinderDataBinderFactory extends DefaultDataBinderFactory {
// 保存所有的,private final List<InvocableHandlerMethod> binderMethods;
...
@Override
public void initBinder(WebDataBinder dataBinder, NativeWebRequest request) throws Exception {for (InvocableHandlerMethod binderMethod : this.binderMethods) {if (isBinderMethodApplicable(binderMethod, dataBinder)) {
// invokeForRequest 这个方法不用多说了,和调用普通控制器方法一样
// 方法入参上也可以写格式各样的参数~~~~
Object returnValue = binderMethod.invokeForRequest(request, null, dataBinder);
// 标注有 @InitBinder 注解方法必须返回 void
if (returnValue != null) {throw new IllegalStateException("@InitBinder methods must not return a value (should be void):" + binderMethod);
}
}
}
}
// dataBinder.getObjectName()在此处终于起效果了 通过这个名称来匹配
// 也就是说可以做到让 @InitBinder 注解只作用在指定的入参名字的数据绑定上~~~~~
// 而 dataBinder 的这个 ObjectName,一般就是入参的名字(注解指定的 value 值~~)// 形参名字的在 dataBinder,所以此处有个简单的过滤~~~~~~~
protected boolean isBinderMethodApplicable(HandlerMethod initBinderMethod, WebDataBinder dataBinder) {InitBinder ann = initBinderMethod.getMethodAnnotation(InitBinder.class);
Assert.state(ann != null, "No InitBinder annotation");
String[] names = ann.value();
return (ObjectUtils.isEmpty(names) || ObjectUtils.containsElement(names, dataBinder.getObjectName()));
}
}
WebBindingInitializer
接口方式是优先于@InitBinder
注解方式执行的(API 方式是去全局的,注解方式可不一定,所以更加的灵活些)
子类 ServletRequestDataBinderFactory
就做了一件事:new ExtendedServletRequestDataBinder(target, objectName)
ExtendedServletRequestDataBinder
只做了一件事:处理 path
变量。
binderMethods
是通过构造函数进来的,它表示和本次请求有关的所有的标注有 @InitBinder
的方法,所以需要了解它的实例是如何被创建的,那就是接下来这步。
3、ServletRequestDataBinderFactory
的创建
任何一个请求进来,最终交给了 HandlerAdapter.handle()
方法去处理,它的创建流程如下:
public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter implements BeanFactoryAware, InitializingBean {
...
@Override
protected ModelAndView handleInternal(HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
...
// 处理请求,最终其实就是执行控制器的方法,得到一个 ModelAndView
mav = invokeHandlerMethod(request, response, handlerMethod);
...
}
// 执行控制器的方法,挺复杂的。但本文我只关心 WebDataBinderFactory 的创建,方法第一句便是
@Nullable
protected ModelAndView invokeHandlerMethod(HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {WebDataBinderFactory binderFactory = getDataBinderFactory(handlerMethod);
...
}
// 创建一个 WebDataBinderFactory
// Global methods first(放在前面最先执行)然后再执行本类自己的
private WebDataBinderFactory getDataBinderFactory(HandlerMethod handlerMethod) throws Exception {
// handlerType:方法所在的类(控制器方法所在的类,也就是 xxxController)// 由此可见,此注解的作用范围是类级别的。会用此作为 key 来缓存
Class<?> handlerType = handlerMethod.getBeanType();
Set<Method> methods = this.initBinderCache.get(handlerType);
if (methods == null) { // 缓存没命中,就去 selectMethods 找到所有标注有 @InitBinder 的方法们~~~~
methods = MethodIntrospector.selectMethods(handlerType, INIT_BINDER_METHODS);
this.initBinderCache.put(handlerType, methods); // 缓存起来
}
// 此处注意:Method 最终都被包装成了 InvocableHandlerMethod,从而具有执行的能力
List<InvocableHandlerMethod> initBinderMethods = new ArrayList<>();
// 上面找了本类的,现在开始看看全局里有木有 @InitBinder
// Global methods first(先把全局的放进去,再放个性化的~~~~ 所以小细节:有覆盖的效果哟~~~)// initBinderAdviceCache 它是一个缓存 LinkedHashMap(有序哦~~~),缓存着作用于全局的类。// 如 @ControllerAdvice,注意和 `RequestBodyAdvice`、`ResponseBodyAdvice` 区分开来
// methodSet:说明一个类里面是可以定义 N 多个标注有 @InitBinder 的方法~~~~~
this.initBinderAdviceCache.forEach((clazz, methodSet) -> {
// 简单的说就是 `RestControllerAdvice` 它可以指定:basePackages 之类的属性,看本类是否能被扫描到吧~~~~
if (clazz.isApplicableToBeanType(handlerType)) {// 这个 resolveBean() 有点意思:它持有的 Bean 若是个 BeanName 的话,会 getBean()一下的
// 大多数情况下都是 BeanName,这在 @ControllerAdvice 的初始化时会讲~~~
Object bean = clazz.resolveBean();
for (Method method : methodSet) {
// createInitBinderMethod:把 Method 适配为可执行的 InvocableHandlerMethod
// 特点是把本类的 HandlerMethodArgumentResolverComposite 传进去了
// 当然还有 DataBinderFactory 和 ParameterNameDiscoverer 等
initBinderMethods.add(createInitBinderMethod(bean, method));
}
}
});
// 后一步:再条件标注有 @InitBinder 的方法
for (Method method : methods) {Object bean = handlerMethod.getBean();
initBinderMethods.add(createInitBinderMethod(bean, method));
}
// protected 方法,就一句代码:new ServletRequestDataBinderFactory(binderMethods, getWebBindingInitializer())
return createDataBinderFactory(initBinderMethods);
}
...
}
到这里,整个 @InitBinder
的解析过程就算可以全部理解了。关于这个过程,我有如下几点想说:
- 对于
binderMethods
每次请求过来都会新 new 一个(具有第一次惩罚效果),它既可以来自于全局(Advice),也可以来自于Controller
本类 - 倘若
Controller
上的和Advice
上标注有次注解的方法名一毛一样,也是不会覆盖的(因为类不一样) - 关于注解有
@InitBinder
的方法的执行,它和执行控制器方法差不多,都是调用了InvocableHandlerMethod#invokeForRequest
方法,因此可以自行类比
目前方法执行的核心,无非就是对参数的解析、封装,也就是对
HandlerMethodArgumentResolver
的理解。强烈推荐你可以参考 这个系列的所有文章~
有了这些基础理论的支撑,接下来当然就是它的使用 Demo Show
了
@InitBinder
的使用案例
我抛出两个需求,借助 @InitBinder
来实现:
- 请求进来的所有 字符串 都
trim
一下 -
yyyy-MM-dd
这种格式的字符串能直接用Date
类型接收(不用先用String
接收再自己转换,不优雅)
为了实现如上两个需求,我需要先自定义两个属性编辑器:
1、StringTrimmerEditor
public class StringTrimmerEditor extends PropertyEditorSupport {
// 将属性对象用一个字符串表示,以便外部的属性编辑器能以可视化的方式显示。缺省返回 null,表示该属性不能以字符串表示
//@Override
//public String getAsText() {// Object value = getValue();
// return (value != null ? value.toString() : null);
//}
// 用一个字符串去更新属性的内部值,这个字符串一般从外部属性编辑器传入
// 处理请求的入参:test 就是你传进来的值(并不是 super.getValue()哦~)@Override
public void setAsText(String text) throws IllegalArgumentException {text = text == null ? text : text.trim();
setValue(text);
}
}
说明:Spring 内置有
org.springframework.beans.propertyeditors.StringTrimmerEditor
,默认情况下它并没有装配进来,若你有需要可以直接使用它的(此处为了演示,我就用自己的)。Spring 内置注册了哪些?参照PropertyEditorRegistrySupport#createDefaultEditors
方法
Spring 的属性编辑器和传统的用于 IDE 开发时的属性编辑器不同,它们没有 UI 界面, 仅负责将配置文件中的文本配置值转换为 Bean 属性的对应值,所以 Spring 的属性编辑器并非传统意义上的 JavaBean 属性编辑器。
2、CustomDateEditor
关于这个属性编辑器,你也可以像我一样自己实现。本文就直接使用 Spring 提供了的,参见:org.springframework.beans.propertyeditors.CustomDateEditor
// @since 28.04.2003
// @see java.util.Date
public class CustomDateEditor extends PropertyEditorSupport {
...
@Override
public void setAsText(@Nullable String text) throws IllegalArgumentException {
...
setValue(this.dateFormat.parse(text));
...
}
...
@Override
public String getAsText() {Date value = (Date) getValue();
return (value != null ? this.dateFormat.format(value) : "");
}
}
定义好后,如何使用呢?有两种方式:
-
API 方式
WebBindingInitializer
,关于它的使用,请参阅这里,本文略。1. 重写 `initBinder` 注册的属性编辑器是全局的属性编辑器,对 ** 所有的 `Controller` 都有效 **(全局的)
@InitBinder
注解方式
在 Controller
本类上使用@InitBinder
,形如这样:
@Controller
@RequestMapping
public class HelloController {
@InitBinder
public void initBinder(WebDataBinder binder) {//binder.setDisallowedFields("name"); // 不绑定 name 属性
binder.registerCustomEditor(String.class, new StringTrimmerEditor());
// 此处使用 Spring 内置的 CustomDateEditor
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, true));
}
@ResponseBody
@GetMapping("/test/initbinder")
public String testInitBinder(String param, Date date) {return param + ":" + date;}
}
请求:/test/initbinder?param= ds&date=2019-12-12
。结果为:ds:Thu Dec 12 00: 00: 00 CST 2019
,符合预期。
注意,若 date 为 null 返回值为
ds: null
(因为我设置了允许为 null)
但若你不是yyyy-MM-dd
格式,那就抛错喽(格式化异常)
本例的 @InitBinder
方法只对当前 Controller
生效。要想全局生效,可以使用 @ControllerAdvice/WebBindingInitializer
。
通过 @ControllerAdvice
可以将对于控制器的 全局配置放置在同一个位置 ,注解了@ControllerAdvice
的类的方法可以使用 @ExceptionHandler
,@InitBinder
,@ModelAttribute
等注解到方法上,这对所有注解了 @RequestMapping
的控制器内的方法有效(关于全局的方式本文略,建议各位自己实践~)。
@InitBinder 的 value 属性的作用
获取你可能还不知道,它还有个 value
属性呢,并且还是数组
public @interface InitBinder {
// 用于限定次注解标注的方法作用于哪个模型 key 上
String[] value() default {};}
说人话:若指定了 value 值,那么只有方法参数名(或者模型名)匹配上了此注解方法才会执行(若不指定,都执行)。
@Controller
@RequestMapping
public class HelloController {@InitBinder({"param", "user"})
public void initBinder(WebDataBinder binder, HttpServletRequest request) {System.out.println("当前 key:" + binder.getObjectName());
}
@ResponseBody
@GetMapping("/test/initbinder")
public String testInitBinder(String param, String date,
@ModelAttribute("user") User user, @ModelAttribute("person") Person person) {return param + ":" + date;}
}
请求:/test/initbinder?param=fsx&date=2019&user.name=demoUser
,控制台打印:
当前 key:param
当前 key:user
从打印结果中很清楚的看出了 value
属性的作用~
需要说明一点:虽然此处有 key 是
user.name
,但是 User 对象可是不会封装到此值的(因为request.getParameter('user')
没这个 key 嘛~)。如何解决???需要绑定前缀,原理可参考这里
其它应用场景
上面例举的场景是此注解最为常用的场景,大家务必掌握。它还有一些奇淫技巧的使用,心有余力的小伙伴不妨也可以消化消化:
若你一次提交需要提交两个 ” 模型 ” 数据,并且它们有重名的属性。形如下面例子:
@Controller
@RequestMapping
public class HelloController {
@Getter
@Setter
@ToString
public static class User {
private String id;
private String name;
}
@Getter
@Setter
@ToString
public static class Addr {
private String id;
private String name;
}
@InitBinder("user")
public void initBinderUser(WebDataBinder binder) {binder.setFieldDefaultPrefix("user.");
}
@InitBinder("addr")
public void initBinderAddr(WebDataBinder binder) {binder.setFieldDefaultPrefix("addr.");
}
@ResponseBody
@GetMapping("/test/initbinder")
public String testInitBinder(@ModelAttribute("user") User user, @ModelAttribute("addr") Addr addr) {return user + ":" + addr;}
}
请求:/test/initbinder?user.id=1&user.name=demoUser&addr.id=10&addr.name= 北京市海淀区
,结果为:HelloController.User(id=1, name=demoUser):HelloController.Addr(id=10, name= 北京市海淀区)
至于加了前缀为何能绑定上,这里简要说说:
1、ModelAttributeMethodProcessor#resolveArgument
里依赖 attribute = createAttribute(name, parameter, binderFactory, webRequest)
方法完成数据的封装、转换
2、createAttribute
先request.getParameter(attributeName)
看请求域里是否有值(此处为 null),若木有就 反射创建一个空实例 ,回到resolveArgument
方法。
3、继续利用 WebDataBinder
来完成对这个空对象的数据值绑定,这个时候这些 FieldDefaultPrefix
就起作用了。执行方法是:bindRequestParameters(binder, webRequest)
,实际上是 ((WebRequestDataBinder) binder).bind(request);
。对于 bind 方法的原理,就不陌生了~
4、完成 Model 数据的封装后,再进行@Valid
校验 …
参考解析类:
ModelAttributeMethodProcessor
对参数部分的处理
总结
本文花大篇幅从原理层面总结了 @InitBinder
这个注解的使用,虽然此注解在当下的环境中出镜率并不是太高,但我还是期望小伙伴能理解它,特别是我本文举例说明的例子的场景一定能做到运用自如。
最后,此注解的使用的注意事项我把它总结如下,供各位使用过程中参考:
-
@InitBinder
标注的方法执行是多次的,一次请求来就执行一次(第一次惩罚) -
Controller
实例中的所有@InitBinder
只对当前所在的Controller
有效 -
@InitBinder
的 value 属性控制的是模型 Model 里的 key,而不是方法名(不写代表对所有的生效) -
@InitBinder
标注的方法不能有返回值(只能是void
或者returnValue=null
) -
@InitBinder
对@RequestBody
这种基于消息转换器的请求参数无效1. 因为 `@InitBinder` 它用于初始化 `DataBinder` 数据绑定、类型转换等功能,而 `@RequestBody` 它的数据解析、转换时消息转换器来完成的,所以即使你自定义了属性编辑器,对它是不生效的(** 它的 `WebDataBinder` 只用于数据校验,不用于数据绑定和数据转换。它的数据绑定转换若是 json,一般都是交给了 `jackson` 来完成的 **)
- 只有
AbstractNamedValueMethodArgumentResolver
才会调用binder.convertIfNecessary
进行数据转换,从而属性编辑器才会生效
== 若对 Spring、SpringBoot、MyBatis 等源码分析感兴趣,可加我 wx:fsx641385712,手动邀请你入群一起飞 ==
== 若对 Spring、SpringBoot、MyBatis 等源码分析感兴趣,可加我 wx:fsx641385712,手动邀请你入群一起飞 ==