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

Dependency Injection i wczytanie konfiguracji w aplikacji konsolowej

Czasami zachodzi potrzeba wczytania konfiguracji w aplikacji konsolowej. W świecie .NET Core to bardzo proste i wystarczy odwołać się do IConfiguration. Aby jednak urozmaicić trochę to zadanie, założymy że podczas uruchamiania aplikacji będzie istniała możliwość podania nazwy pliku z konfiguracją. Plik ten zawierał będzie listę książek oraz typ importu jaki zostanie uruchomiony.

Do wyboru będzie jeden z trzech typów importu: zapis książek do bazy danych, przesłanie listy książek do księgarni oraz przesłanie listy książek do systemu back office. Nie będziemy implementować mechanizmów, które są za te funkcje odpowiedzialne. Skupimy się natomiast tylko na wczytaniu danych, wstrzyknięciu ich w odpowiednie miejsca oraz na prezentacji informacji o typie importu i liście książek jakich dotyczył.

Wszystkie klasy odpowiedzialne za logikę aplikacji zarejestrujemy w kontenerze Autofac. Może to wyglądać jak lekki over engineering, ale zyskamy porządek w kodzie, łatwość rozbudowy i większą testowalność aplikacji.

Diagram poglądowy

LoadingMultipleConfig_diagram

  • LoadData – wczytanie danych z dysku
  • AppConfiguration – wczytanie konfiguracji, wstrzyknięcie instancji klasy LoadData
  • ImportProcess – import książek, wstrzyknięcie instancji klasy AppConfiguration
  • Program – główna klasa aplikacji, rejestracja klas w kontenerze Autofac, uruchomienie importu

Implementacja

Zaczynamy od utworzenia aplikacji konsolowej dla platformy .NET Core. Następnie przy pomocy menadżera pakietów NuGet instalujemy biblioteki Autofac i CommandLineParser. Autofac jest kontenerem IoC a CommandLineParser biblioteką, która ułatwia parsowanie poleceń przekazanych podczas uruchamiania aplikacji z poziomu konsoli. Czyli będziemy w stanie przekazać do aplikacji nazwę pliku z konfiguracją.

.\LoadingMultipleConfig.exe -c .\bookConfigSaveInDb.json

Następnie tworzymy klasy, których użyjemy do importu listy książek.

[Serializable]
public class Config
{
	[JsonPropertyName("importType")]
	public ImportType ImportType { get; set; }
	
	[JsonPropertyName("books")]
	public List<Book> Books { get; set; } = new List<Book>();
}

[Serializable]
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ImportType
{
	SaveInDb,
	SendToBackOfficeSystem,
	InformBookstore
}

[Serializable]
public class Book
{
	[JsonPropertyName("title")]
	public string Title { get; set; }
	
	[JsonPropertyName("pageCount")]
	public int PageCount { get; set; }
	
	[JsonPropertyName("authors")]
	public List<Author> Authors { get; set; } = new List<Author>();
}

[Serializable]
public class Author
{
	[JsonPropertyName("name")]
	public string Name { get; set; }
}

Plik konfiguracyjny zapisujemy w postaci pliku json. Pamiętajmy, aby podać typ importu.

{
	"importType": "saveInDb",
	"books": [
		{
			"title": "ASP.NET Core 3 and Angular 9",
			"pageCount": 732,
			"authors": [
				{
					"name": "Valerio De Sanctis"
				}
			]
		},
		{
			"title": "Hands-On Domain-Driven Design with .NET Core",
			"pageCount": 446,
			"authors": [
				{
					"name": "Alexey Zimarev"
				}
			]
		},
		{
			"title": "C# 8.0 and .NET Core 3.0 – Modern Cross-Platform Development",
			"pageCount": 818,
			"authors": [
				{
					"name": "Mark J. Price"
				}
			]
		}
	]

}

