Naciśnij “Enter” aby skoczyć do treści

Dependency Injection w aplikacji konsolowej cz.2

W poprzednim poście stworzyliśmy aplikację, której zadaniem było wczytanie i zaimportowanie listy książek. Przy pomocy biblioteki Autofac udało nam się przygotować w miarę rozsądną architekturę aplikacji. Jednak zostało przynajmniej jeszcze jedno miejsce, które moglibyśmy zmodyfikować i uzyskać bardziej przejrzysty kod i łatwiejszą rozbudowę aplikacji. Tym miejscem jest proces importu książek, czyli klasa ImportProcess.

Jak łatwo zauważyć wszystkie implementacje importów znajdują się w jednej i tej samej klasie. Takie rozwiązanie nie jest najlepsze, ponieważ mieszamy różne implementacje, a dodatkowo utrudniamy późniejszą rozbudowę. Spróbujmy przenieść więc każdy importer do oddzielnej klasy i wstrzyknąć do procesu importu tylko ten, który będzie potrzebny.

Diagram poglądowy

LoadingMultipleConfig_diagram_cz2
Widać, że do diagramu z poprzedniego posta dodaliśmy interfejs IImport i trzy klasy importów, które będą go implementować.

  • IImport – interfejs z jedną metodą Import
  • ImportInformBookstore – implementacja przeniesiona z klasy ImportProcess
  • ImportSaveInDb – implementacja przeniesiona z klasy ImportProcess
  • ImportSendToBackOfficeSystem – implementacja przeniesiona z klasy ImportProcess

Implementacja

Chcemy doprowadzić do takiego stanu, aby z klasy ImportProcess zniknęły wszelkie implementacje importerów. Zacznijmy więc od przeniesienia każdej implementacji do osobnej klasy.

public interface IImport
{
	List<string> Import(List<Book> books);
}

public class ImportInformBookstore : IImport
{
	public List<string> Import(List<Book> books)
	{
		var resultList = new List<string> { "The bookstore will be informed of the following books:" };

		foreach (var book in books)
		{
			resultList.Add(book?.Title);
		}

		return resultList;
	}
}

public class ImportSaveInDb : IImport
{
	public List<string> Import(List<Book> books)
	{
		var resultList = new List<string> { "The following books have been saved in the database:" };

		foreach (var book in books)
		{
			resultList.Add(book?.Title);
		}

		return resultList;
	}
}

public class ImportSendToBackOfficeSystem : IImport
{
	public List<string> Import(List<Book> books)
	{
		var resultList = new List<string> { "The following books have been sent to the back office system:" };

		foreach (var book in books)
		{
			resultList.Add(book?.Title);
		}

		return resultList;
	}
}

W naszym przypadku implementacje importerów są bardzo proste i prezentują tylko ogólną koncepcję rozwiązania.

Po wydzieleniu kodu do oddzielnych klas możemy przystąpić do wprowadzenia zmian w klasie ImportProcess.

public class ImportProcess : IImportProcess
{
	private readonly AppConfiguration _config;
	private readonly IImport _import;

	public ImportProcess(AppConfiguration config, IImport import)
	{
		_config = config;
		_import = import;
	}

	public IEnumerable<string> DoImport()
	{
		if (_config?.Config?.Books is null || !_config.Config.Books.Any())
		{
			return new List<string> { "No data to import!" };
		}

		List<string> resultList =  _import.Import(_config.Config.Books);

		return resultList;
	}
}

Widać, że instancja danego importera wstrzyknięta jest w konstruktorze. W taki oto prosty sposób odseparowaliśmy implementacje importerów i możemy rozwijać je niezależnie. Nasuwa się jednak pytanie, co właściwie zostanie wstrzyknięte do konstruktora. Z którą instancją klasy będziemy mieli do czynienia.

Aby odpowiedzieć na to pytanie, musimy jeszcze zmodyfikować kod odpowiedzialny za rejestrację klas w kontenerze Autofac. Zmiany wprowadzamy w klasie ProcessModule czyli podczas rejestracji modułów w głównej klasie aplikacji Program.

internal class ProcessModule : Module
{
	protected override void Load(ContainerBuilder builder)
	{
		builder.RegisterType<LoadData>().As<ILoadData>().InstancePerDependency();
		
		builder.RegisterType<ImportInformBookstore>().Keyed<IImport>(ImportType.InformBookstore);
		builder.RegisterType<ImportSaveInDb>().Keyed<IImport>(ImportType.SaveInDb);
		builder.RegisterType<ImportSendToBackOfficeSystem>().Keyed<IImport>(ImportType.SendToBackOfficeSystem);

		builder.Register(c =>
				new ImportProcess(c.Resolve<AppConfiguration>(),
					c.ResolveKeyed<IImport>(c.Resolve<AppConfiguration>().Config.ImportType))).As<IImportProcess>()
			.InstancePerDependency();
	}
}

