А что если бы можно было создать интерфейс, например, такой:
@Service
public interface GoogleSearchApi {
/**
* @return http status code for Google main page
*/
@Uri("https://www.google.com")
int mainPageStatus();
}
А затем просто внедрять его и вызывать его методы:
@SpringBootApplication
public class App implements CommandLineRunner {
private static final Logger LOG = LoggerFactory.getLogger(App.class);
private final GoogleSearchApi api;
public App(GoogleSearchApi api) {
this.api = api;
}
@Override
public void run(String... args) {
LOG.info("Main page status: " + api.mainPageStatus());
}
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
Такое вполне возможно реализовать (и не очень то и сложно). Дальше я покажу, как и зачем это делать.
Недавно у меня была задача упростить для разработчиков взаимодействие с одним из используемых фреймворков. Нужно было дать им ещё более простой и удобный способ работать с ним, чем тот, который уже был реализован.
Свойства, которых хотелось добиться от такого решения:
- декларативное описание желаемого действия
- минимальное необходимое количество кода
- интеграция с используемым фреймворком внедрения зависимостей (в нашем случае Spring)
Подобное реализовано в библиотеках Spring Data Repository и Retrofit. В них пользователь описывает желаемое взаимодействие в виде java интерфейса, дополненного аннотациями. Пользователю не нужно самому писать реализацию — её генерирует библиотека в рантайме на основе сигнатур методов, аннотаций и типов.
Когда я изучал тему, у меня возникало много вопросов, ответы на которые были разбросаны по всему интернету. Мне бы в тот момент не помешала статья, подобная этой. Потому здесь я постарался собрать всю информацию и мой опыт в одном месте.
В данном посте я покажу, как можно реализовать данную идею, на примере обёртки для http-клиента. Пример игрушечный, предназначенный не для реального использования, а для демонстрации подхода. Исходники проекта можно изучить на bitbucket.
Как это выглядит для пользователя
Пользователь описывает необходимый ему сервис в виде интерфейса. Например, для выполнения http запросов в google:
/**
* Some Google requests
*/
@Service
public interface GoogleSearchApi {
/**
* @return http status code for Google main page
*/
@Uri("https://www.google.com")
int mainPageStatus();
/**
* @return request object for Google main page
*/
@Uri("https://www.google.com")
HttpGet mainPageRequest();
/**
* @param query search query
* @return result of search request execution
*/
@Uri("https://www.google.com/search?q={query}")
CloseableHttpResponse searchSomething(String query);
/**
* @param query doodle search query
* @param language doodle search language
* @return http status code for doodle search result
*/
@Uri("https://www.google.com/doodles/?q={query}&hl={language}")
int searchDoodleStatus(String query, String language);
}
Что в конечном итоге будет делать реализация данного интерфейса, определяется по сигнатуре. Если возвращаемый тип int — будет выполняться http запрос и возвращаться статус код результата. Если возвращаемый тип CloseableHttpResponse, то возвращаться будет ответ на запрос целиком, и так далее. Куда будет делаться запрос — будем брать из аннотации Uri, подставляя в её содержимое вместо плейсхолдеров одноимённые переданные значения.
В данном примере я ограничился поддержкой трёх возвращаемых типов и одной аннотации. Также можно использовать для выбора реализации имена методов, типы параметров, использовать всевозможные их комбинации, но эту тему я вскрывать в данном посте не буду.
Когда пользователь хочет использовать данный интерфейс, он внедряет его в свой код используя Spring:
@SpringBootApplication
public class App implements CommandLineRunner {
private static final Logger LOG = LoggerFactory.getLogger(App.class);
private final GoogleSearchApi api;
public App(GoogleSearchApi api) {
this.api = api;
}
@Override
@SneakyThrows
public void run(String... args) {
LOG.info("Main page status: " + api.mainPageStatus());
LOG.info("Main page request: " + api.mainPageRequest());
LOG.info("Doodle search status: " + api.searchDoodleStatus("tesla", "en"));
try (CloseableHttpResponse response = api.searchSomething("qweqwe")) {
LOG.info("Search result " + response);
}
}
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
Интеграция со Spring нужна была в моём рабочем проекте, но она, разумеется, не единственно возможная. Если вы не используете внедрение зависимостей, получение реализации можно сделать, например, через static factory method. Но я в данной статье буду рассматривать именно Spring.
Данный подход очень удобен: достаточно пометить свой интерфейс как компонент Spring (аннотация Service в данном случае), и он готов к внедрению и использованию.
Как заставить Spring поддерживать эту магию
Типичное Spring приложение сканирует classpath на старте и ищет все компоненты, помеченные специальными аннотациями. Для них оно регистрирует BeanDefinition'ы — рецепты, по которым будут создаваться данные компоненты. Но если в случае конкретных классов Spring знает, как их создать, какие вызвать конструкторы и что в них передать, то для абстрактных классов и интерфейсов у него такой информации нет. Поэтому для нашего GoogleSearchApi Spring не будет создавать BeanDefinition. В этом ему потребуется помощь от нас.
Для того, чтобы допилить логику обработку BeanDefinition'ов, в спринге существует интерфейс BeanDefinitionRegistryPostProcessor. С помощью него мы можем добавить в BeanDefinitionRegistry какие нам угодно определения бинов.
К сожалению, я не нашёл способа встроиться в логику Spring сканирования classpath, чтобы обработать и обычные бины и наши интерфейсы за один проход. Поэтому я создал и использовал наследника класса ClassPathScanningCandidateComponentProvider для того, чтобы найти все интерфейсы, помеченные аннотацией Service:
Полный код сканирования пакетов и регистрации BeanDefinition'ов:
@Component
public class DynamicProxyBeanDefinitionRegistryPostProcessor implements BeanDefinitionRegistryPostProcessor {
//корневые пакеты, которые мы будем сканировать
private static final String[] SCAN_PACKAGES = {"com"};
private final InterfaceScanner classpathScanner;
public DynamicProxyBeanDefinitionRegistryPostProcessor() {
classpathScanner = new InterfaceScanner();
//настраиваем фильтры для сканера. В данном примере достаточно аннотации Service
classpathScanner.addIncludeFilter(new AnnotationTypeFilter(Service.class));
}
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
for (String basePackage : SCAN_PACKAGES) {
createRepositoryProxies(basePackage, registry);
}
}
@SneakyThrows
private void createRepositoryProxies(String basePackage, BeanDefinitionRegistry registry) {
for (BeanDefinition beanDefinition : classpathScanner.findCandidateComponents(basePackage)) {
Class<?> clazz = Class.forName(beanDefinition.getBeanClassName());
//для каждого найденного класса создаём кастомный bean definition
BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(clazz);
builder.addConstructorArgValue(clazz);
//указываем, какой метод будет использоваться для создания инстансов наших интерфейсов
builder.setFactoryMethodOnBean(
"createDynamicProxyBean",
DynamicProxyBeanFactory.DYNAMIC_PROXY_BEAN_FACTORY
);
registry.registerBeanDefinition(ClassUtils.getShortNameAsProperty(clazz), builder.getBeanDefinition());
}
}
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
}
private static class InterfaceScanner extends ClassPathScanningCandidateComponentProvider {
InterfaceScanner() {
super(false);
}
@Override
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
return beanDefinition.getMetadata().isInterface();
}
}
}
Готово! На старте приложения Spring выполнит данный код и зарегистрирует все необходимые интерфейсы, как бины.
Создание реализации найденных бинов делегируется отдельному компоненту DynamicProxyBeanFactory:
@Component(DYNAMIC_PROXY_BEAN_FACTORY)
public class DynamicProxyBeanFactory {
public static final String DYNAMIC_PROXY_BEAN_FACTORY = "repositoryProxyBeanFactory";
private final DynamicProxyInvocationHandlerDispatcher proxy;
public DynamicProxyBeanFactory(DynamicProxyInvocationHandlerDispatcher proxy) {
this.proxy = proxy;
}
@SuppressWarnings("unused")
public <T> T createDynamicProxyBean(Class<T> beanClass) {
//noinspection unchecked
return (T) Proxy.newProxyInstance(beanClass.getClassLoader(), new Class[]{beanClass}, proxy);
}
}
Для создания реализации используется старый добрый механизм Dynamic Proxy. Реализация создаётся на лету при помощи метода Proxy.newProxyInstance. О нём уже много написано статей, поэтому останавливаться здесь подробно я не буду.
Поиск нужного обработчика и обработка вызова
Как можно увидеть, DynamicProxyBeanFactory перенаправляет обработку метода в DynamicProxyInvocationHandlerDispatcher. Так как у нас существует потенциально много реализаций обработчиков (на каждую аннотацию, на каждый возвращаемый тип, и т.д.), то логично завести какое-то центральное место их хранения и поиска.
Для того, чтобы определять, подходит ли обработчик для обработки вызванного метода, я расширил стандартный интерфейс InvocationHandler новым методом
public interface HandlerMatcher {
/**
* @return {@code true} if handler is able to handle given method, {@code false} othervise
*/
boolean canHandle(Method method);
}
public interface ProxyInvocationHandler extends InvocationHandler, HandlerMatcher {
}
В результате получился интерфейс ProxyInvocationHandler, реализации которого и будут нашими обработчиками. Также реализации обработчиков будут помечены как Component, чтобы Spring мог соберать их для нас в один большой список внутри DynamicProxyInvocationHandlerDispatcher:
package com.bachkovsky.dynproxy.lib.proxy;
import lombok.SneakyThrows;
import org.springframework.stereotype.Component;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.List;
/**
* Top level dynamic proxy invocation handler, which finds correct implementation based and uses it for method
* invocation
*/
@Component
public class DynamicProxyInvocationHandlerDispatcher implements InvocationHandler {
private final List<ProxyInvocationHandler> proxyHandlers;
/**
* @param proxyHandlers all dynamic proxy handlers found in app context
*/
public DynamicProxyInvocationHandlerDispatcher(List<ProxyInvocationHandler> proxyHandlers) {
this.proxyHandlers = proxyHandlers;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
switch (method.getName()) {
// three Object class methods don't have default implementation after creation with Proxy::newProxyInstance
case "hashCode":
return System.identityHashCode(proxy);
case "toString":
return proxy.getClass() + "@" + System.identityHashCode(proxy);
case "equals":
return proxy == args[0];
default:
return doInvoke(proxy, method, args);
}
}
@SneakyThrows
private Object doInvoke(Object proxy, Method method, Object[] args) {
return findHandler(method).invoke(proxy, method, args);
}
private ProxyInvocationHandler findHandler(Method method) {
return proxyHandlers.stream()
.filter(h -> h.canHandle(method))
.findAny()
.orElseThrow(() -> new IllegalStateException("No handler was found for method: " +
method));
}
}
В методе findHandler мы проходимся по всем обработчикам и возвращем первый попавшийся, способный обработать переданный метод. Данный механизм поиска может быть не очень эффективен, когда реализаций обработчиков станет много. Возможно, тогда нужно будет задуматься о какой-то более подходящей структуре для их хранения, чем список.
Реализация обработчиков
В задачи обработчиков входит считывание информации о вызванном методе интерфейса и обработка самого вызова.
Что должен сделать обработчик в данном случае:
- Считать аннотацию Uri, достать её содержимое
- Заменить в строке Uri плейсхолдеры на реальные значения
- Считать возвращаемый тип метода
- Если возвращаемый тип подходит, выполнить обработку метода и вернуть результат.
Первые три пункта нужны для всех возвращаемых типов, поэтому общий код я вынес в абстрактный суперкласс
HttpInvocationHandler:
public abstract class HttpInvocationHandler implements ProxyInvocationHandler {
final HttpClient client;
private final UriHandler uriHandler;
HttpInvocationHandler(HttpClient client, UriHandler uriHandler) {
this.client = client;
this.uriHandler = uriHandler;
}
@Override
public boolean canHandle(Method method) {
return uriHandler.canHandle(method);
}
final String getUri(Method method, Object[] args) {
return uriHandler.getUriString(method, args);
}
}
Во вспомогательном классе UriHandler реализована работа с аннотацией Uri: считывание значения, замена плейсхолдеров. Код его я тут приводить не буду, т.к. он довольно утилитный.
Но стоит отметить, что для считывания имён параметров из сигнатуры метода java, нужно при компиляции добавить опцию "-parameters".
HttpClient — обёртка над апачевским CloseableHttpClient, является бэкэндом для данной библиотеки.
В качестве примера конкретного обработчика приведу обработчик, возвращающий статус код ответа:
@Component
public class HttpCodeInvocationHandler extends HttpInvocationHandler {
public HttpCodeInvocationHandler(HttpClient client, UriHandler uriHandler) {
super(client, uriHandler);
}
@Override
@SneakyThrows
public Integer invoke(Object proxy, Method method, Object[] args) {
try (CloseableHttpResponse resp = client.execute(new HttpGet(getUri(method, args)))) {
return resp.getStatusLine().getStatusCode();
}
}
@Override
public boolean canHandle(Method method) {
return super.canHandle(method) && method.getReturnType().equals(int.class);
}
}
Остальные обработчики сделаны аналогично. Добавление новых обработчиков выполняется просто и не требует модификации существующего кода — просто создаём новый обработчик и помечаем его как компонент Spring.
Вот и всё. Код написан и готов к работе.
Заключение
Чем больше я думаю о подобном дизайне, тем больше вижу в нём недостатков. Слабые стороны, которые я вижу:
- Type Safety, которой нет. Неправильно поставил аннотацию — до встречи с RuntimeException. Использовал неправильную комбинацию возвращаемого типа и аннотации — то же самое.
- Слабая поддержка от IDE. Отсутствие автодополнения. Пользователь не можжет посмотреть, какие действия доступны ему в его ситуации (как если бы он поставил "точку" после объекта и увидел список доступных методов)
- Мало возможностей для применения. Мне приходят на ум уже упомянутые http клиент, и клиент к базе данных. Но для чего ещё это можно применить?
Впрочем, у меня в рабочем проекте подход прижился и пользуется популярностью. Достоинства, которые я уже упоминал — простота, малое колчичество кода, декларативность, позволяют разработчикам концентрироваться на написании более важного кода.
А что вы думаете про такой подход? Стоит ли оно стараний? Какие вы видите в данном подходе проблемы? Пока я его всё ещё стараюсь осмыслить, пока он обкатывается в нашем продакшене, хотелось бы услышать что думают о нём другие люди. Надеюсь, данный материал был полезен кому-то.