1. Problems to be solved
In spring cloud microservices, when feign is used as the client of declarative microservice call, we often encounter the problem that spring MVC's native annotation @ RequestParam does not support custom POJO objects, for example:
API interface of service:
@FeignClient(name="springcloud-nacos-producer", qualifier="productApiService") public interface ProductApiService { @GetMapping(value="/api/product/list", produces=APPLICATION_JSON) public PageResult<List<Product>> getProductListByPage(@RequestParam Product condition, @RequestParam Page page, @RequestParam Sort sort); } public class Page implements DtoModel { private static final long serialVersionUID = 1L; private Integer currentPage = 1; private Integer pageSize = 10; private Integer totalRowCount = 0; //get/set... } public class Sort implements DtoModel { private static final long serialVersionUID = 1L; private List<Order> orders; Sort() { super(); } Sort(List<Order> orders) { super(); this.orders = orders; } public static Sort by(Order... orders) { return new Sort(Arrays.asList(orders)); } public List<Order> getOrders() { return orders; } public void setOrders(List<Order> orders) { this.orders = orders; } public Order first() { if(orders != null && orders.size() > 0) { return orders.get(0); } return null; } public static class Order { public static final String DIRECTION_ASC = "asc"; public static final String DIRECTION_DESC = "desc"; private String property; private String direction; Order() { super(); } Order(String property, String direction) { super(); if(direction != null) { direction = direction.toLowerCase(); direction = DIRECTION_DESC.equals(direction) ? DIRECTION_DESC : DIRECTION_ASC; } else { direction = DIRECTION_ASC; } this.property = property; this.direction = direction; } public static Order by(String property, String direction) { return new Order(property, direction); } public static Order asc(String property) { return new Order(property, DIRECTION_ASC); } public static Order desc(String property) { return new Order(property, DIRECTION_DESC); } public String getProperty() { return property; } public void setProperty(String property) { this.property = property; } public String getDirection() { return direction; } public void setDirection(String direction) { this.direction = direction; } /** * Used by SpringMVC @RequestParam and JAX-RS @QueryParam * @param order * @return */ public static Order valueOf(String order) { if(order != null) { String[] orders = order.trim().split(":"); String prop = null, dir = null; if(orders.length == 1) { prop = orders[0] == null ? null : orders[0].trim(); if(prop != null && prop.length() > 0) { return Order.asc(prop); } } else if (orders.length == 2) { prop = orders[0] == null ? null : orders[0].trim(); dir = orders[1] == null ? null : orders[1].trim(); if(prop != null && prop.length() > 0) { return Order.by(prop, dir); } } } return null; } @Override public String toString() { return property + ":" + direction; } } @Override public String toString() { return "Sort " + orders + ""; } }
Provider of service:
@RestController("defaultProductApiService") public class ProductApiServiceImpl extends HttpAPIResourceSupport implements ProductApiService { @Autowired private ProductMapper productMapper; @Override public PageResult<List<Product>> getProductListByPage(Product condition, Page page, Sort sort) { List<Product> dataList = productMapper.selectModelPageListByExample(condition, sort, new RowBounds(page.getOffset(), page.getLimit())); page.setTotalRowCount(productMapper.countModelPageListByExample(condition)); return PageResult.success().message("OK").data(dataList).totalRowCount(page.getTotalRowCount()).build(); } }
Consumer of service:
@RestController public class ProductController implements ProductApiService { //Remote call to feign proxy service of provider @Resource(name="productApiService") private ProductApiService productApiService; @Override public PageResult<List<Product>> getProductListByPage(Product condition, Page page, Sort sort) { return productApiService.getProductListByPage(condition, page, sort); } }
2. It is expected to be compatible with the native features of @ RequestParam in springmvc:
That is, if the request URL is: http://127.0.0.1 : 18181 / API / product / list? Product name = Huawei & ProductType = 1 & CurrentPage = 1 & PageSize = 20 & orders = createtime: DESC, Updatetime: desc
Expectation 1: the following two writing methods are fully compatible:
Style 1 (the native style of spring MVC):
@RestController public class ProductController1 { @GetMapping(value="/api/product/list", produces=APPLICATION_JSON) public PageResult<List<Product>> getProductListByPage(Product condition, Page page, Sort sort) { .... } }
Method 2 (compatible with feign):
public interface ProductApiService { @GetMapping(value="/api/product/list", produces=APPLICATION_JSON) public PageResult<List<Product>> getProductListByPage(@RequestParam Product condition, @RequestParam Page page, @RequestParam Sort sort); }
Expectation 2: whether it is a direct call to Provider or a call to Consumer, the request URL is compatible!
3. Solutions
(1) inherit RequestParamMethodArgumentResolver, enhance the resolution ability of springmvc to @ RequestParam, and be able to resolve the handler defined as follows:
@GetMapping(value="/api/product/list1", produces=APPLICATION_JSON) public PageResult<List<Product>> getProductListByPage1(@RequestParam Product condition, @RequestParam Page page, @RequestParam Sort sort) { //... } //perhaps @GetMapping(value="/api/product/list2", produces=APPLICATION_JSON) public PageResult<List<Product>> getProductListByPage1(@RequestParam("condition") Product condition, @RequestParam("page") Page page, @RequestParam("sort") Sort sort) { //... }
Customized EnhancedRequestParamMethodArgumentResolver
/** * The enhanced RequestParamMethodArgumentResolver solves the problem of parameter resolution when @ RequestParam annotation is used for user-defined POJO objects * * for instance: * * Request 1: http://172.16.18.174:18180/api/user/list1/? Condition = {"username": "a", "status": 1} & page = {"CurrentPage": 1, "PageSize": 20} & sort = {"orders": [{"property": "createtime", "direction": "desc"}, {"property": "Updatetime", "direction": "ASC"}]} * * Request 2: http://172.16.18.174:18180/api/user/list/? Username = A & status = 1 & CurrentPage = 1 & PageSize = 20 & orders = createtime: DESC, Updatetime: desc * * @GetMapping(value="/api/user/list", produces=APPLICATION_JSON) * public PageResult<List<User>> getUserListByPage( @RequestParam User condition, @RequestParam Page page, @RequestParam Sort sort ); * * As shown in the above example, the parameter of request 1 can be correctly parsed by the @ RequestParam annotation, but request 2 cannot. This implementation solves this problem * */ public class EnhancedRequestParamMethodArgumentResolver extends RequestParamMethodArgumentResolver { /** * A list of explicitly stated resolvable parameter types */ private List<Class<?>> resolvableParameterTypes; private volatile ConversionService conversionService; private BeanFactory beanFactory; public EnhancedRequestParamMethodArgumentResolver(boolean useDefaultResolution) { super(useDefaultResolution); } public EnhancedRequestParamMethodArgumentResolver(ConfigurableBeanFactory beanFactory, boolean useDefaultResolution) { super(beanFactory, useDefaultResolution); this.beanFactory = beanFactory; } @Override protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception { Object arg = super.resolveName(name, parameter, request); if(arg == null) { if(isResolvableParameter(parameter)) { HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class); Map<String,Object> parameterMap = getRequestParameters(servletRequest); arg = instantiateParameter(parameter); SpringBeanUtils.setBeanProperty(arg, parameterMap, getConversionService()); } } return arg; } /** * Judge whether the parameters of @ RequestParam annotation are resolvable * 1,Not a SimpleProperty (determined by the BeanUtils.isSimpleProperty() method) * 2,It is not a Map type (the Map type uses RequestParamMapMethodArgumentResolver, which is not considered here) * 3,This parameter type has a default parameterless constructor * @param parameter * @return */ protected boolean isResolvableParameter(MethodParameter parameter) { Class<?> clazz = parameter.getNestedParameterType(); if(!CollectionUtils.isEmpty(resolvableParameterTypes)) { for(Class<?> parameterType : resolvableParameterTypes) { if(parameterType.isAssignableFrom(clazz)) { return true; } } } if(!BeanUtils.isSimpleProperty(clazz) && !Map.class.isAssignableFrom(clazz)) { Constructor<?>[] constructors = clazz.getDeclaredConstructors(); if(!ArrayUtils.isEmpty(constructors)) { for(Constructor<?> constructor : constructors) { if(constructor.getParameterTypes().length == 0) { return true; } } } } return false; } /** * Instantiate an instance of @ RequestParam annotation parameter * @param parameter * @return */ protected Object instantiateParameter(MethodParameter parameter) { return BeanUtils.instantiateClass(parameter.getNestedParameterType()); } protected Map<String,Object> getRequestParameters(HttpServletRequest request) { Map<String,Object> parameters = new HashMap<String,Object>(); Map<String,String[]> paramMap = request.getParameterMap(); if(!CollectionUtils.isEmpty(paramMap)) { paramMap.forEach((key, values) -> { parameters.put(key, ArrayUtils.isEmpty(values) ? null : (values.length == 1 ? values[0] : values)); }); } return parameters; } protected ConversionService getConversionService() { if(conversionService == null) { synchronized (this) { if(conversionService == null) { try { conversionService = (ConversionService) beanFactory.getBean("mvcConversionService"); //lazy init mvcConversionService, create by WebMvcAutoConfiguration } catch (BeansException e) { conversionService = new DefaultConversionService(); } } } } return conversionService; } public List<Class<?>> getResolvableParameterTypes() { return resolvableParameterTypes; } public void setResolvableParameterTypes(List<Class<?>> resolvableParameterTypes) { this.resolvableParameterTypes = resolvableParameterTypes; } } public class SpringBeanUtils { /** * Fill the values in properties into the specified bean * @param bean * @param properties * @param conversionService */ public static void setBeanProperty(Object bean, Map<String,Object> properties, ConversionService conversionService) { Assert.notNull(bean, "Parameter 'bean' can not be null!"); BeanWrapper beanWrapper = PropertyAccessorFactory.forBeanPropertyAccess(bean); beanWrapper.setConversionService(conversionService); for(Map.Entry<String,Object> entry : properties.entrySet()) { String propertyName = entry.getKey(); if(beanWrapper.isWritableProperty(propertyName)) { beanWrapper.setPropertyValue(propertyName, entry.getValue()); } } } }
Inherit RequestMappingHandlerAdapter to replace the customized EnhancedRequestParamMethodArgumentResolver into springmvc:
public class EnhancedRequestMappingHandlerAdapter extends RequestMappingHandlerAdapter { @Override public void afterPropertiesSet() { super.afterPropertiesSet(); List<HandlerMethodArgumentResolver> argumentResolvers = new ArrayList<HandlerMethodArgumentResolver>(getArgumentResolvers()); replaceRequestParamMethodArgumentResolvers(argumentResolvers); setArgumentResolvers(argumentResolvers); List<HandlerMethodArgumentResolver> initBinderArgumentResolvers = new ArrayList<HandlerMethodArgumentResolver>(getInitBinderArgumentResolvers()); replaceRequestParamMethodArgumentResolvers(initBinderArgumentResolvers); setInitBinderArgumentResolvers(initBinderArgumentResolvers); } /** * Replace RequestParamMethodArgumentResolver with enhanced RequestParamMethodArgumentResolver * @param methodArgumentResolvers */ protected void replaceRequestParamMethodArgumentResolvers(List<HandlerMethodArgumentResolver> methodArgumentResolvers) { methodArgumentResolvers.forEach(argumentResolver -> { if(argumentResolver.getClass().equals(RequestParamMethodArgumentResolver.class)) { Boolean useDefaultResolution = ReflectionUtils.getFieldValue(argumentResolver, "useDefaultResolution"); EnhancedRequestParamMethodArgumentResolver enhancedArgumentResolver = new EnhancedRequestParamMethodArgumentResolver(getBeanFactory(), useDefaultResolution); enhancedArgumentResolver.setResolvableParameterTypes(Arrays.asList(DtoModel.class)); Collections.replaceAll(methodArgumentResolvers, argumentResolver, enhancedArgumentResolver); } }); } }
Register the customized EnhancedRequestMappingHandlerAdapter to the container
@Configuration public class MyWebMvcConfiguration implements WebMvcConfigurer, WebMvcRegistrations { private final RequestMappingHandlerAdapter defaultRequestMappingHandlerAdapter = new EnhancedRequestMappingHandlerAdapter(); /** * Custom RequestMappingHandlerAdapter */ @Override public RequestMappingHandlerAdapter getRequestMappingHandlerAdapter() { return defaultRequestMappingHandlerAdapter; } }
(2) feign client is supported, and the corresponding Converter needs to be customized to parse the request parameters:
/** * feign-client When parsing the complex object annotated by @ RequestParam, feign client serializes the object into a String converter when initiating the request * */ public class ObjectRequestParamToStringConverter implements ConditionalGenericConverter { private static final TypeDescriptor STRING_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(String.class); private final ObjectMapper objectMapper; public ObjectRequestParamToStringConverter() { super(); this.objectMapper = JsonUtils.createDefaultObjectMapper(); this.objectMapper.setSerializationInclusion(Include.NON_EMPTY); } @Override public Set<ConvertiblePair> getConvertibleTypes() { return Collections.singleton(new ConvertiblePair(Object.class, String.class)); } @Override public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { try { return objectMapper.writeValueAsString(source); } catch (Exception e) { throw new ApplicationRuntimeException(e); } } @Override public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { if(STRING_TYPE_DESCRIPTOR.equals(targetType)) { Class<?> clazz = sourceType.getObjectType(); if(!BeanUtils.isSimpleProperty(clazz)) { if(sourceType.hasAnnotation(RequestParam.class)) { return true; } } } return false; } } /** * feign-client When parsing complex objects annotated with @ RequestParam, deserialize String to the object's converter when spring MVC receives the request * */ public class StringToObjectRequestParamConverter implements ConditionalGenericConverter { private static final TypeDescriptor STRING_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(String.class); public StringToObjectRequestParamConverter() { super(); } @Override public Set<ConvertiblePair> getConvertibleTypes() { return Collections.singleton(new ConvertiblePair(String.class, Object.class)); } @Override public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { try { if(source != null && JsonUtils.isJsonObject(source.toString())) { return JsonUtils.json2Object(source.toString(), targetType.getObjectType()); } return null; } catch (Exception e) { throw new ApplicationRuntimeException(e); } } @Override public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { if(STRING_TYPE_DESCRIPTOR.equals(sourceType)) { Class<?> clazz = targetType.getObjectType(); if(!BeanUtils.isSimpleProperty(clazz)) { if(targetType.hasAnnotation(RequestParam.class)) { return true; } } } return false; } }
Register and apply the ObjectRequestParamToStringConverter and stringtobjectrequestparamconverter customized above
@Configuration @ConditionalOnClass(SpringMvcContract.class) public class MyFeignClientsConfiguration implements WebMvcConfigurer { @Bean public List<FeignFormatterRegistrar> feignFormatterRegistrar() { return Arrays.asList(new DefaultFeignFormatterRegistrar()); } @Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(new StringToObjectRequestParamConverter()); } public static class DefaultFeignFormatterRegistrar implements FeignFormatterRegistrar { @Override public void registerFormatters(FormatterRegistry registry) { registry.addConverter(new ObjectRequestParamToStringConverter()); } } }