【WEB系列】從0到1實現自定義web引數對映器

語言: CN / TW / HK

SpringBoot系列之從0到1實現自定義web引數對映器

在使用SpringMVC進行開發時,接收請求引數屬於基本功,當我們希望將傳參與專案中的物件關聯起來時,最常見的做法是預設的case(即傳參name與我們定義的name保持一致),當存在不一致,需要手動指定時,通常是藉助註解 @RequestParam 來實現,但是不知道各位小夥伴是否有發現,它的使用是有缺陷的

  • @RequestParam 不支援配置在類的屬性上

如果我們定義一個VO物件來接收傳承,這個註解用不了,如當我們定義一個Java bean(pojo)來接收引數時,若是get請求,post表單請求時,這個時候要求傳參name與pojo的屬性名完全匹配,如果我們有別名的需求場景,怎麼整?

最簡單的如傳參為: user_id=110&user_name=一灰灰

而接收引數的POJO為

public class ViewDo {
  private String uesrId;
  private String userName;
}

接下來本文通過從0到1,手擼一個自定義的web傳參對映,帶你瞭解SpringMVC中的引數繫結知識點

I. 專案搭建

1. 專案依賴

本專案藉助 SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA 進行開發

開一個web服務用於測試

<dependencies>
    <!-- 郵件傳送的核心依賴 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

配置檔案application.yml

server:
  port: 8080

II. 別名對映

接下來我們的目的就是希望實現一個自定義的別名註解,來支援傳參的別名繫結,核心知識點就是自定義的引數解析器 HandlerMethodArgumentResolver

0. 知識點概要說明

在下面的實現之前,先簡單介紹一下我們要用到的知識點

引數處理類: HandlerMethodArgumentResolver ,兩個核心的介面方法

// 用於判斷當前這個是否可以用來處理當前的傳參
boolean supportsParameter(MethodParameter parameter);


// 實現具體的引數對映功能,從請求引數中獲取對應的傳參,然後設定給目標物件
@Nullable
Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;

所以我們的核心邏輯就是實現上面這個介面,然後實現上面的兩個方法即可;當然直接實現原始的介面,額外需要處理的內容就稍稍有點多了,我們這裡會用到SpringMVC本身提供的兩個實現類,進行能力的擴充套件

  • ServletModelAttributeMethodProcessor:用於後續處理POJO類的屬性註解
  • RequestParamMethodArgumentResolver:用於後續處理方法引數註解

1. 自定義註解

自定義的註解,支援掛在類成員上,也支援放在方法引數上

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ParamName {
    /**
     * new name
     */
    String value();
}

2. 自定義引數處理器

接下來我們就是需要定義上面註解的解析器,鑑於方法引數註解與類的成員註解的處理邏輯的差異性(後面說為啥要區分開)

首先來看一下當方法引數上,有上面註解時,對應的解析類

public class ParamArgumentProcessor extends RequestParamMethodArgumentResolver {
    public ParamArgumentProcessor() {
        super(true);
    }

    // 當引數上擁有 ParanName 註解,且引數型別為基礎型別時,匹配
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(ParamName.class) && BeanUtils.isSimpleProperty(parameter.getParameterType());
    }


    // 根據自定義的對映name,從傳參中獲取對應的value
    @Override
    protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
        ParamName paramName = parameter.getParameterAnnotation(ParamName.class);
        String ans = request.getParameter(paramName.value());
        if (ans == null) {
            return request.getParameter(name);
        }
        return ans;
    }
}

上面的實現比較簡單,判斷是否可以使用當前Resolver的方法實現

parameter.hasParameterAnnotation(ParamName.class) && BeanUtils.isSimpleProperty(parameter.getParameterType());

其次,另外一個實現方法 resolveName 就很直觀了,根據繫結的name獲取具體的傳參

  • 注意:內部還做了一個相容,當繫結的傳參name找不到時,使用變數名來取傳參
  • 舉例說明:
    • 引數定義如 @ParamName("user_name") String userName
    • 那麼上面這個傳參值,會從傳參列表中,取 user_name 對應的值,當 user_name 不存在時,則取 userName 對應的值

接下來則是針對引數為POJO的場景,此時我們的自定義引數解析器實現類 ServletModelAttributeMethodProcessor ,具體的實現邏輯如下

public class ParamAttrProcessor extends ServletModelAttributeMethodProcessor {
    private final Map<Class<?>, Map<String, String>> replaceMap = new ConcurrentHashMap<>();

    public ParamAttrProcessor() {
        super(true);
    }

    // 要求引數為非基本型別,且引數的成員上存在@ParamName註解
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        if (!BeanUtils.isSimpleProperty(parameter.getParameterType())) {
            for (Field field : parameter.getParameterType().getDeclaredFields()) {
                if (field.getDeclaredAnnotation(ParamName.class) != null) {
                    return true;
                }
            }
        }
        return false;
    }

    // 主要是使用自定義的DataBinder,給傳參增加一些別名對映
    @Override
    protected void bindRequestParameters(WebDataBinder binder, NativeWebRequest nativeWebRequest) {
        Object target = binder.getTarget();
        Class<?> targetClass = target.getClass();
        if (!replaceMap.containsKey(targetClass)) {
            Map<String, String> mapping = analyzeClass(targetClass);
            replaceMap.put(targetClass, mapping);
        }
        Map<String, String> mapping = replaceMap.get(targetClass);
        ParamDataBinder paramNameDataBinder = new ParamDataBinder(target, binder.getObjectName(), mapping);
        super.bindRequestParameters(paramNameDataBinder, nativeWebRequest);
    }


    // 避免每次都去解析targetClass對應的別名定義,在實現中新增一個快取
    private static Map<String, String> analyzeClass(Class<?> targetClass) {
        Field[] fields = targetClass.getDeclaredFields();
        Map<String, String> renameMap = new HashMap<>();
        for (Field field : fields) {
            ParamName paramNameAnnotation = field.getAnnotation(ParamName.class);
            if (paramNameAnnotation != null && !paramNameAnnotation.value().isEmpty()) {
                renameMap.put(paramNameAnnotation.value(), field.getName());
            }
        }
        if (renameMap.isEmpty()) return Collections.emptyMap();
        return renameMap;
    }
}

