springboot中,可以很简单的通过@RequestBody注解获取到请求参数并映射为实体,但是仅限于请求方式为application/json,而@RequestParam注解则对实体映射不友好,在一些场景(尤其是对接了古老遗留系统)需要同时支持表单以及json的请求,我们可以实现自己的一个类似@RequstBody的参数解析器,可以同时支持表单或json到实体的转换。

要实现自己的参数解析器,得益于springboot的封装,只需三部操作:

  • 自定义注解

    自己的一个注解,用于替代@RequestBody,比如叫@AssginParams

  • 实现参数解析器

    只需要实现HandlerMethodArgumentResolver接口即可,对应用了我们自定义注解的参数进行处理,将请求参数取出来并转换为实体对象,同时支持进行校验。

  • 配置参数解析器

    将参数解析器添加到spring的解析器列表里。

需要用到的依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
    <groupId>commons-beanutils</groupId>
    <artifactId>commons-beanutils</artifactId>
    <version>1.9.4</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.76</version>
</dependency>

自定义注解

定义一个注解,替代@RequestBody,将注解声明为可作用于方法参数上。

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface AssignParams {
}

实现参数解析器

只需要实现HandlerMethodArgumentResolver接口即可,实现具体的解析逻辑,其中接口中要实现的解析方法参数如下:

public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer mContainer, NativeWebRequest webRequest, WebDataBinderFactory factory) {}

解析器会把此方法返回的值赋给方法参数,methodParameter即是要解析的参数类型对象,我们要做的也就是在此方法中将网络请求参数取出来,根据methodParameter利用反射生成实体对象返回。

解析时存在两种情况,一是请求类型是表单请求(form-datax-www-form-urlencoded),这时直接通过webRequest即可得到,先获取到所有请求参数名:

Iterator<String> parameterNames = webRequest.getParameterNames();

获取某一参数:

webRequest.getParameter(parameterName); 

迭代即可获取所有表单值,然后依据methodParameter的反射,实例化实体对象,并将相应的字段赋值即可。

二是请求类型是json,此时就不能通过webRequest直接获取了,需要通过webRequest获取到HttpServletRequest,主动去读:

HttpServletRequest servletRequest = ((ServletWebRequest) webRequest).getRequest();
String jsonStr = servletRequest.getReader().lines().reduce("", String::concat); // 得到json字符串

然后直接将jsonStr装换为实体对象即可。

最后判断参数是否同时应用了@Valid@Validate注解,如果有,则进行一下校验。

完整实现如下:

import java.lang.reflect.Field;
import java.util.Iterator;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;
import javax.validation.*;

import com.alibaba.fastjson.JSON;
import org.apache.commons.beanutils.ConvertUtils;
import org.hibernate.validator.HibernateValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;


/**
 * 自定义参数解析器,以便同时支持 json/x-www-form-urlencoded/form-data 到对象实体的的转换,同时进行校验
 */
@Component
public class AssignParamsResolver implements HandlerMethodArgumentResolver {
    private final Logger logger = LoggerFactory.getLogger(AssignParamsResolver.class);
    private final Validator validator = Validation
            .byProvider(HibernateValidator.class)
            .configure()
            .failFast(true)
            .buildValidatorFactory()
            .getValidator();

    /**
     * 判断是否是application/json方式的请求
     * @param request 请求对象
     * @return boolean
     */
    private boolean hasJsonBody(HttpServletRequest request) {
        String contentType = request.getContentType();
        return contentType != null && contentType.contains("application/json");
    }

    // 解析实现
    @Override
    public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer mContainer, NativeWebRequest webRequest, WebDataBinderFactory factory) throws Exception {
        Class<?> clazz = methodParameter.getParameterType(); // 获取到参数的class对象
        HttpServletRequest servletRequest = ((ServletWebRequest) webRequest).getRequest(); // 获取到HttpServletRequest对象
        Object instance = null;
        if (hasJsonBody(servletRequest)) {
            // 解析JSON
            String jsonStr = servletRequest.getReader().lines().reduce("", String::concat);
            try {
                if (jsonStr.length() > 0) {
                    instance = JSON.parseObject(jsonStr, clazz); // 直接通过fastjson转换
                }
            } catch (Exception e) {
                logger.warn(String.format("Parse body to object failed, body: %s ...", jsonStr));
                throw new HttpServerErrorException(HttpStatus.FORBIDDEN, "Body is invalid");
            }
        } else {
            // 解析x-www-form-urlencoded/form-data
            Iterator<String> parameterNames = webRequest.getParameterNames(); // 取出所有参数名
            instance = clazz.newInstance(); // 创建实体对象
            while (parameterNames.hasNext()) {
                String name = parameterNames.next(); // 请求参数名
                String value = webRequest.getParameter(name); // 请求参数值
                try {
                    Field field = clazz.getDeclaredField(name); // 取出实体对象中与请求参数名一致的字段
                    field.setAccessible(true);
                    // 将请求参数值赋值给实体对象对于的字段,这里用到了ConvertUtils.convert先做了装换,因为form表单中的值均是字符串类型存储的,而实体中的参数可能是int或boolean,因此先要把字符串类型的表单值转换为字段对应的类型再进行赋值
                    field.set(instance, ConvertUtils.convert(value, field.getType()));
                } catch (NoSuchFieldException e) {
                    // 实体中没有与参数名称一致的字段
                    logger.warn(String.format("resolver parameter %s failed, no such field in dto ...", name));
                }
            }
        }

        if (methodParameter.hasParameterAnnotation(Valid.class) || methodParameter.hasParameterAnnotation(Validated.class)) {
            // 如果应用了Valid或Validated注解,则进行校验抛出第一个错误
            Set<ConstraintViolation<Object>> constraintValidatorSet = validator.validate(instance);
            if (!constraintValidatorSet.isEmpty()) {
                ConstraintViolation<Object> violation = constraintValidatorSet.iterator().next();
                // 校验错误,直接抛出异常
                throw new HttpServerErrorException(HttpStatus.FORBIDDEN, violation.getMessage());
            }
        }
        return instance; // 返回值会被赋给参数
    }

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        // 解析的条件,只对用了 AssignParams 自定义注解的参数执行解析逻辑
        return parameter.hasParameterAnnotation(AssignParams.class);
    }
    
}

配置参数解析器

将参数解析器添加到spring的解析器列表里:

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfiguration implements WebMvcConfigurer {
    @Autowired private AssignParamsResolver paramsResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(paramsResolver); // 添加到解析器列表
    }
}

使用

至此参数解析器便实现完成,使用与@RequestBody一致,只需定义相应的实体对象并直接应用于controller的方法参数上即可,如下:

import lombok.Data;
import lombok.ToString;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Positive;

@Data
@ToString
public static class UserDto {
    @NotNull(message = "用户id不能为空")
    @Positive(message = "用户id必须为正整数")
    private int userId;
    
    @NotEmpty(message = "用户名不能为空")
    private String username;
}
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@RestController
@RequestMapping("user")
public class UserController {

    // 无论是json还是form表单,都能映射成实体对象
    @PostMapping()
    public String getUser(@Valid @AssignParams UserDto user) {
        // TODO
        return "ok ...";
    }
}