Guice всемогущий: assistedinject, multibindings, generics

В последнее время чаще стал встречать команды, которые используют Guice в качестве DI фреймворка. Стал его бояться (слезать с любимого Spring!?), и, как это обычно в жизни и бывает, страхи мои материализовались — я попал на проект, на котором активно используется Guice

В интернете уже накопилось изрядное количество публикаций (в том числе и русскоязычных) по данному фреймворку, что не может не радовать. Однако, на проекте я столкнулся с ситуацией, готовое решение для которой мне не удалось найти.

В статье я в очередной раз покажу практическое использование Guice и некоторых его extensions: assistedinject, mutibindings , а также работу с generics. Сначала я опишу суть задачи, а затем итеративно приду к ее решению. Подразумеваю у читателя наличие базовых представлений о фреймворке и о DI в целом, поэтому опущу основы. Тем более, есть отличная документация.

Исходный код проекта и историю его итераций можно найти на гитхабе.

1. Обработчик запросов


Ситуация следующая. Представьте, что нам прилетает запрос (например, по REST) с параметром, на основании которого нужно создавать исполнителя этого запроса, и с аргументом, который будут использован исполнителем для его дальнейшей работы.

public class Request {
    public String parameter;
    public int argument;
}

Исполнителей Worker у нас много (целая иерархия) и каждому для выполнения его работы нужны зависимости в виде сервисов, причем наборы зависимостей могут отличаться для разных исполнителей. Рассмотрим это на примере простой иерархии из абстрактного класса и двух наследников. На деле, конечно, это должно работать для любого N.

В соответствие с этим, прототип Worker:

public abstract class Worker{
    protected final int argument;

    public Worker(int argument) {
        this.argument = argument;
    }

    public abstract void doWork();
}

Сами реализации Worker:

public class Worker1 extends Worker {
    private ServiceA serviceA;
    private ServiceB serviceB;

    public Worker1(ServiceA serviceA, ServiceB serviceB, int argument) {
        super(argument);
        this.serviceA = serviceA;
        this.serviceB = serviceB;
    }

    @Override
    public void doWork() {
        System.out.println(String.format("Worker1 starts work with argument %d services %s and %s",  argument, serviceA, serviceB));
    }
}

public class Worker2 extends Worker {
    private ServiceB serviceB;
    private ServiceC serviceC;

    public Worker2(ServiceB serviceB, ServiceC serviceC, int argument) {
        super(argument);
        this.serviceB = serviceB;
        this.serviceC = serviceC;
    }

    @Override
    public void doWork() {
        System.out.println(String.format("Worker2 starts work with argument %d services %s and %s",  argument, serviceB, serviceC));
    }
}

Простая реализация обработчика


Когда я первый раз увидел код обработчика, он выглядел примерно следующим образом:

public class RequestHandler {
    private final ServiceA serviceA;
    private final ServiceB serviceB;
    private final ServiceC serviceC;

    public RequestHandler(ServiceA serviceA,
                             ServiceB serviceB,
                             ServiceC serviceC) {
        this.serviceA = serviceA;
        this.serviceB = serviceB;
        this.serviceC = serviceC;
    }

    public void handleRequest(Request request) {
        Worker worker = null;
        if (request.parameter.equals("case1")) {
            worker = new Worker1(request.argument);
        } else if (request.parameter.equals("case2")) {
            worker = new Worker2(request.argument);
        }
        //Здесь и далее не буду добавлять проверки на корректность входного
        //параметра, чтобы не загромождать код
        worker.setServiceA(serviceA);
        worker.setServiceB(serviceB);
        worker.setServiceC(serviceC);

        worker.doWork();
    }
}

Чем такой подход плох? Попробую сформировать небольшой список недостатков этого кода:

  • сразу бросается в глаза внедрение зависимостей через set*-методы. Пришлось добавить эти методы в абстрактный класс;
  • значит, и все три зависимости на ServiceA, ServiceB и ServiceC переехали «наверх» в абстрактный класс. Но ему совершенно необязательно знать о сервисах, это дело его наследников. К примеру, Worker1 абсолютно не нуждается в ServiceC;
  • if — else if-структура. Она будет очень страшно выглядеть при увеличении количества наследников в иерархии Worker;
  • передача зависимостей в конструктор обработчика. Сам обработчик запросов об этих зависимостях не должен знать — они ему ни к чему. Нужны они только самим экземплярам Worker непосредственно,
  • Слишком много ответственностей для класса RequestHandler: он умеет маппить запрос на нужный Worker, создавать его и проставлять зависимости. В идеале (согласно принципам SOLID) хочется следовать парадигме «Один класс — одна ответственность».

