HandlerMethodArgumentResolver - 自定义参数解析器代替特定场景中的@RequestBody

1,170 阅读5分钟

写在前面

今天一个C++转java的大佬和我吐槽说项目中使用的@RequestBody为什么不能映射多个对象,每次前端有变动都要改接收对象,非常难顶。

一、关于@RequestBody的痛点

项目中遇到的问题是这样的。有一个附件管理模块,只要有一个ID和一个文件列表,我们就能将附件列表与该条记录绑定起来。从而实现,附件与其他的模块解耦。但是要求数据插入的时候,同时要进行绑定。

class BaseDto{
    String id;
    String context;
}

Class FileDto{
    String id;
    List<String> fileList;
}

@RequestMapping("/test")
public String test(@RequestBody BaseDto baseDto){
    ...
}

前端使用的是json对象传输,请求如下:

{
	"context":"12345678",
	"fileList":[ "1", "2", "3"]
}

这时候后端如果想获取BaseDto类里面没有的参数,我们可以通过继承,让子类可以接收更多的参数

class BaseParams extends BaseDto{
	List<String> fileList;
}

@RequestMapping("/upload")
public String upload(@RequestBody BaseParams baseParams){
	...
}

这种方法好,也不好。因为FileDto是一个容易变动的类,请求体总是会出现各种各样奇怪的参数。比如,文件类型权限列表、文件类型列表等等。再或许文件系统变动,原本的字符串列表,变成map列表。这样的话FileDto的变动又要改BaseParams

如果能直接用BaseDto和FileDto同时接收参数,那就不用去维护一个接收的参数的类了

@RequestMapping("/upload")
public String upload(@RequestBody BaseDto baseDto, @RequestBody FileDto fileDto){
	...
}

但这样子肯定是报错的,因为@RequestBody 只能在一个参数上使用

二、解决方法

1. 使用Map<String,Object>

遇事不决用Map,可能会麻烦点,但不会出错。拿到全部值再映射到对象上

@RequestMapping("/test")
public String test(@RequestBody Map<String, Object> map){
	...
}

public static <T> T parseEntity(Map<String, Object> map, Class<T> entity){
    return JSON.parseObject(JSON.toJSONString(map), entity);
}

2. 使用json字符串

用JSON去做对象的映射,json字符串比Map还方便呢

@RequestMapping("/test")
public String test(@RequestBody String jsonString){
    BaseDto baseDto = JSON.parseObject(jsonString, BaseDto.class);
    FileDto file = JSON.parseObject(jsonString, FileDto.class);
}

3. 自定参数解析器封装 @MultiRequestBody

其实就是把对象的映射封装进参数解析器里面,让我们能像@RequestBody一样,一个注解就能完成上面的事。只不过我们的注解是能重复使用的。

1.创建注解

@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface MultiRequestBody {

	String value() default "";
}

2.实现HandlerMethodArgumentResolver接口

重写supportsParameter方法,判断当前的接受对象是否合适调用我们的ArgumentResolver

public class MultiRequestBodyArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        MultiRequestBody ann = methodParameter.getParameterAnnotation(MultiRequestBody.class);

        // 判断是否带上了我们的MultiRequestBody注解
        if (ann == null){
            return false;
        }
            
        Class<?> parameterType = methodParameter.getParameterType();

        // 如果是基本类型,不支持
        if (parameterType.isPrimitive()){
             return false;

        }
        
        // 一些业务判断逻辑...
        
        return true;
    }

}

接着就是重写resolveArgument方法,把参数映射到对象的逻辑写到这里

在处理这个问题之前还有一个问题。HttpServletRequest 获取POST请求数据只能获取一次,这也是为何 @RequestBody只能在一个参数上的原因。获取完,流就关闭了,所以我们如果要多次获取的话,就要将这个流序列化。

解决思路是继承HttpServletRequestWrapper这个包装类,来实现流的序列化,以及序列化值的获取

public class CachingRequestWrapper extends HttpServletRequestWrapper {
    private byte [] bodyCache;

    public CachingRequestWrapper(HttpServletRequest request) {
        super(request);
        try{
            InputStream requestInputStream = request.getInputStream();
            this.bodyCache = StreamUtils.copyToByteArray(requestInputStream);
        } catch (IOException e) {
            bodyCache = new byte[0];
        }
    }

