Create your own Spring Data Repository style library with Dynamic Proxy and Spring IoC

But what if you could create an interface, for example, like this:


@Service
public interface GoogleSearchApi {
    /**
     * @return http status code for Google main page
     */
    @Uri("https://www.google.com")
    int mainPageStatus();
}

And then just inject it and call its methods:


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

This is quite possible to implement (and not very difficult). Next I will show how and why to do it.


Recently, I had the task to simplify for developers interaction with one of the used frameworks. It was necessary to give them an even simpler and more convenient way to work with him than the one that had already been implemented.


Properties that I wanted to achieve from such a solution:


  • declarative description of the desired action
  • minimum amount of code needed
  • integration with the dependency injection framework used (in our case, Spring)

This is implemented in the Spring Data Repository and Retrofit libraries . In them, the user describes the desired interaction in the form of a java interface, supplemented by annotations. The user does not need to write the implementation himself - the library generates it in runtime based on the signatures of methods, annotations and types.


When I studied the topic, I had a lot of questions, the answers to which were scattered throughout the Internet. At that moment, an article like this would not hurt me. Therefore, here I tried to collect all the information and my experience in one place.


In this post I will show how you can implement this idea, using the example of a wrapper for an http client. An example of a toy, designed not for real use, but to demonstrate the approach. The source code of the project can be studied on bitbucket .


How does it look for the user


The user describes the service he needs in the form of an interface. For example, to perform http requests on 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);
}

What the implementation of this interface will ultimately do is determined by the signature. If the return type is int, an http request will be executed and the status will be returned as a result code. If the return type is CloseableHttpResponse, then the entire response will be returned, and so on. Where the request will be made, we will take the Uri from the annotation, substituting the same transferred values ​​instead of placeholders in its contents.


In this example, I limited myself to supporting three return types and one annotation. You can also use method names, parameter types to choose an implementation, use all kinds of combinations of them, but I will not open this topic in this post.


When a user wants to use this interface, he embeds it in his code using 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);
    }
}

Integration with Spring was needed in my working project, but it, of course, is not the only one possible. If you do not use dependency injection, you can get the implementation, for example, through the static factory method. But in this article I will consider Spring.


This approach is very convenient: just mark your interface as a component of Spring (Service annotation in this case), and it is ready for implementation and use.


How to get Spring to support this magic


A typical Spring application scans the classpath at startup and looks for all components marked with special annotations. For them, it registers BeanDefinitions, recipes by which these components will be created. But if in the case of concrete classes, Spring knows how to create them, what constructors to call, and what to pass in them, then for abstract classes and interfaces it does not have such information. Therefore, for our GoogleSearchApi Spring will not create BeanDefinition. In this he will need help from us.


In order to finish the logic of processing BeanDefinitions, there is a BeanDefinitionRegistryPostProcessor interface in the spring. With it, we can add to the BeanDefinitionRegistry any definition of beans we want.


Unfortunately, I did not find a way to integrate into the Spring logic of the classpath scan in order to process both ordinary beans and our interfaces in a single pass. Therefore, I created and used the descendant of the ClassPathScanningCandidateComponentProvider class to find all the interfaces marked with the Service annotation:


Full package scan code and registration of BeanDefinitions:


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

Done! At the start of the application, Spring will execute this code and register all the necessary interfaces, like beans.


Creating an implementation of the found beans is delegated to a separate component of 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 createDynamicProxyBean(Class beanClass) {
        //noinspection unchecked
        return (T) Proxy.newProxyInstance(beanClass.getClassLoader(), new Class[]{beanClass}, proxy);
    }
}

To create the implementation, the good old Dynamic Proxy mechanism is used. An implementation is created on the fly using the Proxy.newProxyInstance method. A lot of articles have already been written about him, so I will not dwell here in detail.


Finding the right handler and call processing


As you can see, DynamicProxyBeanFactory redirects method processing to DynamicProxyInvocationHandlerDispatcher. Since we have potentially many implementations of handlers (for each annotation, for each returned type, etc.), it is logical to establish some central place for their storage and search.


In order to determine whether the handler is suitable for processing the called method, I expanded the standard InvocationHandler interface with a new method


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 {
}

The result is the ProxyInvocationHandler interface, the implementations of which will be our handlers. Also, handler implementations will be marked as Component so that Spring can collect them for us into one big list inside 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 proxyHandlers;
    /**
     * @param proxyHandlers all dynamic proxy handlers found in app context
     */
    public DynamicProxyInvocationHandlerDispatcher(List 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));
    }
}

In the findHandler method, we go through all the handlers and return the first one that can handle the passed method. This search mechanism may not be very effective when there are a lot of handler implementations. Perhaps then you will need to think about some more suitable structure for storing them than a list.


Handler Implementation


The tasks of the handlers include reading information about the called method of the interface and processing the call itself.


What should the handler do in this case:


  1. Read Uri annotation, get its contents
  2. Replace Uri placeholders in the string with real values
  3. Read method return type
  4. If the return type is suitable, process the method and return the result.

The first three points are needed for all return types, so I put the general code into the abstract superclass
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);
    }
}

The UriHandler helper class implements work with Uri annotation: reading values, replacing placeholders. I will not give the code here, because it is quite utilitarian.
But it is worth noting that to read the parameter names from the signature of the java method, you need to add the option "-parameters" when compiling .
HttpClient - a wrapper over Apachevsky CloseableHttpClient, is a backend for this library.


As an example of a specific handler, I will give a handler that returns a status response code:


@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. Отсутствие автодополнения. Пользователь не можжет посмотреть, какие действия доступны ему в его ситуации (как если бы он поставил "точку" после объекта и увидел список доступных методов)
  • There are few possibilities for application. The already mentioned http client comes to mind, and the client goes to the database. But why else can this be applied?

However, in my working draft the approach has taken root and is popular. The advantages that I already mentioned - simplicity, a small amount of code, declarativeness, allow developers to concentrate on writing more important code.


What do you think about this approach? Is it worth the effort? What problems do you see in this approach? While I am still trying to make sense of it, while it is being rolled around in our production, I would like to hear what other people think about it. I hope this material was useful to someone.


Also popular now: