Let spring cloud feign client fully support the @ RequestParam annotation feature of spring MVC

Keywords: Java Spring

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());
        }
        
    }
    
}

Posted by ririe44 on Sat, 02 Nov 2019 04:16:58 -0700