Spring MVC 自定义方法参数解析实战

适合读者:使用Spring MVC / Boot 开发web api项目但http request 参数解析方式不适用于框架本身提供的解析器的开发者。

案例:
        Post接口:/api/v1/orders
        参数:type:string, limit:number
        额外要求:当request的QueryString和body中同时携带参数时,优先使用QueryString。比如

curl -X POST 'https://company.com/api/v1/orders?type=Food' -d 'type=Dig&limit=100'

            则此时解析到的参数应该是type=Food&limit=100

开发方案:

1.  创建Controller,使用HttpServletRequest来自主解析参数。关键代码如下

@RestController
public class OrderController {

    @PostMapping("/api/v1/orders")
    public List queryOrders(HttpServletRequest request) {
        String queryStr = request.getQueryString();
        String body = request.getReader().lines.stream()
                        .collect(Collectors.joining(System.lineSeparator()));
    /* 该方法进行字符串解析放进map并针对相同参数名做替换的过程,此处省略 */
        Map paramMap = parseParam(queryStr, body);
        String type = paramMap.get("type");
        int limit = paramMap.get("limit");
    /**
     * do your business
     */
        return orderList;
    }

/**
 * other methods
 */

}

看上去好像还行。但,如果有很多接口呢?如果接口参数增加呢?如果接口参数有变化呢?如果接口参数有校验呢?
每个接口里都这么写,那前三行是不是都一样。每个接口的参数名只能通过map的key来指定。参数类型从map中获取出来要强转。参数校验更是只能手写。听我这么一说,是不是觉得这不是个好方案。

我们理想中的接口方法应该是这样的:

@RestController
public class OrderController {

    @PostMapping("/api/v1/orders")
    public List queryOrders(@Valid QueryOrdersParam param) {
        return orderService.queryOrders(param.getType(), param.getLimit());
    }

/**
 * other methods
 */

}

参数Bean 定义成这样

@Data
@Builder
public class QueryOrdersParam {
    @NotBlank(message = "Type must be given")
    private String type;
    @Max(value = 1000)
    private int limit=100;
}

看上去是不是很清爽。我们知道参数Bean可以使用javax.validation来委托给框架做校验。Controller方法的参数可以通过框架自动注入。但是框架解析请求参数并注入到Bean的过程好像和我们的需求不太一样,怎么办呢?

来来来,少年。HandlerMethodArgumentResolver 了解一下?

2. 我们先来看一下这个接口有什么内容。

public interface HandlerMethodArgumentResolver {

	/**
	 * Whether the given {@linkplain MethodParameter method parameter} is
	 * supported by this resolver.
	 * @param parameter the method parameter to check
	 * @return {@code true} if this resolver supports the supplied parameter;
	 * {@code false} otherwise
	 */
	boolean supportsParameter(MethodParameter parameter);

	/**
	 * Resolves a method parameter into an argument value from a given request.
	 * A {@link ModelAndViewContainer} provides access to the model for the
	 * request. A {@link WebDataBinderFactory} provides a way to create
	 * a {@link WebDataBinder} instance when needed for data binding and
	 * type conversion purposes.
	 * @param parameter the method parameter to resolve. This parameter must
	 * have previously been passed to {@link #supportsParameter} which must
	 * have returned {@code true}.
	 * @param mavContainer the ModelAndViewContainer for the current request
	 * @param webRequest the current request
	 * @param binderFactory a factory for creating {@link WebDataBinder} instances
	 * @return the resolved argument value, or {@code null}
	 * @throws Exception in case of errors with the preparation of argument values
	 */
	Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception;

}

· supportsParameter
    这个方法返回一个布尔值来标识这个parameter 是否需要处理。

· resolveArgument
    这个方法就是具体处理参数的方法。方法入参包括目标参数相关元数据parameter,当前请求的ModelAndView容器mavContainer,当前的请求webRequest, 最后还有一个用于创建binder的工厂类实例。

那么,还等什么呢,让我们动手吧。

@Slf4j
@Component
public class ControllerArgumentsResolver implements HandlerMethodArgumentResolver {

    /**
     * 直接返回true 表示对所有参数类型都进行转换
     */
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return true;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, 
                    ModelAndViewContainer mavContainer,
                    NativeWebRequest webRequest, 
                    WebDataBinderFactory binderFactory) throws Exception {
        return null;
    }
}