Teraz utworzymy klasę AppConfiguration, w której wczytamy i zdeserializujemy plik z konfiguracją. Dodatkowo aby ułatwić późniejsze testowanie oraz odseparować fizyczne wczytanie danych z pliku, utworzymy klasę LoadData i odpowiadający jej interfejs ILoadData. Widać tu przy okazji, że instancja ILoadData zostanie wstrzyknięta do konstruktora. W naszym przypadku zajmie się tym Autofac, ale równie dobrze może to zrobić inny mechanizm IoC, np. ten wbudowany w .NET Core.

public class AppConfiguration
{
	public Config Config { get; }

	public AppConfiguration(string filename, ILoadData loadData)
	{
		var jsonString = loadData.ReadData(filename);
		Config = JsonSerializer.Deserialize<Config>(jsonString, new JsonSerializerOptions
		{
			IgnoreNullValues = false
		});
	}
}

public interface ILoadData
{
	string ReadData(string filename);
}

public class LoadData : ILoadData
{
	public string ReadData(string filename)
	{
		if (!File.Exists(filename))
		{
			Console.WriteLine($"Error! Config file '{filename}' not found");
			Environment.Exit(0);
		}
		
		var jsonString = File.ReadAllText(filename);
		
		return jsonString;
	}
}

Przechodzimy do klasy, w której umieścimy logikę importu listy książek. Sam sposób importu nie jest celem tego posta więc uprościmy implementację i po prostu zwrócimy listę z opisem importu. Następnie wyświetlimy ją w klasie Program.cs.

public interface IImportProcess
{
	IEnumerable<string> DoImport();
}

public class ImportProcess : IImportProcess
{
	private readonly AppConfiguration _config;

	public ImportProcess(AppConfiguration config)
		=> _config = config;

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

		string taskToDo = _config.Config.ImportType switch
		{
			ImportType.InformBookstore => "The bookstore will be informed of the following books:",
			ImportType.SaveInDb => "The following books have been saved in the database:",
			ImportType.SendToBackOfficeSystem => "The following books have been sent to the back office system:",
			_ => ":("
		};

		var resultList = new List<string> { taskToDo };

		foreach (var book in _config.Config.Books)
		{
			resultList.Add(book?.Title);
		}

		return resultList;
	}
}

Teraz zostaje tylko poskładać wszystko w jedna całość, zarejestrować klasy w kontenerze i uruchomić import. Rejestrację możemy zrobić automatycznie poprzez przeskanowanie całego assembly, jak również ręcznie rejestrując każdą klasę oddzielnie. My zrobimy to ręcznie ponieważ rejestrując AppConfiguration musimy przekazać nazwę pliku z konfiguracją. Zmiany wprowadzamy w pliku startowym aplikacji, czyli w pliku Program.cs

class Program
{
	static void Main(string[] args)
	{
		var builder = new ContainerBuilder();

		builder
			.RegisterModule(new ProcessModule())
			.RegisterModule(new ConfigurationsModule(args));

		var container = builder.Build();
		
		var importResult = container.Resolve<IImportProcess>().DoImport();

		// Show import result
		foreach (var item in importResult)
		{
			Console.WriteLine(item);
		}
	}
}

Korzystamy z RegisterModule aby podzielić rejestrację i zgrupować ją według typu. W ProcessModule rejestrujemy klasy związane z procesem importu. Rejestrujemy je jako InstancePerDependency, czyli każde wstrzyknięcie do konstruktora utworzy nową instancję.

internal class ProcessModule : Module
{
	protected override void Load(ContainerBuilder builder)
	{
		builder.RegisterType<LoadData>().As<ILoadData>().InstancePerDependency();
		builder.RegisterType<ImportProcess>().A<IImportProcess>().InstancePerDependency();
	}
}

W ConfigurationsModule korzystamy z biblioteki CommandLineParser, dzięki której uzyskamy dostęp do przekazanych parametrów. Następnie rejestrujemy klasę AppConfiguration przekazując do konstruktora wcześniej pobraną nazwę nazwę pliku z konfiguracją. Klasę AppConfiguration
rejestrujemy jako singleton ponieważ zależy nam, aby konfiguracja została wczytana tylko raz. Jeżeli podczas parsowania wystąpi błąd lub zostanie wywołany parametr –help to po prostu zamykamy aplikację.

