Автоматизация HTTP запросов в контексте Spring

в 12:14, , рубрики: di, java, rest, spring

Предыстория

Несколько месяцев назад поступила задача по написанию HTTP API работы с продуктом компании, а именно обернуть все запросы с помощью RestTemplate и последующим перехватом информации от приложения и модификации ответа. Примерная реализация сервиса по работе с приложением была таковая:

        if (headers == null) {
            headers = new HttpHeaders();
        }

        if (headers.getFirst("Content-Type") == null) {
            headers.add("Content-Type", MediaType.APPLICATION_JSON_VALUE);
        }

        HttpEntity<Object> entity;
        if (body == null) {
            entity = new HttpEntity<>(headers);
        } else {
            entity = new HttpEntity<>(body, headers);
        }

        final String uri = String.format("%s%s/%s", workingUrl, apiPath, request.info());

        final Class<O> type = (Class<O>) request.type();
        final O response = (O)restTemplate.exchange(uri, request.method(), entity, type);

… простенький метод, принимающий тип, тело и заголовки запроса. И все бы хорошо, но выглядело как костыль и не особо юзабельно в контексте Spring.
И пока товарищи джуны писали "костыли" в своих ветках, мне пришла в голову гениальнейшая идея — а почему бы не писать эти запросы "в одну строчку" (like Feign).

Идея

У нас в руках имеется мощный DI контейнер Spring, так почему бы не использовать его функционал в полной мере? В частности инициализации Data репозиториев на примере Jpa. Предо мной стояла задача инициализация класса типа интерфейс в контексте Spring и три варианта решения перехвата вызова метода, как типичной реализации — Aspect, PostProcess и BeanDefinitionRegistrar.

Кодовая база

Первым делом — аннотации, куда же без них, иначе как конфигурировать запросы.

1) Mapping — аннотация, идентифицирующая интерфейс как компонент HTTP вызовов.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Mapping {
    /**
     * Registered service application name, need for config
     */
    String alias();
}

Параметр alias отвечает за присваивание корневого роутинга сервиса, будь то https://habr.com, https://github.com, etc.

2) ServiceMapping — аннотация, идентифицирующая метод интерфейса, который должен быть вызван как стандартный HTTP запрос к приложению, откуда мы хотим получить ответ либо выполнить какое-либо действие.

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface ServiceMapping {
    /**
     * Registered service application route
     */
    String path();

    /**
     * Registered service application route http-method
     */
    HttpMethod method();

    Header[] defaultHeaders() default {};

    Class<?> fallbackClass() default Object.class;

    String fallbackMethod() default "";
}

Параметры:

  • path — путь запроса, пример alias + /ru/hub/${hub_name};
  • method — метод HTTP запроса (GET, POST, PUT, etc.);
  • defaultHeaders — статические заголовки запроса, которые неизменяемые для удаленного ресурса (Content-Type, Accept, etc.);
  • fallbackClass — класс отбраковки запроса, который обработался с ошибкой (Exception);
  • fallbackMethod — наименование метода класса, который должен вернуть корректный результат, если произошла ошибка (Exception).

3) Header — аннотация, идентифицирующая статические заголовки в настройках запроса

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.ANNOTATION_TYPE})
@Documented
public @interface Header {
    String name();

    String value();
}

Параметры:

  • name — наименование заголовка;
  • value — значение заголовка.

Следующий этап — реализация своего FactoryBean для перехвата вызова методов интерфейса.

MappingFactoryBean.java

package org.restclient.factory;

import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.restclient.annotations.RestInterceptor;
import org.restclient.annotations.ServiceMapping;
import org.restclient.annotations.Type;
import org.restclient.config.ServicesConfiguration;
import org.restclient.config.ServicesConfiguration.RouteSettings;
import org.restclient.interceptor.Interceptor;
import org.restclient.model.MappingMetadata;
import org.restclient.model.Pair;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.jmx.access.InvocationFailureException;
import org.springframework.lang.NonNull;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClient.ResponseSpec;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import javax.naming.ConfigurationException;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.*;