    public String getHttpRequestBody() {
        return bodyCache.length == 0 ? "" : new String(bodyCache);
    }

}
   

再重写OncePerRequestFilter,将包装类替换成我们重写的包装类

@Component
public class CachingRequestFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain)
            throws ServletException, IOException {
        // 替换成自己写的 CachingRequestWrapper
        CachingRequestWrapper requestWrapper = new CachingRequestWrapper(httpServletRequest);

        filterChain.doFilter(requestWrapper, httpServletResponse);
    }
}
  

终于可以重写resolveArgument方法了 先写一个通用获取 get、post请求体的方法

public class MultiRequestBodyArgumentResolver implements HandlerMethodArgumentResolver {
    private String getHttpRequestBody(HttpServletRequest request) {
        if (request.getMethod().equalsIgnoreCase("get")) {
            return request.getQueryString();
        } else {
            if (request instanceof CachingRequestWrapper) {
                return ((CachingRequestWrapper) request).getHttpRequestBody();
            }
        }
        System.out.println(String.format("request 非 CachingRequest %s", request.getClass()));
        return "";
    }
}

然后写参数映射到对象的逻辑

public class MultiRequestBodyArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
        HttpServletRequest request = nativeWebRequest.getNativeRequest(HttpServletRequest.class);
        String jsonString = getHttpRequestBody(request);
        MultiRequestBody ann = methodParameter.getParameterAnnotation(MultiRequestBody.class);
        Class<?> parameterType = methodParameter.getParameterType();

        // 如果注解的值不为空,我们则先查找到该 key
        // 不然直接解析
        if (StringUtils.isEmpty(ann.value())) {
            return JSON.parseObject(jsonString, parameterType);
        } else {
            JSONObject jsonObject = JSON.parseObject(jsonString);
            return jsonObject.getObject(ann.value(), parameterType);
        }
    }
}
  1. 继承WebMvcConfigurer,将我们的自定义参数解析器加到配置里
@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {
    
    @Bean
    public MultiRequestBodyArgumentResolver getMultiRequestBodyArgumentResolver(){
        return new MultiRequestBodyArgumentResolver();
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(getMultiRequestBodyArgumentResolver());
    }
}
    

三、使用@MultiRequestBody

可以直接映射多个对象

image.png

image.png

四、使用@MultiRequestBody导致@RequestBody失效

因为我们CachingRequestWrapper的构造方法把request的InputStream给占用了。导致@RequestBody调用时,流会为空。所以还要重写CachingRequestWrapper中的getInputStream和getReader这两个方法

1. 继承ServletInputStream

由于需要 ServletInputStream ,故我们需要写一个自己继承 ServletInputStream 的流

public class CachingInputStream extends ServletInputStream {

    private InputStream cachedBodyInputStream;

    public CachingInputStream(byte[] cachedBody) {
        this.cachedBodyInputStream = new ByteArrayInputStream(cachedBody);
    }

    @Override
    public boolean isFinished() {
        try {
            return cachedBodyInputStream.available() == 0;
        } catch (IOException e) {
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public boolean isReady() {
        return false;
    }

    @Override
    public void setReadListener(ReadListener readListener) {
        throw new UnsupportedOperationException();
    }

    @Override
    public int read() throws IOException {
        return cachedBodyInputStream.read();
    }
}
    

2. 重写getInputStream和getReader

public class CachingRequestWrapper extends HttpServletRequestWrapper {
   @Override
   public ServletInputStream getInputStream() throws IOException {
       return new CachingInputStream(this.bodyCache);
   }

   @Override
   public BufferedReader getReader() throws IOException {
       ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.bodyCache);
       return new BufferedReader(new InputStreamReader(byteArrayInputStream));
   }

}

3. 运行结果

image.png

image.png 完美解决问题

写在最后

此文基本是大佬的思路和实现,我就做了一些修改,或许使用Map或者json字符串会更加方便,但是愿意去改进和思考改进思路,让代码能更加简洁,值得尊敬。解决问题的过程也收益良多。

参考资料

HandlerMethodArgumentResolver(四):自定参数解析器处理特定应用场景
Spring多次读取HttpServletRequest