Это перевод статьи Мартина Фаулера “Inversion of Control Containers and the Dependency Injection pattern
В сообществе Java наблюдается бурный рост числа легковесных контейнеров (lightweight containers), которые помогают собрать компоненты из разных проектов в целостное приложение. В основе этих контейнеров лежит общий шаблон того, как они выполняют прокидывание зависимостей, это концепция, которую разработчики называют очень общим именем Inversion of Control (IoC: инверсия контроля). В этой статье я углублюсь в то, как работает этот паттерн под более конкретным названием «Dependency Injection» (Инъекция зависимостей), и сравню его с альтернативой - Service Locator. Выбор между ними не так важен, как принцип разделения конфигурации и использования.
Содержание
Компоненты и Сервисы
Наивный пример
Инверсия управления
-
Формы Инъекции Зависимостей
Constructor Injection с помощью PicoContainer’а
Setter Injection с помощью Spring
Interface Injection
-
Использование Сервис Локатора
Использование Разделения Интерфейса для Локатора
Динамический Сервис Локатор
Использование Локатора и Инъекции с помощью Avalon
-
Выбор между различными вариантами
Service Locator vs Dependency Injection
Constructor против Setter injection
Код или конфигурационные файлы
Разделение Конфигурации от Использования
Некоторые дополнительные вопросы
Заключительные размышления
Одна из самых занятных вещей в мире корпоративной Java - это огромная активность в создании альтернатив основным технологиям J2EE, причем большая часть из них выпускается с открытым исходным кодом. Многое из этого является реакцией на высокий порог вхождения в основные J2EE-технологии, но многое также является исследованием альтернатив и воплощением креативных идей. Самая распространенная проблема, с которой приходится сталкиваться является соединение различных элементов: как совместить свою архитектуру веб-контроллера с чужим интерфейсом базы данных, если они были созданы разными командами, мало знакомыми друг с другом. Ряд фреймворков попытался решить эту проблему, и некоторые из них расширяются, чтобы предоставить решение для сборки компонентов из разных слоев. Их часто называют Легковесными Контейнерами, примерами могут служить PicoContainer и Spring.
В основе этих контейнеров лежит ряд интересных принципов проектирования, которые выходят за рамки как этих конкретных контейнеров, так и платформы Java. В этой статье я хочу провести обзор некоторых из этих принципов. Примеры, которые я использую, написаны на Java, но, как и большинство моих статей, эти принципы в равной степени применимы и к другим ОО-средам, в особенности к .NET.
Компоненты и сервисы
Тема связывания элементов друг с другом почти сразу же втягивает меня в запутанные проблемы терминологии, связанные с терминами «сервис» и «компонент». Вы легко найдете длинные и противоречивые статьи об их определении. Вот как я сейчас использую эти перегруженные термины для моих целей.
Я использую термин «компонент» для обозначения совокупности программ, предназначенных для использования неограниченным кругом лиц, без изменений. Под «без изменений» я подразумеваю, что использующее приложение не изменяет исходный код компонентов, хотя может изменить поведение компонента, расширив его способами, разрешенными авторами компонента.
Сервис похож на компонент в том, что его используют посторонние приложения. Основное различие заключается в том, что я ожидаю, что компонент будет использоваться локально (например, jar-файл, сборка, dll или импорт исходного кода). Сервис же будет использоваться удаленно через некоторый удаленный интерфейс, синхронный или асинхронный (например, веб-сервис, система обмена сообщениями, RPC или сокет).
В этой статье я в основном использую сервисы, но многое из той же логики можно применить и к локальным компонентам. Действительно, часто вам нужен какой-то фреймворк для локальных компонентов, чтобы легко получить доступ к удаленному сервису. Но писать «компонент или сервис» утомительно, а использовать сервисы сейчас гораздо моднее (хз что пришло в голову автору, чтобы это предложение родить).
Наивный пример
Чтобы было яснее, я использую пример, на котором буду рассказывать обо всем этом. Как и все мои примеры, это один из тех суперпростых примеров; достаточно маленький, чтобы быть нереальным, но, надеюсь, достаточный для того, чтобы вы могли представить себе что происходит, не слишком углубляясь в детали реального примера.
В этом примере я пишу компонент, который предоставляет список фильмов, снятых определенным режиссером. Эта потрясающе полезная функция реализуется единственным методом.
class MovieLister...
public Movie[] moviesDirectedBy(String arg) {
List allMovies = finder.findAll();
for (Iterator it = allMovies.iterator(); it.hasNext();) {
Movie movie = (Movie) it.next();
if (!movie.getDirector().equals(arg)) it.remove();
}
return (Movie[]) allMovies.toArray(new Movie[allMovies.size()]);
}
Реализация этой функции до крайности наивна: она запрашивает объект finder (о котором мы поговорим чуть позже), чтобы вернуть все фильмы, о которых он знает. Затем она просто проходится по списку, чтобы вернуть фильмы, снятые определенным режиссером. Этот прекрасный наивный пример я не собираюсь исправлять, поскольку он является лишь подмостком для настоящей сути этой статьи.
На самом деле суть этой статьи заключается в объекте finder, или, в частности, в том, как мы связываем объект lister с конкретным объектом finder. Причина, по которой это интересно, заключается в том, что я хочу, чтобы мой замечательный метод moviesDirectedBy
был полностью независим от того, как хранятся все фильмы. Поэтому все, что делает метод, - это ссылается на finder, а все, что делает finder, - это знает, как реагировать на метод findAll
. Я могу реализовать это, определив интерфейс для finder.
public interface MovieFinder {
List findAll();
}
Теперь все это очень хорошо разнесено, но в какой-то момент мне нужно создать конкретный класс, чтобы на самом деле получать фильмы. В данном случае я поместил код для этого в конструктор моего класса lister.
class MovieLister...
private MovieFinder finder;
public MovieLister() {
finder = new ColonDelimitedMovieFinder("movies1.txt");
}
Название конкретного класса ColonDelimitedMovieFinder
обусловлено тем, что я получаю список из текстового файла с двоеточием в качеством разделителя. Я избавлю вас от подробностей, суть в том, что все-таки какая-то реализация существует.
Если только я использую этот класс, то все хорошо и замечательно. Но что случится, если мои друзья переполнятся желанием использовать эту замечательную функциональность и захотят получить копию моей программы? Если они тоже хранят свои списки фильмов в текстовом файле с разделителем двоеточий под названием «movies1.txt», то все замечательно. Если у них есть другое имя для файла с фильмами, то легко указать имя файла в файле свойств. Но что, если у них совершенно другой подход к хранению списка фильмов: база данных SQL, файл XML, веб-служба или просто текстовый файл другого формата? В этом случае нам нужен другой класс, чтобы получить эти данные. Поскольку я определил интерфейс MovieFinder
, это не изменит мой метод moviesDirectedBy
. Но мне все равно нужно каким-то образом получить экземпляр нужной реализации интерфейса MovieFinder
.
На рисунке 1 показаны зависимости для этой ситуации. Класс MovieLister зависит как от интерфейса MovieFinder, так и от его реализации. Мы бы предпочли, чтобы он зависел только от интерфейса, но тогда как мы создадим экземпляр для работы?
В моей книге P of EAA мы описали эту ситуацию как Plugin. Класс реализации для искателя не подключается к программе во время компиляции, поскольку я не знаю, что будут использовать мои друзья. Вместо этого мы хотим, чтобы мой lister работал с любой реализацией, и чтобы эта реализация была подключена позже, не моими руками. Вопрос в том, как сделать эту связь такой, чтобы мой класс lister не знал о классе реализации, но при этом мог обращаться к экземпляру для выполнения своей работы.
Если перенести это на реальную систему, то у нас могут быть десятки подобных сервисов и компонентов. В каждом случае мы можем абстрагироваться от использования этих компонентов, обращаясь к ним через интерфейс (и используя адаптер, если компонент не был разработан с учетом интерфейса). Но если мы хотим развернуть эту систему разными способами, нам нужно использовать плагины для обработки взаимодействия с этими сервисами, чтобы мы могли использовать разные реализации в разных средах развертывания.
Итак, основная проблема заключается в том, как собрать эти плагины в приложение? Это одна из главных проблем, с которой сталкивается новые разновидности легковесных контейнеров, и все они решают ее с помощью инверсии управления.
Инверсия управления
Когда о контейнерах говорят, что они так полезны, потому что в них реализована «инверсия управления», я прихожу в недоумение. Инверсия управления - это общая характеристика фреймворков, поэтому говорить, что эти легковесные контейнеры особенные, потому что используют инверсию контроля, все равно что говорить, что моя машина особенная, потому что у нее есть колеса.
Вопрос в том, какой аспект управления они инвертируют? Когда я впервые столкнулся с инверсией управления, это было в основном управлении пользовательским интерфейсом. Ранние пользовательские интерфейсы управлялись прикладной программой. У вас была последовательность команд типа «введите имя», «введите адрес»; ваша программа управляла подсказками и получала ответ на каждую из них. В графических (или даже экранных) пользовательских интерфейсах UI-фреймворк содержит этот основной сценарий, а ваша программа вместо этого предоставляет обработчики событий для различных полей на экране. Контроль над программой был инвертирован, перенесен с вас на фреймворк.
Для этой новой разновидности контейнеров инверсия заключается в том, как они ищут реализацию плагина. В моем наивном примере lister искал реализацию finder, непосредственно создавая ее. Это не позволяет finder быть плагином. Подход, который используют эти контейнеры, заключается в том, чтобы гарантировать, что любой пользователь плагина следует некоторому соглашению, которое позволяет отдельному модулю сборщика внедрить реализацию в lister.
В результате я думаю, что нам нужно более конкретное название для этого паттерна. Инверсия контроля - слишком общий термин, и поэтому люди находят его запутанным. В результате после долгих обсуждений с различными сторонниками IoC мы остановились на названии Dependency Injection.
Я собираюсь начать с рассказа о различных формах инъекции зависимостей, но уже сейчас отмечу, что это не единственный способ снять зависимость с класса приложения на реализацию плагина. Другой паттерн, который вы можете использовать для этого, - Service Locator, и я расскажу о нем после того, как закончу объяснять Dependency Injection.
Формы Инъекции Зависимостей
Основная идея инъекции зависимостей заключается в том, чтобы иметь отдельный объект, сборщик, который заполняет поле в классе lister соответствующей реализацией интерфейса finder, в результате чего диаграмма зависимостей выглядит так, как показано на рисунке 2.
Существует три основных стиля инъекции зависимостей. Я использую для них названия Constructor Injection, Setter Injection и Interface Injection. Если вы читаете об этих вещах в текущих дискуссиях об инверсии контроля, вы услышите, что они называются IoC первого типа (инъекция интерфейса), IoC второго типа (инъекция сеттера) и IoC третьего типа (инъекция конструктора). Я нахожу числовые названия довольно трудными для запоминания, поэтому использую те названия, которые привел здесь.
Constructor Injection с помощью PicoContainer
Я начну с того, что покажу, как эта инъекция выполняется с помощью легковесного контейнера под названием PicoContainer. Я начинаю с этого контейнера прежде всего потому, что несколько моих коллег в Thoughtworks принимают активное участие в разработке PicoContainer (да, это своего рода корпоративное кумовство).
PicoContainer использует конструктор, чтобы решить, как внедрить реализацию finder в класс lister. Чтобы это работало, класс movie lister должен объявить конструктор, который включает все, что ему нужно внедрить.
class MovieLister...
public MovieLister(MovieFinder finder) {
this.finder = finder;
}
Сам finder также будет управляться контейнером pico, и, как таковой, будет иметь имя текстового файла, введенное в него контейнером.
class ColonMovieFinder...
public ColonMovieFinder(String filename) {
this.filename = filename;
}
Затем контейнеру pico нужно указать, какой класс реализации связать с каждым интерфейсом и какую строку подставить в finder.
private MutablePicoContainer configureContainer() {
MutablePicoContainer pico = new DefaultPicoContainer();
Parameter[] finderParams = {new ConstantParameter("movies1.txt")};
pico.registerComponentImplementation(MovieFinder.class, ColonMovieFinder.class, finderParams);
pico.registerComponentImplementation(MovieLister.class);
return pico;
}
Этот код конфигурации обычно задается в другом классе. В нашем примере каждый друг, использующий мой lister, может написать соответствующий код конфигурации в каком-то собственном классе настроек. Конечно, обычно такая информация о конфигурации хранится в отдельных файлах конфигурации. Вы можете написать класс, который будет читать файл конфигурации и настраивать контейнер соответствующим образом. Хотя PicoContainer сам по себе не содержит такой функциональности, существует тесно связанный с ним проект NanoContainer, который предоставляет соответствующие обертки, позволяющие вам иметь XML-файлы конфигурации. Такой наноконтейнер будет анализировать XML и затем конфигурировать нижележащий PicoContainer. Философия проекта заключается в том, чтобы отделить формат файла конфигурации от базового механизма.
Чтобы использовать контейнер, вы пишете код примерно следующего содержания.
public void testWithPico() {
MutablePicoContainer pico = configureContainer();
MovieLister lister = (MovieLister) pico.getComponentInstance(MovieLister.class);
Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
assertEquals("Once Upon a Time in the West", movies[0].getTitle());
}
Хотя в этом примере я использовал инъекцию конструктора, PicoContainer также поддерживает инъекцию сеттера, хотя его разработчики предпочитают инъекцию конструктора.
Setter Injection с помощью Spring
Фреймворк Spring - это широкомасштабная платформа для разработки корпоративных Java-приложений. Он включает в себя слои абстракции для транзакций, фреймворки персистентности, разработку веб-приложений и JDBC. Как и PicoContainer, он поддерживает инъекцию конструктора и сеттера, но его разработчики, как правило, предпочитают инъекцию сеттера, что делает его подходящим выбором для этого примера.
Чтобы заставить мой MovieLister принять инъекцию, я определяю setter для этой службы.
public void testWithPico() {
MutablePicoContainer pico = configureContainer();
MovieLister lister = (MovieLister) pico.getComponentInstance(MovieLister.class);
Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
assertEquals("Once Upon a Time in the West", movies[0].getTitle());
}
Точно так же я определю setter для поля filename.
class ColonMovieFinder...
public void setFilename(String filename) {
this.filename = filename;
}
Третий шаг - это настройка конфигурации для файлов. Spring поддерживает конфигурацию через XML-файлы, а также через код, но предпочтительным способом является XML.
<beans>
<bean id="MovieLister" class="spring.MovieLister">
<property name="finder">
<ref local="MovieFinder"/>
</property>
</bean>
<bean id="MovieFinder" class="spring.ColonMovieFinder">
<property name="filename">
<value>movies1.txt</value>
</property>
</bean>
</beans>
Тест выглядит следующим образом.
public void testWithSpring() throws Exception {
ApplicationContext ctx = new FileSystemXmlApplicationContext("spring.xml");
MovieLister lister = (MovieLister) ctx.getBean("MovieLister");
Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
assertEquals("Once Upon a Time in the West", movies[0].getTitle());
}
Interface Injection
Третья техника инъекций - это определение и использование интерфейсов для инъекций. Avalon - это пример фреймворка, который местами использует эту технику. Я расскажу об этом немного позже, но в данном случае я собираюсь использовать ее в простом примере кода.
При использовании этой техники я начинаю с определения интерфейса, через который я буду выполнять инъекцию. Вот интерфейс для инъекции киноискателя в объект.
public interface InjectFinder {
void injectFinder(MovieFinder finder);
}
Этот интерфейс должен быть определен тем, кто предоставляет интерфейс MovieFinder. Он должен быть реализован любым классом, который хочет использовать искатель, например, списком.
class MovieLister implements InjectFinder
public void injectFinder(MovieFinder finder) {
this.finder = finder;
}
Я использую аналогичный подход для инъекции имени файла в реализацию finder.
public interface InjectFinderFilename {
void injectFilename(String filename);
}
class ColonMovieFinder implements MovieFinder, InjectFinderFilename...
public void injectFilename(String filename) {
this.filename = filename;
}
Затем, как обычно, мне понадобится код конфигурации для подключения реализаций. Для простоты я сделаю это в коде.
class Tester...
private Container container;
private void configureContainer() {
container = new Container();
registerComponents();
registerInjectors();
container.start();
}
Эта конфигурация состоит из двух этапов, регистрация компонентов через ключи поиска довольно похожа на другие примеры.
class Tester...
private void registerComponents() {
container.registerComponent("MovieLister", MovieLister.class);
container.registerComponent("MovieFinder", ColonMovieFinder.class);
}
Новый шаг - регистрация инжекторов, которые будут внедрять зависимые компоненты. Каждый интерфейс инжектора нуждается в некотором коде для инжекции зависимого объекта. Здесь я делаю это, регистрируя объекты-инжекторы в контейнере. Каждый объект-инжектор реализует интерфейс инжектора.
class Tester...
private void registerInjectors() {
container.registerInjector(InjectFinder.class, container.lookup("MovieFinder"));
container.registerInjector(InjectFinderFilename.class, new FinderFilenameInjector());
}
public interface Injector {
public void inject(Object target);
}
Если зависимым является класс, написанный для этого контейнера, имеет смысл, чтобы компонент сам реализовал интерфейс инжектора, как это сделано в случае с movie finder. Для общих классов, таких как строка, я использую внутренний класс в коде конфигурации.
class ColonMovieFinder implements Injector...
public void inject(Object target) {
((InjectFinder) target).injectFinder(this);
}
class Tester...
public static class FinderFilenameInjector implements Injector {
public void inject(Object target) {
((InjectFinderFilename)target).injectFilename("movies1.txt");
}
}
Затем используем контейнер в тестах.
class Tester…
public void testIface() {
configureContainer();
MovieLister lister = (MovieLister)container.lookup("MovieLister");
Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
assertEquals("Once Upon a Time in the West", movies[0].getTitle());
}
Контейнер использует объявленные интерфейсы инъекции для определения зависимостей, а инжекторы - для инъекции нужных зависимостей. (Конкретная реализация контейнера, которую я сделал здесь, не важна для техники, и я не буду ее показывать, потому что вы только посмеетесь).
Использование Сервис Локатора
Основное преимущество паттерна Инъекции Зависимостей заключается в том, что он устраняет зависимость класса MovieLister от конкретной реализации MovieFinder. Это позволяет мне давать lister’ы друзьям, а они могут подключить подходящую реализацию для своего окружения. Инъекция - не единственный способ избавиться от этой зависимости, еще один - использовать Сервис Локатор.
Основная идея Сервис Локатора заключается в том, чтобы иметь объект, который знает, как получить все сервисы, которые могут понадобиться приложению. Таким образом, Сервис Локатор для этого приложения будет иметь метод, который возвращает поиск фильмов, когда это необходимо. Конечно, это только немного перекладывает ответственность, но нам все еще нужно доставить локатор в lister, что приводит к зависимостям на Рисунке 3.
В данном случае я буду использовать ServiceLocator как синглтон Registry. Затем lister может использовать его для получения finder’а при создании.
class MovieLister...
MovieFinder finder = ServiceLocator.movieFinder();
class ServiceLocator...
public static MovieFinder movieFinder() {
return soleInstance.movieFinder;
}
private static ServiceLocator soleInstance;
private MovieFinder movieFinder;
Как и в случае с инъекционным подходом, нам нужно настроить Сервис Локатор. Здесь я делаю это в коде, но несложно использовать механизм, который считывал бы соответствующие данные из файла конфигурации.
class Tester...
private void configure() {
ServiceLocator.load(new ServiceLocator(new ColonMovieFinder("movies1.txt")));
}
class ServiceLocator...
public static void load(ServiceLocator arg) {
soleInstance = arg;
}
public ServiceLocator(MovieFinder movieFinder) {
this.movieFinder = movieFinder;
}
Это код для тестирования.
class Tester...
public void testSimple() {
configure();
MovieLister lister = new MovieLister();
Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
assertEquals("Once Upon a Time in the West", movies[0].getTitle());
}
Мне часто приходилось слышать жалобу на то, что подобные Сервис Локаторы - это плохо, потому что они не поддаются тестированию, так как вы не можете подменить их реализацию. Конечно, их можно плохо спроектировать и попасть в такую ситуацию, но это не обязательно. В данном случае экземпляр локатора сервиса - это простой держатель данных. Я могу легко создать локатор с тестовыми реализациями моих сервисов.
Для создания более сложного локатора я могу создать подкласс service locator и передать этот подкласс в переменную класса registry. Я могу изменить статические методы так, чтобы они вызывали метод экземпляра, а не обращались к переменным экземпляра напрямую. Я могу обеспечить локаторы для конкретных потоков, используя хранилище для конкретных потоков. Все это можно сделать, не меняя потребителей Сервис Локатора.
Можно подумать, что сервисный локатор - это реестр, а не синглтон. Синглтон обеспечивает простой способ реализации реестра, но это решение по реализации легко изменить
Использование Отдельного Интерфейса для Локатора
Одна из проблем простого подхода, описанного выше, заключается в том, что MovieLister зависит от класса локатора полного сервиса, хотя использует только один сервис. Мы можем уменьшить эту зависимость, используя ролевой интерфейс. Таким образом, вместо использования полного интерфейса локатора сервиса, листер может объявить только ту часть интерфейса, которая ему нужна.
В этой ситуации поставщик lister’а также предоставит интерфейс локатора, который нужен ему для получения информации о находке.
public interface MovieFinderLocator {
public MovieFinder movieFinder();
Затем локатор должен реализовать этот интерфейс, чтобы обеспечить доступ к поисковой системе.
MovieFinderLocator locator = ServiceLocator.locator();
MovieFinder finder = locator.movieFinder();
public static ServiceLocator locator() {
return soleInstance;
}
public MovieFinder movieFinder() {
return movieFinder;
}
private static ServiceLocator soleInstance;
private MovieFinder movieFinder;
Вы заметите, что, поскольку мы хотим использовать интерфейс, мы больше не можем просто получить доступ к сервисам через статические методы. Мы должны использовать класс, чтобы получить экземпляр локатора, а затем использовать его, чтобы получить то, что нам нужно.
Динамический Сервис Локатор
Приведенный выше пример был статическим, в котором класс локатора сервисов содержит методы для каждого из необходимых вам сервисов. Это не единственный способ сделать это, вы также можете сделать динамический локатор сервисов, который позволит вам хранить в нем любой сервис, который вам нужен, и делать выбор во время выполнения.
В этом случае Сервис Локатор использует Map вместо полей для каждого из сервисов и предоставляет generic-методы для получения и загрузки сервисов.
class ServiceLocator...
private static ServiceLocator soleInstance;
public static void load(ServiceLocator arg) {
soleInstance = arg;
}
private Map services = new HashMap();
public static Object getService(String key){
return soleInstance.services.get(key);
}
public void loadService(String key, Object service) {
services.put(key, service);
}
Конфигурация заключается в загрузке службы с соответствующим ключом.
class Tester...
private void configure() {
ServiceLocator locator = new ServiceLocator();
locator.loadService("MovieFinder", new ColonMovieFinder("movies1.txt"));
ServiceLocator.load(locator);
}
Я пользуюсь сервисом, используя ту же строку ключей.
class MovieLister...
MovieFinder finder = (MovieFinder) ServiceLocator.getService("MovieFinder");
В целом мне не нравится такой подход. Хотя он, конечно, гибкий, но не очень явный. Единственный способ узнать, как обратиться к сервису, - это текстовые ключи. Я предпочитаю явные методы, потому что проще найти, где они находятся, посмотрев определения интерфейсов.
Использование Локатора и Инъекции в Avalon
Инъекция зависимостей и Сервис Локатор - не обязательно взаимоисключающие понятия. Хорошим примером их совместного использования является фреймворк Avalon. Avalon использует локатор сервисов, но с помощью инъекций указывает компонентам, где искать локатор.
Берин Лорич прислал мне эту простую версию моего примера с использованием Avalon.
public class MyMovieLister implements MovieLister, Serviceable {
private MovieFinder finder;
public void service( ServiceManager manager ) throws ServiceException {
finder = (MovieFinder)manager.lookup("finder");
}
Сервисный метод - это пример инъекции интерфейса, позволяющий контейнеру внедрить ServiceManager в MyMovieLister. ServiceManager - это пример Сервис Локатора. В этом примере листер не хранит менеджер в поле, а сразу использует его для поиска finder’а, который он хранит
Выбор между различными вариантами
До сих пор я концентрировался на объяснении того, как я вижу эти паттерны и их вариации. Теперь я могу начать говорить об их плюсах и минусах, чтобы помочь понять, какие из них когда использовать.
Service Locator vs Dependency Injection
Основной выбор - между Service Locator и Dependency Injection. Первый момент заключается в том, что обе реализации обеспечивают фундаментальное разделение, отсутствующее в наивном примере - в обоих случаях код приложения не зависит от конкретной реализации интерфейса сервиса. Важное различие между двумя паттернами заключается в том, как эта реализация предоставляется классу приложения. При использовании Сервис Локатора прикладной класс запрашивает его явно, отправляя сообщение локатору. При инъекции явного запроса нет, сервис появляется в классе приложения - отсюда и инверсия контроля.
Инверсия контроля - распространенная особенность фреймворков, но за нее приходится платить. Как правило, она сложна для понимания и приводит к проблемам при попытке отладки. Поэтому в целом я предпочитаю избегать ее, если она мне не нужна. Это не значит, что это плохо, просто я считаю, что это должно быть оправдано по сравнению с более простой альтернативой.
Ключевое отличие заключается в том, что при использовании Сервис Локатора каждый пользователь сервиса имеет зависимость от локатора. Локатор может скрывать зависимости от других реализаций, но вы должны видеть локатор. Поэтому решение между локатором и инъектором зависит от того, является ли эта зависимость проблемой.
Использование Инъекции Зависимостей может облегчить просмотр зависимостей компонентов. С помощью Инъекции Зависимостей вы можете просто посмотреть на механизм инъекции, например конструктор, и увидеть зависимости. С Сервис Локатором вам придется искать вызовы локатора в исходном коде. Современные IDE с функцией поиска ссылок облегчают эту задачу, но это все равно не так просто, как посмотреть на конструктор или сеттер-методы.
Многое здесь зависит от природы потребителя сервиса. Если вы создаете приложение с различными классами, использующими сервис, то зависимость от классов приложения к локатору не является большой проблемой. В моем примере с предоставлением Movie Lister моим друзьям использование локатора сервиса работает достаточно хорошо. Все, что им нужно сделать, - это настроить локатор так, чтобы он подключал нужные реализации сервисов, либо через код конфигурации, либо через файл конфигурации. В таком сценарии я не вижу в инверсии инжектора ничего интересного.
Разница возникает, если lister - это компонент, который я предоставляю приложению, которое пишут другие люди. В этом случае я ничего не знаю об API Сервис Локатора, который будут использовать мои клиенты. У каждого клиента могут быть свои несовместимые Сервис Локаторы. Я могу обойти некоторые из этих проблем, используя Segregated Interface. Каждый клиент может написать адаптер, который согласует мой интерфейс с его локатором, но в любом случае мне все равно нужно увидеть первый локатор, чтобы найти мой интерфейс. И как только появляется адаптер, простота прямого подключения к локатору начинает ускользать.
Поскольку при использовании инъекции у вас нет зависимости от компонента к инъектору, компонент не может получить новые сервисы от инъектора после того, как он был настроен.
Часто люди объясняют, почему они предпочитают инъекции зависимостей, тем, что это облегчает тестирование. Дело в том, что для тестирования вам нужно легко подменять реальные реализации сервисов на заглушки или моки. Однако на самом деле нет никакой разницы между Инъекцией Зависимостей и Сервис Локатором: и то, и другое очень удобно для заглушек. Я подозреваю, что это наблюдение происходит из проектов, где люди не прилагают усилий, чтобы убедиться, что их Сервис Локатор можно легко заменить. Именно здесь помогает постоянное тестирование: если вы не можете легко подменить сервисы для тестирования, то это означает серьезную проблему в вашей архитектуре.
Конечно, проблема тестирования усугубляется средой компонентов, которые очень навязчивы, такими как фреймворк EJB в Java. Я считаю, что подобные фреймворки должны минимизировать свое влияние на код приложения и особенно не должны замедлять цикл редактирования-выполнения. Использование плагинов для подмены тяжеловесных компонентов значительно облегчает этот процесс, что очень важно для таких практик, как Test Driven Development.
Таким образом, основная проблема возникает у тех, кто пишет код, который предполагается использовать в приложениях, предназначенных для внешних потребителей. В этих случаях даже минимальное допущение Сервис Локатора может стать проблемой.
Constructor против Setter Injection
Для комбинирования сервисов всегда нужны какие-то соглашения, чтобы соединить все вместе. Преимущество Инъекции заключается прежде всего в том, что она требует очень простых соглашений - по крайней мере, для инъекции конструктора и сеттера. Вам не нужно делать ничего странного в своем компоненте, и Инъектору достаточно просто все настроить.
Инъекция интерфейсов более проникающая, поскольку вам придется написать множество интерфейсов, чтобы разобраться со всем этим. Для небольшого набора интерфейсов, необходимых контейнеру, как, например, в подходе Avalon, это не так уж и плохо. Но для сборки компонентов и зависимостей это большая работа, поэтому современные легковесные контейнеры используют инъекцию с помощью сеттеров и конструкторов.
Выбор между инъекцией сеттера и конструктора интересен тем, что он отражает более общую проблему объектно-ориентированного программирования - заполнять ли поля в конструкторе или с помощью сеттеров.
Мое давнее правило при работе с объектами - по возможности создавать корректные объекты во время конструирования. Этот совет восходит к книге Кента Бека Smalltalk Best Practice Patterns: Метод конструктора и Метод параметра конструктора. Конструкторы с параметрами дают вам четкое представление о том, что значит создать допустимый объект в очевидном месте. Если есть несколько способов сделать это, создайте несколько конструкторов, которые показывают различные комбинации.
Еще одно преимущество инициализации через конструктор заключается в том, что она позволяет явно скрыть любые поля, которые являются неизменяемыми, просто не предоставляя сеттер. Я считаю это важным - если что-то не должно меняться, то отсутствие сеттера очень хорошо об этом говорит. Если вы используете сеттеры для инициализации, то это может стать проблемой. (На самом деле в таких ситуациях я предпочитаю избегать обычного соглашения о сеттерах, я бы предпочел метод типа initFoo, чтобы подчеркнуть, что это то, что вы должны делать только при инициализации).
Но в любой ситуации есть исключения. Если у вас много параметров конструктора, все может выглядеть запутанно, особенно в языках без ключевых слов параметров. Правда, длинный конструктор часто является признаком перегруженного объекта, который следует разделить, но бывают случаи, когда это именно то, что вам нужно.
Если у вас есть несколько способов создать правильный объект, то показать это через конструкторы может быть сложно, поскольку конструкторы могут различаться только количеством и типом параметров. Именно тогда в игру вступают фабричные методы, которые могут использовать комбинацию приватных конструкторов и сеттеров для реализации своей работы. Проблема с классическими фабричными методами для сборки компонентов заключается в том, что они обычно рассматриваются как статические методы, а их нельзя использовать в интерфейсах. Вы можете создать фабричный класс, но тогда он просто станет еще одним экземпляром сервиса. Сервис-фабрика часто является хорошей тактикой, но вам все равно придется инстанцировать фабрику, используя одну из приведенных здесь техник.
Конструкторы также страдают, если у вас есть простые параметры, такие как строки. С помощью инъекции сеттера вы можете дать каждому сеттеру имя, указывающее на то, что должна делать строка. В конструкторах же вы просто полагаетесь на позицию, за которой сложнее уследить.
Если у вас несколько конструкторов и наследование, то ситуация может стать особенно неудобной. Чтобы инициализировать все, вы должны предоставить конструкторы для передачи каждому конструктору суперкласса, а также добавить свои собственные аргументы. Это может привести к еще большему росту числа конструкторов.
Несмотря на все недостатки, я предпочитаю начинать с инъекции через конструктор, но быть готовым перейти на инъекцию сеттеров, как только проблемы, описанные выше, начнут становиться проблемой.
Этот вопрос вызвал много споров между различными командами, которые предоставляют Инъекторы Зависимостей как часть своих фреймворков. Однако, похоже, большинство людей, создающих эти фреймворки, поняли, что важно поддерживать оба механизма, даже если предпочтение отдается одному из них.
Код или Конфигурационные файлы
Отдельный, но часто сопутствующий вопрос - использовать ли файлы конфигурации или код API для подключения сервисов. Для большинства приложений, которые, вероятно, будут развернуты во многих местах, отдельный файл конфигурации обычно имеет наибольший смысл. Почти всегда это будет XML-файл, и это имеет смысл. Однако бывают случаи, когда проще использовать программный код для сборки. Например, если у вас есть простое приложение, которое не имеет большого количества вариаций развертывания. В этом случае немного кода может быть понятнее, чем отдельный XML-файл.
Противоположный случай - когда сборка довольно сложная, включающая условные шаги. Как только вы начинаете приближаться к языку программирования, XML начинает ломаться, и лучше использовать настоящий язык, в котором есть весь синтаксис для написания понятной программы. Затем вы пишете класс сборщика, который выполняет сборку. Если у вас есть разные сценарии сборки, вы можете предоставить несколько классов сборщиков и использовать простой файл конфигурации для выбора между ними.
Мне часто кажется, что люди слишком усердствуют с определением конфигурационных файлов. Часто язык программирования представляет собой простой и мощный механизм конфигурирования. Современные языки могут легко компилировать небольшие сборщики, которые можно использовать для сборки плагинов для больших систем. Если компиляция доставляет неудобства, есть скриптовые языки, которые также могут работать хорошо.
Часто говорят, что конфигурационные файлы не должны использовать язык программирования, потому что их должны редактировать непрограммисты. Но насколько это соответствует действительности? Неужели люди действительно ожидают, что непрограммисты будут изменять уровни изоляции транзакций в сложном серверном приложении? Неязыковые конфигурационные файлы работают хорошо только в той степени, в какой они просты. Если они становятся сложными, то пора задуматься об использовании подходящего языка программирования.
В настоящее время в мире Java мы наблюдаем какофонию конфигурационных файлов, когда каждый компонент имеет свои собственные конфигурационные файлы, отличные от всех остальных. Если вы используете дюжину таких компонентов, вы легко можете получить дюжину конфигурационных файлов, которые необходимо синхронизировать.
Мой совет - всегда предоставляйте возможность легко выполнять все настройки с помощью программного интерфейса, а отдельный конфигурационный файл рассматривайте как необязательную фичу. Вы можете легко построить обработку конфигурационного файла для использования программного интерфейса. Если вы пишете компонент, то оставляете за пользователем право решать, использовать ли ему программный интерфейс, ваш формат конфигурационного файла или написать свой собственный формат конфигурационного файла и связать его с программным интерфейсом.
Разделение конфигурации от Использования
Важным моментом во всем этом является обеспечение того, чтобы конфигурация сервисов была отделена от их использования. На самом деле это фундаментальный принцип проектирования, который стоит в одном ряду с разделением интерфейсов и реализации. Это то, что мы видим в объектно-ориентированных программах, когда условная логика решает, какой класс инстанцировать, а затем будущие оценки этого условия выполняются через полиморфизм, а не через дублирование условного кода.
Если такое разделение полезно в рамках одной кодовой базы, то оно особенно важно при использовании сторонних элементов, таких как компоненты и сервисы. Первый вопрос заключается в том, хотите ли вы отложить выбор класса реализации до конкретных сред использования. Если да, то вам нужно использовать какую-то реализацию плагина. Если вы используете плагины, то очень важно, чтобы сборка плагинов выполнялась отдельно от остальной части приложения, чтобы вы могли легко заменять различные конфигурации для разных сред использования. То, как вы этого добьетесь, имеет второстепенное значение. Этот механизм конфигурации может либо конфигурировать Сервис Локатор, либо использовать инъекции для непосредственной конфигурации объектов.
Некоторые сопутствующие вопросы
В этой статье я сосредоточился на основных вопросах настройки сервисов с помощью Инъекции Зависимостей и Сервис Локатора. Есть еще несколько тем, которые также заслуживают внимания, но у меня пока не было времени на их изучение. В частности, есть вопрос поведения жизненного цикла. У некоторых компонентов есть отдельные события жизненного цикла: например, остановка и запуск. Другой вопрос - растущий интерес к использованию аспектно-ориентированных идей с этими контейнерами. Хотя на данный момент я не рассматривал этот материал в статье, я надеюсь написать об этом больше, либо расширив эту статью, либо написав другую.
Вы можете узнать гораздо больше об этих идеях, заглянув на сайты, посвященные легким контейнерам. На сайтах PicoContainer и Spring, вы сможете получить гораздо больше информации по этим вопросам и начать работу над некоторыми сопутствующими вопросами.
Заключительные размышления
В настоящее время все легковесные контейнеры имеют общий базовый паттерн для сборки сервисов - паттерн Инъекции Зависимостей. Dependency Injection - полезная альтернатива Service Locator. При создании классов приложений эти два паттерна примерно эквивалентны, но я думаю, что Service Locator имеет небольшое преимущество из-за своего более простого поведения. Однако если вы создаете классы, которые будут использоваться в нескольких приложениях, то Dependency Injection будет лучшим выбором.
Если вы используете Инъекцию Зависимостей, то существует несколько стилей, между которыми можно выбирать. Я бы посоветовал вам следовать инъекции конструктора, если только вы не столкнетесь с одной из специфических проблем, связанных с этим подходом, в этом случае переключитесь на инъекцию через сеттер. Если вы решили создать или получить контейнер, ищите тот, который поддерживает как конструкторную, так и сеттерную инъекцию.
Выбор между Сервис Локатором и Инъекцией зависимостей менее важен, чем принцип разделения конфигурации сервисов и их использования в приложении.
Благодарности
Я искренне благодарю многих людей, которые помогли мне в работе над этой статьей. Род Джонсон, Пол Хэммант, Джо Уолнес, Аслак Хеллесёй, Йон Тирсен и Билл Капуто помогли мне освоить эти концепции и прокомментировали ранние черновики этой статьи. Берин Лорич и Гамильтон Вериссимо де Оливейра дали несколько очень полезных советов о том, как Avalon вписывается в систему. Дэйв Смит настойчиво задавал вопросы о моем первоначальном коде конфигурации инъекций интерфейса и тем самым заставил меня признать, что он был глупым. Джерри Лоури прислал мне множество исправлений опечаток - достаточно, чтобы перешагнуть порог благодарности.
Послесловие переводчика
Я перевел эту статью в процессе подготовки материала для другой статьи об Инверсии Контроля. Надеюсь, что этот перевод для вас был полезен. Я не претендую на правильность, однако если у вас есть дополнения/исправления, напишите мне, мы внесем все необходимые правки.
Стоит отметить, что статья довольно старая, поэтому примеры с фреймворками могут показаться устаревшими, однако здесь есть фундаментальные идеи, которые стоит держать в голове.
Sigest
Статья аж 2004 года. Поди уже многие фреймворки и библиотеки из статьи перестали существовать - разные авалоны и пикоконтейнеры
webmadness Автор
ага, но концепция DI и Service Locator остаются неизменными. В Spring скорее всего тоже уже поменялся механизм