Лично для себя я подытожил так: хочется перевести Worker'ы на Guice!

2. Подключаем Guice


Первым делом, добавляем зависимость Maven в pom.xml:

<dependency>
      <groupId>com.google.inject</groupId>
      <artifactId>guice</artifactId>
      <version>${guice.version}</version>
</dependency>

Последняя версия Guice на момент написания статьи — 4.2.0.

Я обещал двигаться итеративно, поэтому для начала упростим задачу. Пускай у нас нет никаких аргументов в Request. Worker — предельно простой класс с парой зависимостей в виде сервисов. Т.е. абстрактный класс предельно прост:

public abstract class Worker {   
    public abstract void doWork();
}

А его реализации выглядят вот так (реализация Worker2 выглядит аналогично):

public class Worker1 extends Worker{
    private ServiceA serviceA;
    private ServiceB serviceB;

    @Inject
    public Worker1(ServiceA serviceA, ServiceB serviceB) {
        this.serviceA = serviceA;
        this.serviceB = serviceB;
    }

    @Override
    public void doWork() {
        System.out.println(String.format("Worker1 starts work with %s and %s", serviceA, serviceB));
    }
}

Аннотация @Inject в данном случае говорит фрейморку, что для создания экземпляра Worker нужно использовать помеченный этой аннотацией конструктор, а также во время создания предоставить конструктору все входные параметры. Мы можем не заботиться о том, где взять сервисы, Guice все сделает за нас.

RequestHandler будет выглядеть следующим образом:

@Singleton
public class RequestHandler {
    private Provider<Worker1> worker1Provider;
    private Provider<Worker2> worker2Provider;

    @Inject
    public RequestHandler(Provider<Worker1> worker1Provider, 
                         Provider<Worker2> worker2Provider) {
        this.worker1Provider = worker1Provider;
        this.worker2Provider = worker2Provider;
    }

    public void handleRequest(Request request) {
        Worker worker = null;
        if (request.parameter.equals("case1")) {
            worker = worker1Provider.get();
        } else if (request.parameter.equals("case2")) {
            worker = worker2Provider.get();
        }
        worker.doWork();
    }
}

Сразу бросается в глаза, что мы избавились от зависимостей на сервисы в этом классе. Вместо этого инжектим Provider, типизированный по Worker. Из документации:
Provider<T> — an object capable of providing instances of type T
В данном случае Provider — это фабрика, предоставляемая фреймворком Guice. После того, как получена зависимость на провайдер, типизированный классом Worker, при каждом вызове метода .get() получаем новый экземпляр класса Worker (если, конечно, Worker не объявлен, как Singleton).

Заметьте, что RequestHandler, в свою очередь, как раз помечен аннотацией @Singleton. Это значит, Guice позаботится о том, чтобы у нас в приложении не возникало двух экземпляров этого класса.

Запускаем код:

 public static void main( String[] args ) {
        Request request = new Request();
        request.parameter = "case1";
        request.argument = 5;

        Injector injector = Guice.createInjector();
        RequestHandler requestHandler = injector.getInstance(RequestHandler.class);

        requestHandler.handleRequest(request);

        request.parameter = "case2";
        requestHandler.handleRequest(request);
    }

Результат выполнения
Worker1 starts work with ServiceA and ServiceB
Worker2 starts work with ServiceB and ServiceC

3. Прокидываем аргументы


А теперь вернем изначальный вид классов Worker. Для этого нужно передать в конструктор новый параметр argument:

