Пользователь
0,0
рейтинг
20 ноября 2013 в 12:17

Разработка → Знакомимся с Dependency Injection на примере Dagger из песочницы

http://radiant--eclipse.deviantart.com/
В данной статье мы попытаемся разобраться с Dependency Injection в Android (и не только) на примере набирающей популярность open source библиотеки Dagger
И так, что же такое Dependency Injection? Согласно википедии, это design pattern, позволяющий динамически описывать зависимости в коде, разделяя бизнес-логику на более мелкие блоки. Это удобно в первую очередь тем, что впоследствии можно эти самые блоки подменять тестовыми, тем самым ограничивая зону тестирования.

Несмотря на замудреное определение, принцип довольно прост и тривиален. Я уверен, что большинство из программистов так или иначе реализовывали этот pattern, даже порой об этом не догадываясь.

Рассмотрим упрощенную (до псевдокода) версию Twitter клиента.

В теории, диаграмма зависимостей выглядит примерно так:


Давайте взглянем как это выглядит в коде:

public class Tweeter
{
	public void tweet(String tweet)
	{
		TwitterApi api = new TwitterApi();
		api.postTweet("Test User", tweet);
	}
}

public class TwitterApi
{
	public void postTweet(String user, String tweet)
	{
		HttpClient client = new OkHttpClient();
		HttpUrlConnection connection = client.open("....");
		/* post tweet */
	}
}




Как видим, набор интерфейсов довольно прост, поэтому использовать мы это будем примерно так:

Tweeter tweeter = new Tweeter();
tweeter.tweet("Hello world!");


Пока все идет хорошо, твиты улетают — все счастливы. Теперь возникает необходимость все это протестировать. Сразу же замечаем, что неплохо было бы иметь возможность подменять Http клиент на тестовый, чтобы возвращать какой-нибудь тестовый результат и не ломиться в сеть каждый раз. В этом случае, нам надо снять с класса TwitterApi обязанность создавать Http клиент и сгрузить эту обязанность вышестоящим классам. Наш код немного преображается:

public class Tweeter
{
	private TwitterApi api;
	public Tweeter(HttpClient httpClient)
	{
		this.api = new TwitterApi(httpClient);
	}

	public void tweet(String tweet)
	{
		api.postTweet("Test User", tweet);
	}
}

public class TwitterApi
{
	private HttpClient client;

	public TwitterApi(HttpClient client)
	{
		this.client = client;
	}

	public void postTweet(String user, String tweet)
	{
		HttpUrlConnection connection = client.open("....");
		/* post tweet */
	}
}


Теперь мы видим, что при необходимости простестировать наш код, мы можем легко «подставить» тестовый Http клиент, который будет возвращать тестовые результаты:
Tweeter tweeter = new Tweeter(new MockHttpClient);
tweeter.tweet("Hello world!");


Казалось бы, что может быть проще? На самом деле, сейчас мы «вручную» реализовали Dependency Injection паттерн. Но есть одно «но». Представим ситуацию, что у нас есть класс Timeline, который умеет загружать последние n сообщений. Этот класс тоже использует TwitterApi:



Timeline timeline = new Timeline(new OkHttpClient(), "Test User");
timeline.loadMore(20);
for(Tweet tweet: timeline.get())
{
	System.out.println(tweet);
}


Наш класс выглядит примерно так:

public class Timeline
{
	String user;
	TweeterApi api;
	public Timeline(HttpClient httpClient, String user)
	{
		this.user = user;
		this.api = new TweeterApi(httpClient);
	}

	public void loadMore(int n){/*.....*/}
	public List<Tweet> get(){/*.......*/}
}


Вроде бы все ничего — мы применили тот же подход, что и с классом Tweeter — дали возможность указывать Http клиент при создании объекта, что позволяет нам протестировать этот модуль, не завися при этом от сети. Но! Вы заметили, сколько кода мы продублировали и как нам приходится «протаскивать» Http клиент прямо из «головы» приложения? Конечно, можно добавить конструкторы по умолчанию, которые будут создавать реальный Http клиент, и использовать кастомный конструктор только при тестировании, но ведь это не решает проблему, а только маскирует ее.

Давайте рассмотрим как мы можем улучшить сложившуюся ситуацию.

Dagger



Dagger — это open source Dependency Injection библиотека от разработчиков okhttp, retrofit, picasso и многих других замечательных библиотек, известных многим Android разработчикам.

