8.从0到1实现自定义web参数映射器

一灰灰blogSpringBootWEB系列Request请求参数约 2412 字大约 8 分钟

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());
  • 注意上面的实现,两个要求,一是参数注解,二是要求为简单对象(非简单对象则交给下面的Resolver来处理)

其次,另外一个实现方法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 的kv存在
    • 然后在DataBinder中,给别名(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;
    }
}

上面提供了三个接口

  • ano:参数为基本类型,通过@ParamName定义别名
  • getV5: 参数为非简单类型ViewDo,类成员上通过@ParamName指定别名映射
  • post: 同上,唯一区别在于它是post请求

实测结果如下

4. 小结

本文主要通过实现自定义的参数映射解析器,来支持自定义的参数别名绑定,虽然内容不多,但其基本实现,则主要利用的是SpringMVC的参数解析这一块知识点,当然本文作为应用篇,主要只是介绍了如何实现自定义的HandlerMethodArgumentResolver,当现有的参数解析满足不了我们的诉求时,完全可以仿造上面的实现来实现自己的应用场景(相信也不会太难)

最后抽取一下本文中使用到的知识点

  • 如何判断一个类是否为基本对象:org.springframework.beans.BeanUtils#isSimpleProperty
  • 自定义参数解析器:实现接口 HandlerMethodArgumentResolver
    • 方法1:supportsParameter,判断当前这个解析器是否适用
    • 方法2:resolveArgument,具体的参数解析实现逻辑
  • RequestParamMethodArgumentResolver:默认的方法参数解析器,主要用于简单参数类型的映射,内部封装了类型适配相关逻辑
  • ServletModelAttributeMethodProcessor:用于默认的POJO/ModelAttribute参数解析

III. 不能错过的源码和相关知识点

0. 项目

系列博文:

【WEB系列】如何支持下划线驼峰互转的传参与返回open in new window

1. 微信公众号: 一灰灰Blog

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

一灰灰blog
一灰灰blog
Loading...