1. 程式人生 > >從原理層面掌握@InitBinder的使用【享學Spring MVC】

從原理層面掌握@InitBinder的使用【享學Spring MVC】

每篇一句

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

前言

為了講述好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進行資料轉換,從而屬性編輯器才會生效

== 若對Spring、SpringBoot、MyBatis等原始碼分析感興趣,可加我wx:fsx641385712,手動邀請你入群一起飛 ==
== 若對Spring、SpringBoot、MyBatis等原始碼分析感興趣,可加我wx:fsx641385712,手動邀請你入群一起飛