/**
 * @author: GenCloud
 * @created: 2019/08
 */
@Slf4j
@ToString
public class MappingFactoryBean implements BeanFactoryAware, FactoryBean<Object>, ApplicationContextAware {
    private static final Collection<String> ignoredMethods = Arrays.asList("equals", "hashCode", "toString");

    private Class<?> type;
    private List<Object> fallbackInstances;
    private List<MappingMetadata> metadatas;
    private String alias;
    private ApplicationContext applicationContext;
    private BeanFactory beanFactory;

    @Override
    public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    @Override
    public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
        this.beanFactory = beanFactory;
    }

    @Override
    public Class<?> getObjectType() {
        return type;
    }

    @Override
    public boolean isSingleton() {
        return true;
    }

    @Override
    public Object getObject() {
        return Enhancer.create(type, (MethodInterceptor) (instance, method, args, methodProxy) -> {
            final boolean skip = ignoredMethods.stream().anyMatch(ignore -> method.getName().equals(ignore));

            final ServiceMapping annotation = method.getAnnotation(ServiceMapping.class);
            if (!skip && annotation != null) {
                return invokeMethod(annotation, method, args);
            }
            return null;
        });
    }

    /**
     * It determines the meta-information of the executing method, calling an HTTP request based on the
     * meta-information found; interceptors are also called.
     *
     * @param annotation - main annotation that defines the path, type, standard request parameters.
     * @param method     - callable method
     * @param args       - method arguments
     * @return if the request is executed without errors, returns a clean server response in wrappers Mono/Flux.
     * @throws Throwable
     */
    private Object invokeMethod(ServiceMapping annotation, Method method, Object[] args) throws Throwable {
        final MappingMetadata metadata = findMetadataByMethodName(method.getName());
        if (metadata == null) {
            throw new NoSuchMethodException(String.format("Cant find metadata for method %s. Check your mapping configuration!", method.getName()));
        }

        final RouteSettings routeSettings = findSettingsByAlias(alias);
        final String host = routeSettings.getHost();

        String url = metadata.getUrl().replace(String.format("${%s}", alias), host);

        final HttpMethod httpMethod = metadata.getHttpMethod();
        final HttpHeaders httpHeaders = metadata.getHttpHeaders();

        final List<Pair<String, Object>> foundVars = new ArrayList<>();
        final List<Pair<String, Object>> foundParams = new ArrayList<>();
        final List<Pair<String, Object>> foundHeaders = new ArrayList<>();

        final Parameter[] parameters = method.getParameters();

        final Object body = initHttpVariables(args, parameters, foundVars, foundParams, foundHeaders);

        url = replaceHttpVariables(url, foundVars, foundParams, foundHeaders, httpHeaders);

        preHandle(args, body, httpHeaders);

        if (log.isDebugEnabled()) {
            log.debug("Execute Service Mapping request");
            log.debug("Url: {}", url);
            log.debug("Headers: {}", httpHeaders);
            if (body != null) {
                log.debug("Body: {}", body);
            }
        }

        final Object call = handleHttpCall(annotation, args, url, httpMethod, body, httpHeaders, metadata);
        postHandle(ResponseEntity.ok(call));
        return call;
    }

    private Object handleHttpCall(ServiceMapping annotation, Object[] args, String url, HttpMethod httpMethod, Object body, HttpHeaders httpHeaders, MappingMetadata metadata) throws Throwable {
        final WebClient webClient = WebClient.create(url);

        ResponseSpec responseSpec;
        final Class<?> returnType = metadata.getReturnType();
        try {
            if (body != null) {
                responseSpec = webClient
                        .method(httpMethod)
                        .headers(c -> c.addAll(httpHeaders))
                        .body(BodyInserters.fromPublisher(Mono.just(body), Object.class))
                        .retrieve();
            } else {
                responseSpec = webClient
                        .method(httpMethod)
                        .headers(c -> c.addAll(httpHeaders))
                        .retrieve();
            }
        } catch (RestClientResponseException ex) {
            if (log.isDebugEnabled()) {
                log.debug("Error on execute route request - Code: {}, Error: {}, Route: {}", ex.getRawStatusCode(), ex.getResponseBodyAsString(), url);
            }

            final String fallbackMethod = metadata.getFallbackMethod();

            final Object target = fallbackInstances.stream()
                    .filter(o ->
                            o.getClass().getSimpleName().equals(annotation.fallbackClass().getSimpleName()))
                    .findFirst().orElse(null);

            Method fallback = null;
            if (target != null) {
                fallback = Arrays.stream(target.getClass().getMethods())
                        .filter(m -> m.getName().equals(fallbackMethod))
                        .findFirst()
                        .orElse(null);
            }

            if (fallback != null) {
                args = Arrays.copyOf(args, args.length + 1);
                args[args.length - 1] = ex;
                final Object result = fallback.invoke(target, args);
                return Mono.just(result);
            } else if (returnType == Mono.class) {
                return Mono.just(ResponseEntity.status(ex.getRawStatusCode()).body(ex.getResponseBodyAsString()));
            } else if (returnType == Flux.class) {
                return Flux.just(ResponseEntity.status(ex.getRawStatusCode()).body(ex.getResponseBodyAsString()));
            } else {
                return Mono.empty();
            }
        }

        final Method method = metadata.getMethod();
        final Type classType = method.getDeclaredAnnotation(Type.class);
        final Class<?> type = classType == null ? Object.class : classType.type();

        if (returnType == Mono.class) {
            return responseSpec.bodyToMono(type);
        } else if (returnType == Flux.class) {
            return responseSpec.bodyToFlux(type);
        }

        return null;
    }

    private String replaceHttpVariables(String url, final List<Pair<String, Object>> foundVars, final List<Pair<String, Object>> foundParams,
                                        final List<Pair<String, Object>> foundHeaders, final HttpHeaders httpHeaders) {
        for (Pair<String, Object> pair : foundVars) {
            url = url.replace(String.format("${%s}", pair.getKey()), String.valueOf(pair.getValue()));
        }

        for (Pair<String, Object> pair : foundParams) {
            url = url.replace(String.format("${%s}", pair.getKey()), String.valueOf(pair.getValue()));
        }

        foundHeaders.forEach(pair -> {
            final String headerName = pair.getKey();
            if (httpHeaders.getFirst(headerName) != null) {
                httpHeaders.set(headerName, String.valueOf(pair.getValue()));
            } else {
                log.warn("Undefined request header name '{}'! Check mapping configuration!", headerName);
            }
        });

        return url;
    }

    private Object initHttpVariables(final Object[] args, final Parameter[] parameters, final List<Pair<String, Object>> foundVars,
                                     final List<Pair<String, Object>> foundParams, final List<Pair<String, Object>> foundHeaders) {
        Object body = null;
        for (int i = 0; i < parameters.length; i++) {
            final Object value = args[i];
            final Parameter parameter = parameters[i];

            final PathVariable pv = parameter.getDeclaredAnnotation(PathVariable.class);
            final RequestParam rp = parameter.getDeclaredAnnotation(RequestParam.class);
            final RequestHeader rh = parameter.getDeclaredAnnotation(RequestHeader.class);
            final RequestBody rb = parameter.getDeclaredAnnotation(RequestBody.class);

            if (rb != null) {
                body = value;
            }

            if (rh != null) {
                foundHeaders.add(new Pair<>(rh.value(), value));
            }

            if (pv != null) {
                final String name = pv.value();
                foundVars.add(new Pair<>(name, value));
            }

            if (rp != null) {
                final String name = rp.value();
                foundParams.add(new Pair<>(name, value));
            }
        }

        return body;
    }

    private void preHandle(Object[] args, Object body, HttpHeaders httpHeaders) {
        final Map<String, Interceptor> beansOfType = applicationContext.getBeansOfType(Interceptor.class);
        beansOfType.values()
                .stream()
                .filter(i ->
                        i.getClass().isAnnotationPresent(RestInterceptor.class)
                                && ArrayUtils.contains(i.getClass().getDeclaredAnnotation(RestInterceptor.class).aliases(), alias))
                .forEach(i -> i.preHandle(args, body, httpHeaders));
    }

    private void postHandle(ResponseEntity<?> responseEntity) {
        final Map<String, Interceptor> beansOfType = applicationContext.getBeansOfType(Interceptor.class);
        beansOfType.values()
                .stream()
                .filter(i ->
                        i.getClass().isAnnotationPresent(RestInterceptor.class)
                                && ArrayUtils.contains(i.getClass().getDeclaredAnnotation(RestInterceptor.class).aliases(), alias))
                .forEach(i -> i.postHandle(responseEntity));
    }

    private MappingMetadata findMetadataByMethodName(String methodName) {
        return metadatas
                .stream()
                .filter(m -> m.getMethodName().equals(methodName)).findFirst()
                .orElseThrow(() -> new InvocationFailureException(""));
    }

    private RouteSettings findSettingsByAlias(String alias) throws ConfigurationException {
        final ServicesConfiguration servicesConfiguration = applicationContext.getAutowireCapableBeanFactory().getBean(ServicesConfiguration.class);
        return servicesConfiguration.getRoutes()
                .stream()
                .filter(r ->
                        r.getAlias().equals(alias))
                .findFirst()
                .orElseThrow(() -> new ConfigurationException(String.format("Cant find service host! Check configuration. Alias: %s", alias)));
    }

    @SuppressWarnings("unused")
    public Class<?> getType() {
        return type;
    }

    @SuppressWarnings("unused")
    public void setType(Class<?> type) {
        this.type = type;
    }

    @SuppressWarnings("unused")
    public List<MappingMetadata> getMetadatas() {
        return metadatas;
    }

    @SuppressWarnings("unused")
    public void setMetadatas(List<MappingMetadata> metadatas) {
        this.metadatas = metadatas;
    }

    @SuppressWarnings("unused")
    public String getAlias() {
        return alias;
    }

    @SuppressWarnings("unused")
    public void setAlias(String alias) {
        this.alias = alias;
    }

    @SuppressWarnings("unused")
    public List<Object> getFallbackInstances() {
        return fallbackInstances;
    }

    @SuppressWarnings("unused")
    public void setFallbackInstances(List<Object> fallbackInstances) {
        this.fallbackInstances = fallbackInstances;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        MappingFactoryBean that = (MappingFactoryBean) o;
        return Objects.equals(type, that.type);
    }

    @Override
    public int hashCode() {
        return Objects.hash(type);
    }
}