internal class ConfigurationsModule : Module
{
	private readonly string[] _args;

	public ConfigurationsModule(string[] args)
		=> _args = args;

	protected override void Load(ContainerBuilder builder)
	{
		Parser.Default.ParseArguments<Options>(_args)
			.WithParsed(options =>
			{
				builder.RegisterType<AppConfiguration>()
					.WithParameter(new TypedParameter(typeof(string), options.Config))
					.SingleInstance();

			})
			.WithNotParsed(errors =>
			{
				// in case of parameter parsing errors or using help option close the application
				Environment.Exit(0);
			});
	}
}

internal class Options
{
	[Option('c', "config", Required = false, Default = "bookConfig.json" , HelpText = "Configuration file name")]
	public string Config { get; set; } = String.Empty;
}

Klasy Options używamy do odczytania parametru config, przekazanego podczas uruchomienia aplikacji.

Testy

Sprawdźmy teraz jak taka implementacja sprawdzi się w testach. Skorzystamy z bibliotek NUnit, Moq oraz Fluent Assertions. Moq jest chyba najpopularniejszą biblioteką do mockowania obiektów a Fluent Assertions pozwoli nam uzyskać bardziej naturalne asercje.

Zaczynamy od zbudowania kontenera Autofac i rejestracji potrzebnych klas. Jako, że nie interesuje nas w tej chwili samo wczytanie danych z dysku to skorzystamy z biblioteki Moq i zamockujemy obiekt LoadData. Taki sposób pozwoli nam na przekazanie danych, które w czasie normalnego działania aplikacji byłyby wczytane z dysku.

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<ImportProcess>().As<IImportProcess>().InstancePerDependency();
	
	builder.RegisterType<AppConfiguration>()
		.WithParameter(new TypedParameter(typeof(string), string.Empty))
		.SingleInstance();
	
	return builder.Build();
}

W 3 i 4 linii mockujemy obiekt LoadData a następnie w 7 linii rejestrujemy go jako instancję interfejsu ILoadData. Czyli jak wstrzykniemy ILoadData to dostaniemy instancję naszego mocka. W linii 9 nic się nie zmienia. Natomiast w linii 11 rejestrujemy klasę AppConfiguration i jako nazwę pliku do wczytania przekazujemy pusty string. Możemy tak zrobić ponieważ nie wczytujemy pliku z dysku tylko z zamockowanego obiektu.

Mając już przygotowane środowisko przechodzimy do pierwszego testu. Sprawdzimy czy w przypadku braku książek do importu zostanie zwrócony komunikat o braku danych.

[Test]
public void No_Books_To_Import_Should_Return_Information_That_There_Is_No_Data_To_Import()
{
	// Arrange
	string jsonData = "{\"importType\":\"saveInDb\"}";
	var container = BuildContainer(jsonData);
	
	// Act
	var importResult = container.Resolve<IImportProcess>().DoImport();

	
	// Assert
	importResult.Should().HaveCount(1);
	importResult.First().Should().BeEquivalentTo("No data to import!");
}

W drugim teście sprawdzimy czy wskazanie konkretnego typu importu spowoduje uruchomienie dokładnie tego mechanizmu. Używając TestCase tworzymy w sumie trzy testy, za każdym razem przekazując inne dane wejściowe. Pozwala nam to przetestować jednym testem wszystkie typy importu.

