聊聊Spring中的數據綁定 --- WebDataBinder、ServletRequestDataBinder、WebBindingInitializer 文章源於Ai生成

每篇一句

大魔王張怡寧:女兒,這堆金牌你拿去玩吧,但我的銀牌不能給你玩。你要想玩銀牌就去找你王浩叔叔吧,他那銀牌多

前言

爲了講述好Spring MVC最爲複雜的數據綁定這塊,我前面可謂是做足了功課,對此部分知識此處給小夥伴留一個學習入口,有興趣可以點開看看:聊聊Spring中的數據綁定 --- WebDataBinder、ServletRequestDataBinder、WebBindingInitializer...【享學Spring】

@InitBinder這個註解是Spring 2.5後推出來,用於數據綁定、設置數據轉換器等,字面意思是“初始化綁定器”。

關於數據綁定器的概念,前面的功課中有重點詳細講解,此處默認小夥伴是熟悉了的~

Spring MVC的web項目中,相信小夥伴們經常會遇到一些前端給後端傳值比較棘手的問題:比如最經典的問題:

  • Date類型(或者LocalDate類型)前端如何傳?後端可以用Date類型接收嗎?
  • 字符串類型,如何保證前段傳入的值兩端沒有空格呢?(99.99%的情況下多餘的空格都是木有用的)

對於這些看似不太好弄的問題,看了這篇文章你就可以優雅的搞定了~



說明:關於Date類型的傳遞,業界也有兩個通用的解決方案

  1. 使用時間戳
  2. 使用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來實現:

  1. 請求進來的所有字符串trim一下
  2. 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) : "");
	}
}

定義好後,如何使用呢?有兩種方式:

  1. API方式WebBindingInitializer ,關於它的使用,請參閱這裏,本文略。
    1. 重寫initBinder註冊的屬性編輯器是全局的屬性編輯器,對所有的Controller都有效(全局的)
  2. @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、createAttributerequest.getParameter(attributeName)看請求域裏是否有值(此處爲null),若木有就反射創建一個空實例,回到resolveArgument方法。
3、繼續利用WebDataBinder來完成對這個空對象的數據值綁定,這個時候這些FieldDefaultPrefix就起作用了。執行方法是:bindRequestParameters(binder, webRequest),實際上是((WebRequestDataBinder) binder).bind(request);。對於bind方法的原理,就不陌生了~
4、完成Model數據的封裝後,再進行@Valid校驗...

參考解析類:ModelAttributeMethodProcessor對參數部分的處理

總結

本文花大篇幅從原理層面總結了@InitBinder這個註解的使用,雖然此註解在當下的環境中出鏡率並不是太高,但我還是期望小夥伴能理解它,特別是我本文舉例說明的例子的場景一定能做到運用自如。

最後,此註解的使用的注意事項我把它總結如下,供各位使用過程中參考:

  1. @InitBinder標註的方法執行是多次的,一次請求來就執行一次(第一次懲罰)
  2. Controller實例中的所有@InitBinder只對當前所在的Controller有效
  3. @InitBinder的value屬性控制的是模型Model裏的key,而不是方法名(不寫代表對所有的生效)
  4. @InitBinder標註的方法不能有返回值(只能是void或者returnValue=null
  5. @InitBinder@RequestBody這種基於消息轉換器的請求參數無效
    1. 因爲@InitBinder它用於初始化DataBinder數據綁定、類型轉換等功能,而@RequestBody它的數據解析、轉換時消息轉換器來完成的,所以即使你自定義了屬性編輯器,對它是不生效的(它的WebDataBinder只用於數據校驗,不用於數據綁定和數據轉換。它的數據綁定轉換若是json,一般都是交給了jackson來完成的
  6. 只有AbstractNamedValueMethodArgumentResolver纔會調用binder.convertIfNecessary進行數據轉換,從而屬性編輯器纔會生效
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章