Вкратце объясню, что делает эта реализация объекта бина:

  • обеспечивает хранение мета-информации методов интерфейса с настройками запросов к ресурсу, таких как сами методы идентифицированные аннотациями, классы отбраковки, коллекция моделей настроек роутинга;
  • обеспечивает перехват вызова метода в контексте приложения с помощью CGlib (MappingFactoryBean#getObject()), т.е. формально реализации вызываемого метода нет, но физически срабатывает перехват метода и в зависимости от параметров аннотация и аргументов метода, происходит обработка HTTP запроса.

Третьим этапом является реализация низкоуровнего компонента DI контейнера Spring, а конкретно интерфейса ImportBeanDefinitionRegistrar.

ServiceMappingRegistrator.java

package org.restclient.factory;

import lombok.extern.slf4j.Slf4j;
import org.restclient.annotations.Header;
import org.restclient.annotations.Mapping;
import org.restclient.annotations.ServiceMapping;
import org.restclient.model.MappingMetadata;
import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition;
import org.springframework.beans.factory.config.BeanDefinitionHolder;
import org.springframework.beans.factory.support.AbstractBeanDefinition;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.BeanDefinitionReaderUtils;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.EnvironmentAware;
import org.springframework.context.ResourceLoaderAware;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
import org.springframework.core.env.Environment;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.core.type.ClassMetadata;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.lang.NonNull;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;

import javax.naming.ConfigurationException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.*;
import java.util.stream.Collectors;

/**
 * @author: GenCloud
 * @created: 2019/08
 */
@Slf4j
public class ServiceMappingRegistrator implements ImportBeanDefinitionRegistrar, ResourceLoaderAware, EnvironmentAware {
    private ResourceLoader resourceLoader;
    private Environment environment;

    @Override
    public void setEnvironment(@NonNull Environment environment) {
        this.environment = environment;
    }

    @Override
    public void setResourceLoader(@NonNull ResourceLoader resourceLoader) {
        this.resourceLoader = resourceLoader;
    }

    @Override
    public void registerBeanDefinitions(@NonNull AnnotationMetadata metadata, @NonNull BeanDefinitionRegistry registry) {
        registerMappings(metadata, registry);
    }

    private void registerMappings(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
        final ClassPathScanningCandidateComponentProvider scanner = getScanner();
        scanner.setResourceLoader(resourceLoader);

        final Set<String> basePackages = getBasePackages(metadata);

        final AnnotationTypeFilter annotationTypeFilter = new AnnotationTypeFilter(Mapping.class);
        scanner.addIncludeFilter(annotationTypeFilter);

        basePackages
                .stream()
                .map(scanner::findCandidateComponents)
                .flatMap(Collection::stream)
                .filter(candidateComponent -> candidateComponent instanceof AnnotatedBeanDefinition)
                .map(candidateComponent -> (AnnotatedBeanDefinition) candidateComponent)
                .map(AnnotatedBeanDefinition::getMetadata)
                .map(ClassMetadata::getClassName)
                .forEach(className -> buildGateway(className, registry));
    }

    private void buildGateway(String className, BeanDefinitionRegistry registry) {
        try {
            final Class<?> type = Class.forName(className);
            final List<Method> methods = Arrays
                    .stream(type.getMethods())
                    .filter(method ->
                            method.isAnnotationPresent(ServiceMapping.class))
                    .collect(Collectors.toList());

            final String alias = type.getDeclaredAnnotation(Mapping.class).alias();

            final List<MappingMetadata> metadatas = new ArrayList<>();

            final List<Object> fallbackInstances = new ArrayList<>();

            for (Method method : methods) {
                final ServiceMapping serviceMapping = method.getDeclaredAnnotation(ServiceMapping.class);

                final Class<?>[] args = method.getParameterTypes();

                final Header[] defaultHeaders = serviceMapping.defaultHeaders();

                final String path = serviceMapping.path();
                final HttpMethod httpMethod = serviceMapping.method();
                final HttpHeaders httpHeaders = new HttpHeaders();

                final StringBuilder url = new StringBuilder();
                url.append("${").append(alias).append("}").append(path);

                final Parameter[] parameters = method.getParameters();
                for (int i = 0; i < parameters.length; i++) {
                    final Parameter parameter = parameters[i];
                    for (Annotation annotation : parameter.getAnnotations()) {
                        if (!checkValidParams(annotation, args)) {
                            break;
                        }

                        if (annotation instanceof RequestParam) {
                            final String argName = ((RequestParam) annotation).value();
                            if (argName.isEmpty()) {
                                throw new ConfigurationException("Configuration error: defined RequestParam annotation dont have value! Api method: " + method.getName() + ", Api Class: " + type);
                            }

                            final String toString = url.toString();
                            if (toString.endsWith("&") && i + 1 == args.length) {
                                url.append(argName).append("=").append("${").append(argName).append("}");
                            } else if (!toString.endsWith("&") && i + 1 == args.length) {
                                url.append("?").append(argName).append("=").append("${").append(argName).append("}");
                            } else if (!toString.endsWith("&")) {
                                url.append("?").append(argName).append("=").append("${").append(argName).append("}").append("&");
                            } else {
                                url.append(argName).append("=").append("${").append(argName).append("}").append("&");
                            }
                        } else if (annotation instanceof PathVariable) {
                            final String argName = ((PathVariable) annotation).value();
                            if (argName.isEmpty()) {
                                throw new ConfigurationException("Configuration error: defined PathVariable annotation dont have value! Api method: " + method.getName() + ", Api Class: " + type);
                            }

                            final String toString = url.toString();
                            final String argStr = String.format("${%s}", argName);
                            if (!toString.contains(argStr)) {
                                if (toString.endsWith("/")) {
                                    url.append(argStr);
                                } else {
                                    url.append("/").append(argStr);
                                }
                            }
                        } else if (annotation instanceof RequestHeader) {
                            final String argName = ((RequestHeader) annotation).value();
                            if (argName.isEmpty()) {
                                throw new ConfigurationException("Configuration error: defined RequestHeader annotation dont have value! Api method: " + method.getName() + ", Api Class: " + type);
                            }

                            httpHeaders.add(argName, String.format("${%s}", argName));
                        }
                    }
                }

                if (defaultHeaders.length > 0) {
                    Arrays.stream(defaultHeaders)
                            .forEach(header -> httpHeaders.add(header.name(), header.value()));
                }

                final Object instance = serviceMapping.fallbackClass().newInstance();
                fallbackInstances.add(instance);

                final String fallbackName = serviceMapping.fallbackMethod();
                final String buildedUrl = url.toString();
                final MappingMetadata mappingMetadata = new MappingMetadata(method, httpMethod, buildedUrl, httpHeaders, fallbackName);
                metadatas.add(mappingMetadata);

                log.info("Bind api path - alias: {}, url: {}", alias, buildedUrl);
            }

            final BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(MappingFactoryBean.class);
            beanDefinitionBuilder.addPropertyValue("type", className);
            beanDefinitionBuilder.addPropertyValue("alias", alias);
            beanDefinitionBuilder.addPropertyValue("metadatas", metadatas);
            beanDefinitionBuilder.addPropertyValue("fallbackInstances", fallbackInstances);

            final AbstractBeanDefinition beanDefinition = beanDefinitionBuilder.getBeanDefinition();

            final BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className, new String[]{type.getSimpleName()});
            BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
        } catch (IllegalAccessException | InstantiationException | ClassNotFoundException | ConfigurationException e) {
            e.printStackTrace();
        }
    }

    private boolean checkValidParams(Annotation annotation, Object[] args) {
        Arrays
                .stream(args)
                .map(Object::getClass)
                .forEach(type -> {
                    if (annotation instanceof RequestParam) {
                        if (type.isAnnotationPresent(PathVariable.class)) {
                            throw new IllegalArgumentException("Annotation RequestParam cannot be used with PathVariable");
                        }
                    } else if (annotation instanceof PathVariable) {
                        if (type.isAnnotationPresent(RequestParam.class)) {
                            throw new IllegalArgumentException("Annotation PathVariable cannot be used with RequestParam");
                        }
                    }
                });

        return true;
    }

    private Set<String> getBasePackages(AnnotationMetadata importingClassMetadata) {
        Map<String, Object> attributes = importingClassMetadata.getAnnotationAttributes(SpringBootApplication.class.getCanonicalName());
        if (attributes == null) {
            attributes = importingClassMetadata.getAnnotationAttributes(ComponentScan.class.getCanonicalName());
        }

        Set<String> basePackages = new HashSet<>();
        if (attributes != null) {
            basePackages = Arrays.stream((String[]) attributes.get("scanBasePackages")).filter(StringUtils::hasText).collect(Collectors.toSet());

            Arrays.stream((Class[]) attributes.get("scanBasePackageClasses")).map(ClassUtils::getPackageName).forEach(basePackages::add);
        }

        if (basePackages.isEmpty()) {
            basePackages.add(ClassUtils.getPackageName(importingClassMetadata.getClassName()));
        }

        return basePackages;
    }

    private ClassPathScanningCandidateComponentProvider getScanner() {
        return new ClassPathScanningCandidateComponentProvider(false, environment) {
            @Override
            protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
                boolean isCandidate = false;
                if (beanDefinition.getMetadata().isIndependent()) {
                    if (!beanDefinition.getMetadata().isAnnotation()) {
                        isCandidate = true;
                    }
                }
                return isCandidate;
            }
        };
    }
}