@Inject
public Worker1(ServiceA serviceA, ServiceB serviceB, int argument) {

Проблема в том, что при наличии аннотации @Inject Guice будет предоставлять все параметры, указанные в конструкторе, что мешает передать параметр, формирующийся в Runtime.

Конечно, решить эту проблему можно с помощью создания собственной Factory:

Worker Factory
@Singleton
public class WorkerFactory {
    private ServiceA serviceA;
    private ServiceB serviceB;
    private ServiceC serviceC;

    @Inject
    public WorkerFactory(ServiceA serviceA,
                         ServiceB serviceB, 
                         ServiceC serviceC) {
        this.serviceA = serviceA;
        this.serviceB = serviceB;
        this.serviceC = serviceC;
    }

    public Worker1 createWorker1 (int argument) {
        return new Worker1(serviceA, serviceB, argument);
    }

    public Worker2 createWorker2 (int argument) {
        return new Worker2(serviceB, serviceC, argument);
    }
}

Запускаем код точно так же, как показано выше и видим тот же результат.

Подобные фабрики содержат довольно много шаблонного кода: необходимо явно указать все зависимости, которые будут использоваться в создаваемом классе, заинжектить их и явным образом передать в конструктор, вызывая его с помощью оператора new. Guice позволяет избежать этой рутинной работы с помощью своих расширений.

Guice AssistedInject


Подключаем зависимость на расширение (extension) для Guice:

<dependency>
      <groupId>com.google.inject.extensions</groupId>
      <artifactId>guice-assistedinject</artifactId>
      <version>${guice.version}</version>
</dependency>

Теперь вместо того, чтобы писать большой класс WorkerFactory, делаем интерфейс с тем же названием:

public interface WorkerFactory {
    Worker1 createWorker1 (int argument);

    Worker2 createWorker2 (int argument);
}

Реализацию интерфейса мы писать не будем, это сделает за нас Guice! Настраиваем это с помощью Модуля:

public class Module extends AbstractModule {
    @Override
    protected void configure() {
        install(new FactoryModuleBuilder().implement(Worker1.class, Worker1.class)
                .implement(Worker2.class, Worker2.class)
                .build(WorkerFactory.class));
    }
}

Можно смотреть на модуль, как на класс для вспомогательной конфигурации Guice. Есть возможность подключать несколько модулей при создании Injector, а также можно подключать модули к модулям, что дает возможность создания гибко-настраиваемой и читаемой системы конфигураций.

Для создания фабрики мы использовали FactoryModuleBuilder. Из документации:
FactoryModuleBuilder — provides a factory that combines the caller's arguments with injector-supplied values to construct objects.
У нас появляется возможность комбинировать пользовательские параметры с объектами, предоставляемые Guice.

Разберем создание фабрики подробнее:

  • метод build(WorkerFactory.class) сообщает Guice, что нужно предоставить реализацию фабрики WorkerFactory;
  • метод implement принимает два аргумента:
    implement (Class<T> source, Class<? extends T> target)
    source — интерфейс возвращаемого значения, target — его реализация, которую наша фабрика будет предоставлять;
  • В данном случае пришлось явным образом задавать создание Worker1 и Worker2, хотя у них есть общий интерфейс Worker. Терпение, на следующем шаге мы это поправим;
  • install(new FactoryModuleBuilder()) — завершаем конфигурацию, добавляя новый модуль к нашему.

Нужно обязательно не забыть сообщить Guice о том, какие параметры в конструкторе Worker будут прокинуты через фабрику, а какие — оставлены фреймворку на растерзание. Делаем это с помощью аннотации @Assisted:

@AssistedInject
public Worker1(ServiceA serviceA, ServiceB serviceB, @Assisted int argument) 

Аннотация @Assisted ставится над теми аргументами, которые мы сами предоставим Guice из фабрики. Также, обычно над конструктором в таком случае ставится @AssistedInject вместо @Inject.

Перепишем RequestHandler, добавив в него зависимость на WorkerFactory:

@Singleton
public class RequestHandler {
    private WorkerFactory workerFactory;

    @Inject
    public RequestHandler(WorkerFactory workerFactory) {
        this.workerFactory = workerFactory;
    }

    public void handleRequest(Request request) {
        Worker worker = null;
        if (request.parameter.equals("case1")) {
            worker = workerFactory.createWorker1(request.argument);
        } else if (request.parameter.equals("case2")) {
            worker = workerFactory.createWorker2(request.argument);
        }

        worker.doWork();
    }
}

Остался последний штрих — для поднятия контекста Guice должен узнать о нашем модуле. Ничего не меняется, просто для получения Injector указываем модуль:

    Injector injector = Guice.createInjector(new Module());

Результат выполнения
Worker1 starts work with argument 5 services ServiceA and ServiceB
Worker2 starts work with argument 5 services ServiceB and ServiceC

4. Параметризуем Factory


Неужели, каждый раз когда у нас будет появляться новый наследник Worker придется и добавлять его в интерфейс WorkerFactory, и сообщать об этом модулю Module?
Попробуем избавиться от этого, сделав параметризацию WorkerFactory по Worker, а заодно узнаем, как с этим справляется Guice.

public interface WorkerFactory<T extends Worker> {
    T createWorker (int argument);
}

Теперь нужно указать фрейморку Guice, что необходимо создать два разных инстанса фабрик — по одной на каждый Worker. Только как сделать так, чтобы фабрика была типизирована? Ведь Java не позволяет писать такие конструкции: WorkerFactory<Worker1>.class

public class Module extends AbstractModule{
    @Override
    protected void configure() {
        install(new FactoryModuleBuilder().implement(Worker.class, Worker1.class)
                .build(new TypeLiteral<WorkerFactory<Worker1>>() {}));

        install(new FactoryModuleBuilder().implement(Worker.class, Worker2.class)
                .build(new TypeLiteral<WorkerFactory<Worker2>>() {}));
    }
}

В этот раз в аргументах метода implement можем указать то, что и требовала его сигнатура: Worker — абстрактный класс, родитель, а Worker1 или Worker2 — его наследники, которые будут создаваться соответствующей фабрикой.

Проблему с generics мы решили с помощью класса TypeLiteral. Из документации Guice:
TypeLiteral<T> — represents a generic type T. Java doesn't yet provide a way to represent generic types, so this class does
Таким образом, раз Java не имеет представления о параметризованном классе, Guice создал свое.

Обычно вместо аргумента Class<T> можно использовать TypeLiteral<T>, достаточно просто взглянуть на перегруженные методы. Не забывайте ставить {} при создании TypeLiteral, так как его конструктор объявлен, как protected.

Теперь посмотрим, как подключить зависимости фабрик к RequestHandler:

@Singleton
public class RequestHandler {
    private WorkerFactory<Worker1> worker1Factory;
    private WorkerFactory<Worker2> worker2Factory;

    @Inject
    public RequestHandler(WorkerFactory<Worker1> worker1Factory,
                          WorkerFactory<Worker2> worker2Factory) {
        this.worker1Factory = worker1Factory;
        this.worker2Factory = worker2Factory;
    }

    public void handleRequest(Request request) {
        Worker worker = null;
        if (request.parameter.equals("case1")) {
            worker = worker1Factory.createWorker(request.argument);
        } else if (request.parameter.equals("case2")) {
            worker = worker2Factory.createWorker(request.argument);
        }

        worker.doWork();
    }
}

5. Multibindings


Итак, мы параметризовали WorkerFactory, оставив единый интерфейс для всех фабрик, который не придется расширять при добавлении новых наследников класса Worker. Но вместо этого нужно будет каждый раз в RequestHandler внедрять новую зависимость на фабрику WorkerFactory<WorkerN> workerNFactory. Теперь поправим и это, используя расширение multibindings. В частности, будем использовать MapBinder:
MapBinder — an API to bind multiple map entries separately, only to later inject them as a complete map.
MapBinder позволяет собрать все зависимости вместе в одну мапу, а потом ее разом заинжектить.

Подключаем расширение multibinings к проекту:

<dependency>
      <groupId>com.google.inject.extensions</groupId>
      <artifactId>guice-multibindings</artifactId>
      <version>4.2.0</version>
</dependency>

И сразу идем дописывать Module — в нем происходит вся магия. Для начала создадим MapBinder:

MapBinder<String, WorkerFactory> binder = MapBinder.newMapBinder(binder(), String.class, WorkerFactory.class);

Ничего особенного, просто указываем типы маппинга: ставим в соответствие параметру запроса типа String нужную фабрику WorkerFactory. Осталось реализовать сам маппинг.

Итак, Guice с нашей помощью уже создал фабрику для Worker:

 new TypeLiteral<WorkerFactory<Worker1>>(){}

Замапим аргумент на такой же объект. Для этого воспользуемся методами addBinding() и to(). Обратите внимание на наличие перегруженной версии метода, принимающей TypeLiteral. Так будет выглядеть модуль полностью:

public class Module extends AbstractModule{
    @Override
    protected void configure() {
        install(new FactoryModuleBuilder().implement(Worker.class, Worker1.class)
                .build(new TypeLiteral<WorkerFactory<Worker1>>() {}));

        install(new FactoryModuleBuilder().implement(Worker.class, Worker2.class)
                .build(new TypeLiteral<WorkerFactory<Worker2>>() {}));

        MapBinder<String, WorkerFactory> binder = MapBinder.newMapBinder(binder(), String.class, WorkerFactory.class);
        binder.addBinding("case1").to(new TypeLiteral<WorkerFactory<Worker1>>(){});
        binder.addBinding("case2").to(new TypeLiteral<WorkerFactory<Worker2>>(){});
    }
}

Все самое интересное уже произошло, осталось только получить Map с нужными нам объектами в RequestHandler:

@Singleton
public class RequestHandler {
    private Map<String, WorkerFactory> workerFactoryMap;

    @Inject
    public RequestHandler(Map<String, WorkerFactory> workerFactoryMap) {
        this.workerFactoryMap = workerFactoryMap;
    }

    public void handleRequest(Request request) {
        Worker worker = workerFactoryMap.get(request.parameter)
                .createWorker(request.argument);
        worker.doWork();
    }
}

Как видите, мы просто делаем @Inject мапы с зависимостями, а потом получаем нужную фабрику через метод get().

И всё! Теперь RequestHandler ответственен только за создание и запуск Worker, а весь маппинг перенесен в модуль. При появлении новых наследников Worker нужно будет добавить об этом информацию туда же, больше ничего не меняя.

Небольшое заключение


В целом скажу, что Guice меня приятно удивил за счет своей простоты и, как модно сейчас говорить, «низкого порога вхождения». Зачастую в простых приложениях можно вообще не писать конфигурацию и обойтись одной аннотацией @Inject. Для более подробного ознакомления читайте вики на github.
Share post

Comments 5

    +2
    Сразу вопрос по первому примеру. А зачем все эти вызовы worker.setService*? Мы же только что создали воркера уже передав ему все сервисы в конструктор.
      0
      Верно, переписывал код, не заметил. Конечно же, в конструкторе сервисы не передавались.
      Исправлю
      0

      Довольно сложное и магическое решение для тривиальной вобщем-то фабрики, хотя и по канонам guice, но можно ли назвать такое решение хорошим, понятным, расширяемым?


      Использование хэштаблицы с фабриками требует обертки для обработки ошибок, если условие создания воркеров вдруг поменяется (станет сложнее), то врядли биндер решит уже проблему и тогда придется придумывать уже что-то другое и наверное еще более сложное.


      В этом случае, я бы наверное стал использовать инжектор напрямую и явно написал бы фабрику, убрал бы дополнительный параметр из конструктора воркеров и заворачивал бы полученный из инжектора воркер в декоратор который вызывал бы внутри doWork(i).

        0
        Для каждого решения применим свой подход.
        Действительно, если поменяется логика создания воркеров, то нужно будет находить подход.
        В целом, то, что я здесь описал — это реальный случай из жизни, просто сильно упрощенный. И именно написанный в статье подход для решения проблемы оказался оптимальным, потому что Воркеры создаются именно так и логика не поменяется.
        Целью статьи было показать, что есть такие расширения, и что их можно использовать вместе. А то, что пример для их использования несколько «притянут за уши» — как по мне, это уже второстепенно
          +1

          Ну вот вы как раз тот человек, который не усложняет себе жизнь, что бы потом подобными постами оправдовать свой выбор :)
          Поддерживаю!

        Only users with full accounts can post comments. Log in, please.