Главная страница   /   6.3. Разрешение циклических зависимостей (Внедрение зависимостей в .NET

Внедрение зависимостей в .NET

Внедрение зависимостей в .NET

Марк Симан

6.3. Разрешение циклических зависимостей

Иногда реализации зависимости оказываются циклическими. Одна реализация требует еще одну зависимость, реализация которой требует первую абстракцию. Такой граф зависимостей не может быть удовлетворительным.

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

Рисунок 6-7: Циклы в графе зависимостей могут произойти даже тогда, когда абстракции не имеют отношения друг к другу. В этом примере каждая реализация реализует отдельный интерфейс, но также требует зависимость. Поскольку Concretec требует IA, но единственной реализацией IA является ConcreteA со своей зависимостью для IB и так далее, то есть у нас есть цикл, который не может быть разрешен, как есть.

Пока цикл остается, мы не можем удовлетворить все зависимости, и наши приложения не будут иметь возможность запускаться. Ясно, что надо что-то делать, но что?

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

Разрешение проблем с циклами зависимостей

Всякий раз, когда я сталкиваюсь с циклом зависимости, вот мой первый вопрос: "Где я ошибся?"

Совет

Цикл зависимостей указывает на «плохо пахнущий» код. Если такое появится, вы должны серьезно пересмотреть структуру и код.

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

Если цикл проходит более одного слоя, мы знаем, что что-то в корне неверно. Как показано на рисунке 6-8, это обозначает, что некоторые ссылки идут не в ту сторону.

Рисунок 6-8: Когда цикл пересекает одну или более границ слоя, по крайней мере, одна ссылка архитектурно незаконна. В данном случае, ссылка от D до А является незаконной. Если такая ситуация возникает, решать ее нужно немедленно.

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

Нам необходимо сломать цикл любым способом. Пока цикл существует, приложение не будет работать.

Любой цикл является плохо пахнущим кодом, поэтому наша первая реакция должна заключаться в том, чтобы переделать вовлеченные части и предотвратить цикл. Таблица 6-1 показывает некоторые общие направления, в которых мы можем двигаться.

Таблица 6-1: Некоторые стратегии разработки, чтобы сломать цикличные зависимости
Стратегия Описание
События Вы можете часто сломать цикл, изменив одну из абстракций так, чтобы она вызывала события вместо явного вызова зависимости, сообщающие зависимости, что что-то произошло. События особенно уместны, если только одна сторона вызывает void методы для своей зависимости. .NET события являются применением шаблона проектирования Наблюдатель (Observer), и вы можете иногда рассматривать вопрос о явной реализации. Это особенно верно, если вы решите использовать доменные события (Domain Events), чтобы разорвать порочный круг. Тут есть потенциал обеспечить возможность истинной асинхронной односторонней передачи сообщений.
Внедрение в свойство Если ничего не помогает, мы можем разорвать порочный круг путем рефакторинга одного класса от внедрения в конструктор ко внедрению в свойство. Это самый крайний вариант, потому что он только лечит симптомы.

Я не намерен тщательно исследовать первый вариант, потому что существующая литература уже предоставляет подробную информацию.

Совет

Попытайтесь решить проблему циклов с помощью событий. Если это не поможет, попробуйте Наблюдатель. И только если у вас все еще не получается разорвать цикл, используйте внедрение в свойство.

Не ошибитесь: цикл зависимостей является плохо пахнущим кодом. Наш первый приоритет заключается в том, чтобы проанализировать код и понять, почему появился цикл. Когда мы поймем, почему, мы должны изменить структуру и код.

Тем не менее, иногда мы не можем изменить дизайн. Даже если мы понимаем причину возникновения цикла, API-«нарушитель» может быть вне нашего контроля.

Прерывание цикла при помощи внедрения в свойство

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

Внимание

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

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

На рисунке 6-9, B требует экземпляр IC (интерфейс, который реализует C). Мы можем разорвать цикл, изменив зависимость для B от внедрения в конструктор во внедрение в свойство. Это означает, что мы можем сначала создать B и внедрить его в A, а затем впоследствии присвоить С B:

var b = new B();
var a = new A(b);
b.C = new C(new D(a));
Рисунок 6-9: Учитывая цикл, мы должны сначала решить, где его оборвать. В данном случае мы решили сломать цикл между В и С.

Использование внедрения в свойство таким способом добавляет дополнительную сложность в B, потому что теперь он должен быть в состоянии работать тогда, когда его зависимость пока не доступна.

Совет

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

Если мы не хотим ослабить любой оригинальный класс таким образом, мы можем ввести виртуальную прокси (Virtual Proxy), которая оставляет B нетронутым:

var lb = new LazyB();
var a = new A(lb);
lb.B = new B(new C(new D(a)));

LazyB реализует IB, как это делает B. Тем не менее, он принимает свою зависимость IB через внедрение в свойство вместо внедрения в конструктор, что позволяет нам разорвать цикл, не нарушая инвариантов любого из оригинальных классов.

Хотя классы с образными именами A-D иллюстрируют структуру решения, более реалистичный пример является более оправданным.

Пример: создание окна

Одна из наиболее распространенных ситуаций, когда мы не можем перестроить наш выход из цикла зависимостей, это когда мы имеем дело с внешними API. Одним из таких примеров является WPF.

В WPF мы можем использовать MVVM паттерн, чтобы реализовать разделение понятий путем деления кода на представления и лежащие в основе модели. Модели присваиваются представлению через свойство DataContext. Это, по существу, внедрение в свойство в действии.

Совет

Вы можете прочитать больше о создании WPF приложений при помощи MVVM в разделе 7.4.

DataContext служит как зависимость для Window, но модель играет большую роль в управлении тем, какие представления и где активируются. Одним из действий, которое модель должна быть в состоянии выполнить, – это сделать всплывающим диалоговое окно. И один из способов реализации этого заключается во внедрении абстракции, как эта, в модель:

public interface IWindow
{
	void Close();
	IWindow CreateChild(object viewModel);
	void Show();
	bool? ShowDialog();
}

С внедренным IWindow любая модель может создать новые Window и отобразить их в виде модальных или немодальных окон. Однако чтобы реализовать этот интерфейс, мы должны иметь ссылку на реальный Window, чтобы правильно установить свойство Owner. В следующем листинге показана реализация метода CreateChild.

Листинг 6-2: Создание дочернего окна
public virtual IWindow CreateChild(object viewModel)
{
	var cw = new ContentWindow();
	cw.Owner = this.wpfWindow;
	cw.DataContext = viewModel;
	WindowAdapter.ConfigureBehavior(cw);
	return new WindowAdapter(cw);
}

ContentWindow – это WPF окно, которое вы можете использовать, чтобы показать новое окно. Важно установить владельца Window, прежде чем показывать его, потому что иначе могут произойти странные ошибки, когда фокусированное или модальное окно скрыто за другими окнами. Чтобы предотвратить такие ошибки, вы устанавливаете свойство Owner для текущего Window. Поле wpfWindow является другим экземпляром System.Windows.Window.

Вы также присваиваете viewModel новому Window DataContext, прежде чем обернуть его в новую реализацию IWindow и вернуть его.

Вопрос в том, что с этой реализацией у вас есть ViewModel, которые требуют IWindow, реализация IWindow, которая требует WPF Window, и WPF Window, которые через их DataContext требуют, чтобы работала ViewModel. Рисунок 6-10 показывает этот цикл.

Рисунок 6-10: Цикл WPF MVVM. В MVVM Window зависит от ViewModel, которая, в свою очередь, зависит от экземпляра IWindow. Надлежащей реализацией IWindow является WindowAdapter, который зависит от WPF Window, чтобы иметь возможность установить владельца каждого Window и избежать ошибок фокусировки.

Мы ничего не можем тут изменить, чтобы выйти из циклической зависимости. Связь между Window и ViewModel зафиксирована, потому что System.Windows.Window является внешним API (определенным в BCL). Кроме того, WindowAdapter зависит от Window, чтобы избежать ошибок фокусировки, так что это отношение дано также и извне.

Единственное отношение, которое можно изменить, это только между ViewModel и его IWindow. Технически вы можете перепроектировать все это, чтобы использовать события, но это приведет к довольно нелогичному API. Для отображения диалогового окна вам нужно было бы вызвать событие и надеяться, что кто-то подпишется, показывая модальное окно. Кроме того, вам пришлось бы возвращать результат диалогового окна по ссылке через аргументы исходного события. Вызов события был бы блокирующим вызовом. Это было бы технически возможным, но странным, так что мы исключим это.

Кажется, мы не можем переделать наш выход из цикла, так как же мы можем разорвать его?

Прерывание цикла

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

Самым простым решением будет подключить что-нибудь еще и установить свойство DataContext для MainWindow в последнюю очередь перед показом. Это возможно, но не особенно дружелюбно по отношению к DI контейнерам, потому что это потребовало бы от нас явно присвоить зависимость, после того как построение было выполнено.

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

Примечание

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

Давайте посмотрим, как инкапсулировать создание реализации IWindow, которая правильно загружает MainWindowViewModel и присваивает ее экземпляру WPF MainWindow. Чтобы помочь сделать это, вы вводите эту абстрактную фабрику:

public interface IMainWindowViewModelFactory
{
	MainWindowViewModel Create(IWindow window);
}

Класс MainWindowViewModel имеет более чем одну зависимость, но все зависимости, кроме IWindow, могут быть удовлетворены сразу, так что вам не нужно передавать их в качестве параметра методу Create. Вместо этого вы можете внедрить их в конкретную реализацию IMainWindowViewModelFactory.

Вы используете IMainWindowViewModelFactory как зависимость в реализации IWindow, унаследованной от WindowAdapter, что представлен в листинге 6-2. Это позволяет отложить инициализацию реализации IWindow, пока не будет вызван первый метод. Здесь вы видите, как переписывается метод CreateChild из листинга 6-2:

public override IWindow CreateChild(object viewModel)
{
	this.EnsureInitialized();
	return base.CreateChild(viewModel);
}

Перед выполнением реальной работы вы должны убедиться, что все зависимости полностью инициализированы. Когда они инициализированы, вы можете безопасно вызвать базовую реализацию.

Следующий листинг показывает, как реализуется метод EnsureInitialized при помощи внедренной IMainWindowViewModelFactory.

Листинг 6-3: Отложенная инициализация зависимостей
private void EnsureInitialized()
{
	if (this.initialized)
	{
		return;
	}
	var vm = this.vmFactory.Create(this);
	this.WpfWindow.DataContext = vm;
	this.DeclareKeyBindings(vm);
	this.initialized = true;
}

Строка 7: Создать ViewModel

Строка 8: Внедрить ViewModel в Window

При инициализации MainWindowAdapter вы в первый раз вызываете внедренную абстрактную фабрику для создания желаемой ViewModel. Это возможно на данный момент, потому что экземпляр MainWindowAdapter уже создан, и поскольку он реализует IWindow, вы можете передать экземпляр методу Create.

Когда у вас есть ViewModel, вы можете присвоить ее DataContext инкапсулированного WPF Window. С небольшой дальнейшей настройкой Window теперь полностью инициализирован и готов к использованию.

В Composition Root приложения вы можете подключить все это вот так:

IMainWindowViewModelFactory vmFactory =
	new MainWindowViewModelFactory(agent);
Window mainWindow = new MainWindow();
IWindow w =
	new MainWindowAdapter(mainWindow, vmFactory);

Переменная MainWindow становится свойством WpfWindow в листинге 6-3, а vmFactory соответствует полю с одноименным названием. При вызове методов Show или ShowDialog для результирующего IWindow вызывается метод EnsureInitialize и все зависимости удовлетворены.

Эта комбинация отложенной инициализации и помощи абстрактной фабрики может быть хорошим дополнительным штрихом, но на первом месте – это наличие внедрения в свойство, которое позволяет разорвать порочный круг. В данном случае вам «повезло», потому что WPF Window уже использует внедрение в свойство через свое свойство DataContext.

Всегда имейте в виду, что лучший способ решить проблему цикла – это пересмотреть API, так чтобы цикл исчез. Тем не менее, в тех редких случаях, когда это невозможно или крайне нежелательно, мы должны разорвать цикл при помощи внедрения в свойство, по крайней мере, в одном месте. Это позволяет составить остальную часть графа объекта отдельно от зависимости, связанной со свойством. Когда остальная часть графа объекта полностью заполнена, то можно внедрить соответствующий экземпляр с помощью свойства. В качестве дополнительного шага мы можем инкапсулировать эту логику назначения свойства в классе и использовать абстрактную фабрику, чтобы присвоить значение свойства в самый последний момент.

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