Т.е. что происходит в начале старта приложения — когда срабатывает событие контекста Spring REFRESH, будут задействованы все реализации интерфейса ImportBeanDefinitionRegistrar, которые импортированы в контекст приложения, и будет вызван метод registerBeanDefinitions в который поступает информация о аннотированных конфигурационных классах и фабрика-регистратор/хранилище бинов (компонентов, сервисов, репозиториев, etc.), и прямо в этом методе можно получить информацию о базовых пакетах приложения и "в какую сторону копать" для поиска наших интерфейсов и их инициализации с помощью BeanDefinitionBulder и нашей реализацией MappingFactoryBean. Для импортирования регистратора достаточно использовать аннотацию Import с именем этого класса (в текущей реализации модуля используется конфигурационный класс RestClientAutoConfiguration, где и прописаны необходимые аннотации для работы модуля).

Как использовать

Кейс — мы хотим получить список информации некоего репозитория GitHub пользователя.

1) Написание конфигурации для работы с сервисом (application.yml)

services:
  routes:
    - host: https://api.github.com # корневой роутинг АПИ GitHub
      alias: github-service # наименование сервиса, использующееся в аннотации Mapping

1) Реализация интерфейса по взаимодействию с сервисом

@Mapping(alias = "github-service") // алиас сервиса указанный в конфигурации
public interface RestGateway {
    /**
     * Метод получения информации всех репозиториев конкретного пользователя.
     *
     * @param userName - пользователь GitHub
     * @return массив объектов LinkedHashMap
     */
    @ServiceMapping(path = "/users/${userName}/repos", method = GET)
    @Type(type = ArrayList.class) // тип возвращаемого объекта при сериализации в обертке Mono/Flux
    Mono<ArrayList> getRepos(@PathVariable("userName") String userName);
}

2) Вызов сервиса

@SprinBootApplication
public class RestApp {
    public static void main(String... args) {
        final ConfigurableApplicationContext context = SpringApplication.run(RestApp.class, args);
        final RestGateway restGateway = context.getType(RestGateway.class);
        final Mono<ArrayList> response = restGateway.getRepos("gencloud");
        response.doOnSuccess(list -> 
                log.info("Received response: {}", list)).subscribe();
    }
}

Как результат выполнения в дебаге можно увидеть это (для удобства можно подложить за место типа ArrayList объектную обертку результирующего json ответа; код отличается, потому что использовал юнит тест в купе с reactor-test библиотекой, но принцип от этого не изменился):
image

Заключение

Не всем конечно по душе такой подход, сложный дебаг, не там ткнул аннотацию — получил оплеуху от ConfigurationException, еще и какие-то конфиги писать, ой…
Приму конструктивные пожелания и предложения по развитию API, надеюсь, что статья была полезной к прочтению. Спасибо за внимание.

Весь код доступен по ссылке — https://github.com/GenCloud/rest_client.

Автор: Максим Иванов

Источник


* - обязательные к заполнению поля