從原理層面掌握@InitBinder的使用【享學Spring MVC】
每篇一句
大魔王張怡寧:女兒,這堆金牌你拿去玩吧,但我的銀牌不能給你玩。你要想玩銀牌就去找你王浩叔叔吧,他那銀牌多
前言
為了講述好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,手動邀請你入群一起飛