雖然上面的實現相比較於第一個,程式碼量要長很多,但是邏輯其實也並不複雜

supportsParameter判斷是否可用

@ParamName

bindRequestParameters請求引數繫結

  • 這個方法的核心訴求就是給傳參中的key=value,新增一個別名
  • 舉例說明:
    user_name = 一灰灰
    @ParamName("user_name") String userName;
    user_name = userName
    

下面就是DataBinder的實現邏輯

public class ParamDataBinder extends ExtendedServletRequestDataBinder {
    private final Map<String, String> renameMapping;
    public ParamDataBinder(Object target, String objectName, Map<String, String> renameMapping) {
        super(target, objectName);
        this.renameMapping = renameMapping;
    }

    @Override
    protected void addBindValues(MutablePropertyValues mpvs, ServletRequest request) {
        super.addBindValues(mpvs, request);
        for (Map.Entry<String, String> entry : renameMapping.entrySet()) {
            String from = entry.getKey();
            String to = entry.getValue();
            if (mpvs.contains(from)) {
                mpvs.add(to, mpvs.getPropertyValue(from).getValue());
            }
        }
    }
}

3. 註冊與測試

最終也是非常重要的一點就是需要註冊我們的自定義引數解析器,實現 WebMvcConfigurationSupport ,過載 addArgumentResolvers 方法即可

@SpringBootApplication
public class Application  extends WebMvcConfigurationSupport {
    @Override
    protected void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(new ParamAttrProcessor());
        argumentResolvers.add(new ParamArgumentProcessor());
    }
}

最後給一個基本的測試

@RestController
public class RestDemo {

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class ViewDo {
        @ParamName("user_id")
        private Integer userId;
        @ParamName("user_name")
        private String userName;
    }
    
    /**
     * POJO 對應Spring中的引數轉換是 ServletModelAttributeMethodProcessor | RequestParamMethodArgumentResolver
     *
     * @param viewDo
     * @return
     */
    @GetMapping(path = "getV5")
    public ViewDo getV5(ViewDo viewDo) {
        System.out.println("v5: " + viewDo);
        return viewDo;
    }

    /**
     * curl 'http://127.0.0.1:8080/postV1' -X POST -d 'user_id=123&user_name=一灰灰'
     * 注意:非json傳參,jackson的配置將不會生效,即上面這個請求是不會實現下劃線轉駝峰的; 但是返回結果會是下劃線的
     *
     * @param viewDo
     * @return
     */
    @PostMapping(path = "postV1")
    public ViewDo post(ViewDo viewDo) {
        System.out.println(viewDo);
        return viewDo;
    }
    
    @GetMapping(path = "ano")
    public ViewDo ano(@ParamName("user_name") String userName, @ParamName("user_id") Integer userId) {
        ViewDo viewDo = new ViewDo(userId, userName);
        System.out.println(viewDo);
        return viewDo;
    }
}

上面提供了三個介面

@ParamName
@ParamName

實測結果如下

4. 小結

本文主要通過實現自定義的引數對映解析器,來支援自定義的引數別名繫結,雖然內容不多,但其基本實現,則主要利用的是SpringMVC的引數解析這一塊知識點,當然本文作為應用篇,主要只是介紹瞭如何實現自定義的 HandlerMethodArgumentResolver ,當現有的引數解析滿足不了我們的訴求時,完全可以仿造上面的實現來實現自己的應用場景(相信也不會太難)

最後抽取一下本文中使用到的知識點

  • 如何判斷一個類是否為基本物件: org.springframework.beans.BeanUtils#isSimpleProperty
  • 自定義引數解析器:實現介面 HandlerMethodArgumentResolver
    • 方法1:supportsParameter,判斷當前這個解析器是否適用
    • 方法2:resolveArgument,具體的引數解析實現邏輯
  • RequestParamMethodArgumentResolver:預設的方法引數解析器,主要用於簡單引數型別的對映,內部封裝了型別適配相關邏輯
  • ServletModelAttributeMethodProcessor:用於預設的POJO/ModelAttribute引數解析

III. 不能錯過的原始碼和相關知識點

0. 專案

系列博文:

【WEB系列】如何支援下劃線駝峰互轉的傳參與返回

1. 微信公眾號: 一灰灰Blog

盡信書則不如,以上內容,純屬一家之言,因個人能力有限,難免有疏漏和錯誤之處,如發現bug或者有更好的建議,歡迎批評指正,不吝感激

下面一灰灰的個人部落格,記錄所有學習和工作中的博文,歡迎大家前去逛逛

打賞 如果覺得我的文章對您有幫助,請隨意打賞。