Предыстория
Несколько месяцев назад поступила задача по написанию 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 для перехвата вызова методов интерфейса.
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.
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 библиотекой, но принцип от этого не изменился):
Заключение
Не всем конечно по душе такой подход, сложный дебаг, не там ткнул аннотацию — получил оплеуху от ConfigurationException, еще и какие-то конфиги писать, ой…
Приму конструктивные пожелания и предложения по развитию API, надеюсь, что статья была полезной к прочтению. Спасибо за внимание.
Весь код доступен по ссылке — https://github.com/GenCloud/rest_client.
Автор: Максим Иванов