自己动手实现一个该接口的实现,如果是Spring MVC框架需要在content.xml 里注入一下这个实现类。


    
            
    

如果是Spring Boot框架需要编码注入。

@Configuration
public class ArgumentResolversConfig extends WebMvcConfigurerAdapter{
    
    @Override
    public void addArgumentResolvers(List argumentResolvers) {
        super.addArgumentResolvers(argumentResolvers);
        argumentResolvers.add(new ControllerArgumentResolver());
    }

}

配置好我们自己的参数解析器之后让我们测试一下我们的接口

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:application-context.xml")
@WebAppConfiguration
@Slf4j
public class ControllerTest {
    @Autowired
    private WebApplicationContext wac;

    private MockMvc mockMvc;

     @Before
    public void setup() {
        /* 构造MockMvc */
        this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
    }

    @Test
    public void testQueryOrders() {
        mockMvc.perform(MockMvcRequestBuilers.post("/api/v1/orders?type=Dig").content("type=Food&limit=10").accept(MedisType.APPLICATION_HSON).andExpect(MockMvcResultMatchers.status.isOk()));
    }

}

嗯,果然Controller的方法里获取的参数Bean是个Null.

3. 既然我们能让参数变成Null 那没有理由我们不能创建它,对吧。让我们看看HandlerMethodArgumentResolver这个接口本身有哪些实现,有没有我们能用得上的。经过筛选,我们发现有一个叫做ModelAttributeMethodProcessor的实现类看上去跟我们要找的东西比较接近。来,让我们完善一下我们的参数解析器。

@Slf4j
@Component
public class ControllerArgumentsResolver implements HandlerMethodArgumentResolver {

    /**
     * 我们自定义一个注解@RequestAttribute 来表示这是要用我们自定义的参数解析器来生成的参数。
     * 同时,我们的参数必须要使用@Valid 来声明进行校验。
     */
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(Valid.class) && parameter
                .hasParameterAnnotation(RequestAttribute.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter,
                ModelAndViewContainer mavContainer,
                NativeWebRequest webRequest, 
                WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        String name = parameter.getParameterName();
        Object attribute = mavContainer.containsAttribute(name) ?
                mavContainer.getModel().get(name) :
                this.createAttribute(parameter);
/* 这段代码和我们一开始直接Controller里进行的操作是一样的 */
        String requestStr = request.getQueryString();
        String body = request.getReader().stream().lines().collect(Collectors.joining(System.lineSeparator()));
        Map paramMap = parseRequest(requestStr, body);

        WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name);
        if (binder.getTarget() != null) {
/* 这一行是进行参数绑定的核心 */
            binder.bind(new MutablePropertyValues(map));
/* 这一行进行validation校验 */
            validateIfApplicable(binder, parameter);
/* 如果有错误则抛出BindException */
            if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
                throw new BindException(binder.getBindingResult());
            }
        }

        Map bindingResultModel = binder.getBindingResult().getModel();
        mavContainer.removeAttributes(bindingResultModel);
        mavContainer.addAllAttributes(bindingResultModel);

        log.debug("Found argument {} ---> {}", parameter.getParameterName(), attribute);
/* 调用binder 方法转换成我们的目标Bean 类型并返回 */
        return binder.convertIfNecessary(binder.getTarget(), parameter.getParameterType(), parameter);
    }

}

好,现在让我们把Controller里的方法参数改一下,增加上我们自定义的注解声明

@RestController
public class OrderController {

    @PostMapping("/api/v1/orders")
    public List queryOrders(@RequestAttribute @Valid QueryOrdersParam param) {
        return orderService.queryOrders(param.getType(), param.getLimit());
    }

/**
 * other methods
 */

}

然后我们跑一下测试类。嗯,通过。大功告成。

引深:
        有很多其他场景都适用于自定义参数解析器来方便我们对Controller内的方法进行开发。比如,全部或者大部分的接口都需要进行user 有效性校验并在方法内获取user 对象。我们可以通过实现拦截器来进行user 有效性校验,通常这个时候你已经获取到了user 对象,那么完全可以在拦截器里把user 对象放进request attribute里,再实现一个参数解析器从request attribute里获取出来。这样我们在Controller 的方法声明里就可以直接得到有效的user 对象。可以很大程度的提高我们的开发效率,也使得代码可读性得到提高。

        说了这么多,不妨自己动手试一下。起码去github上关注一下作者吧。

 

你可能感兴趣的:(working)