W liniach od 7 do 9 rejestrujemy klasy importerów. Do tej pory podczas rejestracji klasy wskazywaliśmy interfejs, pod którym zostanie ona zarejestrowana. W przypadku gdy klasa nie posiadała interfejsu, rejestrowana była pod nazwą klasy. Jeżeli zaszła potrzeba odwołania się do instancji klasy, podawaliśmy w konstruktorze nazwę interfejsu, pod którym została ona zarejestrowana. Autofac przeszukiwał listę zarejestrowanych typów i jeżeli znalazł pasujący, wstrzykiwał go do naszej klasy. Czyli mieliśmy do czynienia z jednym interfejsem i jedną implementującą go klasą.

Teraz musimy zmienić to podejście, ponieważ interfejs IImport implementowany jest przez trzy klasy. Autofac na szczęście pozwala wskazać dodatkowy klucz (Keyed) lub nazwę (Named), pod jaką zostanie zarejestrowana klasa. Upraszając, można to porównać do słownika, gdzie kluczem jest interfejs złączony z kluczem lub nazwą. Jeżeli zajdzie potrzeba odwołania się do instancji klasy to podajemy interfejs i klucz. Kluczem może być dowolny obiekt, czyli w naszym przypadku np. typ importu.

Mamy już zarejestrowane klasy importerów więc przechodzimy do rejestracji klasy ImportProcess. Robimy to ręcznie, ponieważ chcemy wskazać dokładny typ importera, jaki zostanie wstrzyknięty. Pierwszym parametrem, który przekazujemy, jest instancja klasy AppConfiguration. Zarejestrowaliśmy ją jako singleton, więc wczytanie konfiguracji nastąpi tylko raz podczas działania całej aplikacji. Drugim parametrem, który przekazujemy, jest instancja importera. Dostęp do niego uzyskujemy, wskazując interfejs oraz typ importu. W tym miejscu korzystamy właśnie z wcześniej zarejestrowanych klas przy pomocy Keyed.

W taki sposób udało nam się ukończyć wprowadzanie zmian i możemy przejść do testów.

Testy

Po uruchomieniu testów okazuje się, że kończą się porażką. Jest to oczekiwany efekt, ponieważ dodaliśmy nowe klasy i zmieniliśmy sposób ich rejestracji. Aby naprawić testy, musimy zaktualizować metodę BuildContainer.

private IContainer BuildContainer(string jsonData)
{
	var loadDataMock = new Mock<ILoadData>();
	loadDataMock.Setup(c => c.ReadData(string.Empty)).Returns(jsonData);
	
	var builder = new ContainerBuilder();
	builder.RegisterInstance(loadDataMock.Object).As<ILoadData>();
	
	builder.RegisterType<ImportInformBookstore>().Keyed<IImport>(ImportType.InformBookstore);
	builder.RegisterType<ImportSaveInDb>().Keyed<IImport>(ImportType.SaveInDb);
	builder.RegisterType<ImportSendToBackOfficeSystem>().Keyed<IImport>(ImportType.SendToBackOfficeSystem);

	builder.Register(c =>
			new ImportProcess(c.Resolve<AppConfiguration>(),
				c.ResolveKeyed<IImport>(c.Resolve<AppConfiguration>().Config.ImportType))).As<IImportProcess>()
		.InstancePerDependency();

	builder.RegisterType<AppConfiguration>()
		.WithParameter(new TypedParameter(typeof(string), string.Empty))
		.SingleInstance();
	
	return builder.Build();
}

Zmiany zostały wprowadzone od linii 9 do 16. Po ponownym uruchomieniu testów wszystkie kończą się sukcesem. Warto tutaj zwrócić uwagę, że nie zmieniliśmy logiki testów, a jedynie dostosowaliśmy sposób rejestrowania klas.

Podsumowanie

Zmodyfikowaliśmy kod aplikacji tak, aby odseparować od siebie implementację poszczególnych importerów. Uzyskaliśmy w ten sposób większą przejrzystość kodu i łatwość rozbudowy aplikacji. Dodatkowo przyjrzeliśmy się działaniu różnym typom rejestracji klas w Autofac.

Kod aplikacji dostępny jest na GitHub.

Linki:
Autofac: Named and Keyed Services