Главные преимущества Dagger (по сравнению с тем же Guice):
  • Статический анализ всех зависимостей
  • Определение ошибок конфигурации на этапе компиляции (не только в runtime)
  • Отсутствие reflection, что значительно ускоряет процесс конфигурации
  • Довольно небольшая нагрузка на память


В Dagger процесс конфигурации зависимостей разбит на 3 больших блока:
  • инициализация графа завизимостей (ObjectGraph)
  • запрос зависимостей (@Inject)
  • удовлетворение зависимостей (@Module/@Provides)


Запрос зависимостей (request dependency)


Чтобы попросить Dagger проиницализировать одно из полей, все что нужно сделать — добавить аннотацию @Inject:

@Inject
private HttpClient client;


… и убедиться, что этот класс добавлен в граф зависимостей (об этом далее)

Удовлетворение зависимостей (provide dependency)


Чтобы сказать даггеру какой инстанс клиента необходимо создать, необходимо создать «модуль» — класс аннотированный @Module:

@Module
public class NetworkModule{...}


Этот класс отвечает за «удовлетворение» части зависимостей, запрошенных приложением. В этом классе нужно создать так называемый «провайдер» — метод, который возвращает инстанс HttpClient (аннотированный @Provide):

@Module(injects=TwitterApi.class)
public class NetworkModule
{
	@Provides @Singleton
	HttpClient provideHttpClient()
	{
		return new OkHttpClient();
	}
}


Этим мы сказали Dagger'y, чтобы он создал OkHttpClient для любого, кто попросил HttpClient посредством @Inject аннотации

Стоит упомянуть, что для того, чтобы compile-time валидация работала, необходимо указать все классы (в параметре injects), которые просят эту зависимость. В нашем случае, HttpClient необходим только TwitterApi классу.
Аннотация @Singleton указывает Dagger'у, что необходимо создать только 1 инстанс клиента и закэшировать его.

Cоздание графа


Теперь перейдем к созданию графа. Для этого я создал класс Injector, который инициализирует граф одним (или более) модулем. В контексте Android приложения, удобней всего это делать при создании приложения (наследуемся от Application и перегружаем onCreate()). В данном примере, я создал TweeterApp клас, который содержит в себе остальные компоненты (Tweeter и Timeline)

public class Injector
{
	public static ObjectGraph graph;
	public static void init(Object... modules)
	{
		graph = ObjectGraph.create(modules);
	}

	public static void inject(Object target)
	{
		graph.inject(target);
	}
}

public class TweeterApp
{
	public static void main(String... args)
	{
		Injector.init(new NetworkModule());
		Tweeter tweeter = new Tweeter();
		tweeter.tweet("Hello world");
		Timeline timeline = new Timeline("Test User");
		timeline.loadMore(20);
		for(Tweet tweet: timeline.get())
		{
			System.out.println(tweet);
		}
	}
}


Теперь вернемся к запросу зависимостей:

public class TwitterApi
{
	@Inject
	private HttpClient client;

	public TwitterApi()
	{
        //Добавляем класс в граф зависимостей
		Injector.inject(this);
        //На этом этапе "магическим" образом client проинициализирован Dagger'ом
	}

	public void postTweet(String user, String tweet)
	{
		HttpUrlConnection connection = client.open("....");
		/* post tweet */
	}
}


Заметьте Injector.inject(Object). Это необходимо для того, чтобы добавить класс в граф зависимостей. Т.е. если у нас есть хотя бы один @Inject в классе — нам необходимо добавить этот класс к граф. В результате в нашем графе должны быть все классы, которые просят зависимости (каждый из этих классов должен сделать ObjectGraph.inject()) + модули, которые удовлетворяют эти зависимости (обычно добавляются на этапе инициалзации графа).

Теперь вернемся к нашей изначальной задаче — протестировать все. Нам необходимо каким-то образом уметь подменять HttStack. За удовлетворение этой зависимости (хмм — только сейчас заметил как это интересно звучит) отвечает модуль NetworkModule:
@Provides @Singleton
	HttpClient provideHttpClient()
	{
		return new OkHttpClient();
	}


Один из вариантов — это добавить какой-нибудь конфигурационный файл, который будет диктовать какой environment использовать:
@Provides @Singleton
	HttpClient provideHttpClient()
	{
		if(Config.isDebugMode())
		{
			return new MockHttpClient();
		}
		return new OkHttpClient();
	}


Но есть вариант еще элегантней. В Dagger можно создавать модули, переопределяющие функции, предоставляющие зависимости. Для этого в модуль надо добавить параметр overrides=true:
@Module(overrides=true, injects=TwitterApi.class)
public class MockNetworkModule
{
	@Provides @Singleton
	HttpClient provideHttpClient()
	{
		return new MockHttpClient();
	}
}


Все что остается сделать — это добавить этот модуль в граф на этапе инициализации:

public class TweeterApp
{
	public static void main(String... args)
	{
		Injector.init(new NetworkModule(), new MockNetworkModule());
		Tweeter tweeter = new Tweeter();
		tweeter.tweet("Hello world");
		Timeline timeline = new Timeline("Test User");
		timeline.loadMore(20);
		for(Tweet tweet: timeline.get())
		{
			System.out.println(tweet);
		}
	}
}


Теперь все наши запросы будут идти через тестовый Http клиент.

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

Вот что получилось в итоге (то же самое, что и выше, но собранное в кучу)
//Entry point нашей программы
public class TweeterApp
{
	public static void main(String... args)
	{
		Injector.init(new NetworkModule());
		Tweeter tweeter = new Tweeter();
		tweeter.tweet("Hello world");
		Timeline timeline = new Timeline("Test User");
		timeline.loadMore(20);
		for(Tweet tweet: timeline.get())
		{
			System.out.println(tweet);
		}
	}
}

//Инициализатор графа
public class Injector
{
	public static ObjectGraph graph;
	public static void init(Object... modules)
	{
		graph = ObjectGraph.create(modules);
	}

	public static void inject(Object target)
	{
		graph.inject(target);
	}
}

//Собственно, Tweeter (уже не принимающий HttpClient в конструкторе)
public class Tweeter
{
	private TwitterApi api;
	public Tweeter()
	{
		this.api = new TwitterApi();
	}

	public void tweet(String tweet)
	{
		api.postTweet("Test User", tweet);
	}
}

//TwitterApi, который запрашивает HttpClient у Dagger'a
public class TwitterApi
{
	@Inject
	private HttpClient client;

	public TwitterApi()
	{
        //Добавляем класс в граф зависимостей
		Injector.inject(this);
        //На этом этапе "магическим" образом client проинициализирован Dagger'ом
	}

	public void postTweet(String user, String tweet)
	{
		HttpUrlConnection connection = client.open("....");
		/* post tweet */
	}
}

//Модуль, который предоставляет HttpClient всем, кто об этом просил (список "просящих" указывается в 'injects' параметре)
@Module(injects=TwitterApi.class)
public class NetworkModule
{
	@Provides @Singleton
	HttpClient provideHttpClient()
	{
		return new OkHttpClient();
	}
}



Список полезных материалов по теме:

Pavel Dudka @paveldudka
карма
12,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (14)

  • 0
    Правильно ли я понимаю, что при вызове
    Tweeter tweeter = new Tweeter();
    будет каким-то образом вызван конструктор с параметром?
    • 0
      Не совсем. В случае с Dagger, мы убрали HttpClient из конструктора, поэтому у класса Tweeter остался только конструктор по умолчанию. Т.е. HttpClient указывается только в классе, которому он непосредственно необходим и нет необходимости «тянуть» его из родительских классов. Забыл добавить этот класс в вариант с Dagger — обновлю пост. Спасибо!
      • 0
        Получается, что реальное значение client в TwitterApi получит при вызове
        Injector.inject(this);
        в конструкторе?
        А что тогда значит this.client = client;?
        • 0
          Все верно, client инициализируется при inject'e. this.client = client — досадный копи-паст… Поправил. Спасибо еще раз!
  • +3
    Вот это:
        public TwitterApi()
        {
            //Добавляем класс в граф зависимостей
            Injector.inject(this);
            //На этом этапе "магическим" образом client проинициализирован Dagger'ом
        }
    

    очень нехорошо. Убирая зависимость на классы бизнес-логики, вы добавляете зависимость на класс инжектора. TwitterApi невозможно будет использовать отдельно от контейнера DI.

    Самым правильным способом, имхо, является использование внедрения через конструктор. Guice умеет так (да и Dagger, я думаю, тоже):

    public class TwitterApi {
        private final HttpClient client;
        
        @Inject
        public TwitterApi(HttpClient client) {
            this.client = client;
        }
    
        ...
    }
    

    и пусть теперь этот класс создаётся контейнером, когда потребуется его использование внутри проекта. Если же его нужно протестировать, в модульном тесте вы делаете просто new TwitterApi(mockClient), без всяких контейнеров. Использование контейнеров в модульных тестах, кстати, есть плохой тон.
    • 0
      Не очень хорошо понимаю философию DI, поэтому, возможно, глупый вопрос. Как быть, если в некотором месте мне нужно насоздавать произвольное количество объектов, классом которых я хочу управлять с помощью DI-контейнера? Ну, в данном случае пусть мне надо создать массив HttpClient'ов. И как быть, если эти объекты сами имеют внедряемые зависимости?
      • +3
        Без проблем. Внедрите не сам объект, а его провайдер (в терминологии Guice, в Spring, кажется, это называется Factory):
        public class TwitterApi {
            private final Provider<HttpClient> clientProvider;
            
            @Inject
            public TwitterApi(Provider<HttpClient> clientProvider) {
                this.clientProvider = clientProvider;
            }
        
            ...
        }
        

        и теперь можно использовать этот провайдер, чтобы насоздавать столько объектов, сколько нужно:
        List<HttpClient> httpClients = new ArrayList<>();
        for (int i = 0; i < 10; ++i) {
            httpClients.add(clientProvider.get());
        }
        


        Все зависимости при этом будут разрешены автоматически. Тут ещё от scope'а привязки зависит. Если HttpClient помечен как singleton, то контейнер сам позаботится, чтобы существовал только один экземпляр объекта, и провайдер всегда будет возвращать его. Если scope не указан, объекты будут создаваться на каждый вызов get().

        Есть и другие паттерны создания и внедрения объектов. Например, Guice позволяет создавать объекты таким образом, чтобы часть зависимостей объекта внедрялась, а часть передавалась из кода бизнес-логики (расширение AssistedInject). Можно делать провайдеры, которые могут выбрасывать исключения, чтобы затем обрабатывать эти исключения в коде бизнес-логики (расширение ThrowingProviders). Можно сделать привязки на множество реализаций одного интерфейса и внедрить их все сразу как Set или Map, что бывает полезно для плагинной архитектуры (расширение Multibindings). Не знаю, как с этим у Dagger, но Guice умеет всё вышеперечисленное, и это очень удобно.
        • 0
          Спасибо.
        • 0
          Интересно, в спринге вроде бы по умолчанию как раз синглтон-скоуп…
          Надо посмотреть наконец на этот Guice, а то все спринг да спринг :)
          • 0
            Да, в спринге по умолчанию все бины — синглтоны. Ну это в некотором роде соответствует ментальной модели спринговой XML-конфигурации, когда вы описываете компоненты-бины. В Guice нет понятия бинов, там привязки реализаций к интерфейсам, и там создание новых копий объектов при внедрении более логично.
      • +1
        Ну попробую со своей колокольни ответить, не претендую на 100% правильность. В спринге (с даггером не работал, не знаю) можно задать bean scope = prototype. И при каждом инжекте этого бина вы будете получать новый инстанс. Насколько я понял, вам это нужно? Вообще есть много всяких скоупов.
    • 0
      Все верно — в данном примере я показал Fields Injection. Есть еще Constructor Injection, о котором Вы говорите.
      На самом деле, Injector.inject() рано или поздно прийдется сделать, потому что для кого-то TwitterApi будет полем, а при FieldInjection необходимо добавлять себя в контейнер.
      Тем не менее, Ваш вариант явно лучше хотя бы тем, что TwitterApi модуль уже не зависит от контейнера. Опыта с DI у меня немного, поэтому на подобные грабли не наступал (пока :) )
      • 0
        Injector.inject() рано или поздно прийдется сделать, потому что для кого-то TwitterApi будет полем, а при FieldInjection необходимо добавлять себя в контейнер.

        Нет, не придётся. Нужно просто не делать TwitterApi внедряемым полем, и всё. Вы же контролируете ваш код, не так ли? :)
        Опыта с DI у меня немного, поэтому на подобные грабли не наступал (пока :) )

        Всё ещё впереди) я начинал со спринга, и очень долго не мог понять, что такое DI вообще и зачем нужен спринг в частности. Потом щёлкнуло, и сразу всё стало понятно)
  • 0
    хорошая презентация по DI от Jake Wharton тут

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.