[TestCase(ImportType.InformBookstore, "{\"importType\":\"informBookstore\",\"books\":[{\"title\":\"ASP.NET Core 3 and Angular 9\",\"pageCount\":732,\"authors\":[{\"name\":\"Valerio De Sanctis\"}]},{\"title\":\"Hands-On Domain-Driven Design with .NET Core\",\"pageCount\":446,\"authors\":[{\"name\":\"Alexey Zimarev\"}]}]}")]
[TestCase(ImportType.SaveInDb, "{\"importType\":\"saveInDb\",\"books\":[{\"title\":\"ASP.NET Core 3 and Angular 9\",\"pageCount\":732,\"authors\":[{\"name\":\"Valerio De Sanctis\"}]},{\"title\":\"Hands-On Domain-Driven Design with .NET Core\",\"pageCount\":446,\"authors\":[{\"name\":\"Alexey Zimarev\"}]}]}")]
[TestCase(ImportType.SendToBackOfficeSystem, "{\"importType\":\"sendToBackOfficeSystem\",\"books\":[{\"title\":\"ASP.NET Core 3 and Angular 9\",\"pageCount\":732,\"authors\":[{\"name\":\"Valerio De Sanctis\"}]},{\"title\":\"Hands-On Domain-Driven Design with .NET Core\",\"pageCount\":446,\"authors\":[{\"name\":\"Alexey Zimarev\"}]}]}")]
public void Import_Type_Property_Should_Indicate_How_To_Import_Books(ImportType importType, string jsonData)
{
	// Arrange
	var container = BuildContainer(jsonData);
	
	// Act
	var importResult = container.Resolve<IImportProcess>().DoImport();

	string taskToDo = importType switch
	{
		ImportType.InformBookstore => "The bookstore will be informed of the following books:",
		ImportType.SaveInDb => "The following books have been saved in the database:",
		ImportType.SendToBackOfficeSystem => "The following books have been sent to the back office system:",
		_ => ":("
	};
	
	// Assert
	importResult.First().Should().BeEquivalentTo(taskToDo);
}

Na koniec sprawdźmy czy po przekazaniu danej listy książek otrzymamy informację o ich zaimportowaniu.

[TestCase(1, "{\"importType\":\"informBookstore\",\"books\":[{\"title\":\"Hands-On Domain-Driven Design with .NET Core\",\"pageCount\":446,\"authors\":[{\"name\":\"Alexey Zimarev\"}]}]}")]
[TestCase(2, "{\"importType\":\"saveInDb\",\"books\":[{\"title\":\"ASP.NET Core 3 and Angular 9\",\"pageCount\":732,\"authors\":[{\"name\":\"Valerio De Sanctis\"}]},{\"title\":\"Hands-On Domain-Driven Design with .NET Core\",\"pageCount\":446,\"authors\":[{\"name\":\"Alexey Zimarev\"}]}]}")]
public void Import_Should_Return_A_List_Of_Imported_Books(int numberOfImportedBooks, string jsonData)
{
	// Arrange
	var container = BuildContainer(jsonData);
	
	// Act
	var importResult = container.Resolve<IImportProcess>().DoImport();
	
	// Assert
	
	// added 1 because the first line is the import type description
	importResult.Should().HaveCount(numberOfImportedBooks + 1);
}

Po uruchomieniu wszystkie testy przechodzą poprawnie.
LoadingMultipleConfig_tests

Efekt końcowy

Po skończonej implementacji i przeprowadzeniu testów zostaje nam już tylko uruchomić aplikację i sprawdzić czy faktycznie działa.

λ  .\LoadingMultipleConfig.exe -c bookConfigSendToBackoffice.json
The following books have been sent to the back office system:
ASP.NET Core 3 and Angular 9
Hands-On Domain-Driven Design with .NET Core
C# 8.0 and .NET Core 3.0 - Modern Cross-Platform Development

Wynik działania aplikacji jest prawidłowy więc możemy uznać zadanie za gotowe. Jeżeli chcielibyśmy jeszcze trochę rozbudować naszą aplikację, możemy każdy typ importu wydzielić do osobnej klasy i wstrzyknąć wybraną instancję importera bezpośrednio do klasy ImportProcess. W taki sposób odseparujemy kod importerów i uprościmy klasę ImportProcess.

Kod aplikacji dostępny jest na GitHub