一個可以沉迷於技術的程式猿,wx加入加入技術群:fsx641385712 ...
每篇一句
大魔王張怡寧:女兒,這堆金牌你拿去玩吧,但我的銀牌不能給你玩。你要想玩銀牌就去找你王浩叔叔吧,他那銀牌多
前言
為了講述好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,手動邀請你入群一起飛 ==