Pull to refresh

Создание библиотеки в стиле Spring Data Repository своими руками при помощи Dynamic Proxy и Spring IoC

Reading time 10 min
Views 6.5K

А что если бы можно было создать интерфейс, например, такой:


@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'ов:


DynamicProxyBeanDefinitionRegistryPostProcessor
@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:


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 мы проходимся по всем обработчикам и возвращем первый попавшийся, способный обработать переданный метод. Данный механизм поиска может быть не очень эффективен, когда реализаций обработчиков станет много. Возможно, тогда нужно будет задуматься о какой-то более подходящей структуре для их хранения, чем список.


Реализация обработчиков


В задачи обработчиков входит считывание информации о вызванном методе интерфейса и обработка самого вызова.


Что должен сделать обработчик в данном случае:


  1. Считать аннотацию Uri, достать её содержимое
  2. Заменить в строке Uri плейсхолдеры на реальные значения
  3. Считать возвращаемый тип метода
  4. Если возвращаемый тип подходит, выполнить обработку метода и вернуть результат.

Первые три пункта нужны для всех возвращаемых типов, поэтому общий код я вынес в абстрактный суперкласс
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 клиент, и клиент к базе данных. Но для чего ещё это можно применить?

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


А что вы думаете про такой подход? Стоит ли оно стараний? Какие вы видите в данном подходе проблемы? Пока я его всё ещё стараюсь осмыслить, пока он обкатывается в нашем продакшене, хотелось бы услышать что думают о нём другие люди. Надеюсь, данный материал был полезен кому-то.

Tags:
Hubs:
+12
Comments 0
Comments Leave a comment

Articles