Der letzte Beitrag hat die grundsätzliche Motivation adressiert, die Microsoft Extensions einzusetzen – und zwar insbesondere auch, wenn man das klassische .NET Framework weiter nutzen will.
Die Bereitstellung von Konfiguration ist dafür ein sehr gelungenes Beispiel: Microsoft Extensions Configuration ist – meiner Meinung nach – dem klassischen System.Configuration in jeder Beziehung vorzuziehen.
Von System.Configuration …
Wer schon mit System.Configuration gearbeitet hat, kennt die Fähigkeiten dieses Systems:
- Konfiguration kann als XML abgelegt werden
- Man kann Klassen (abgeleitet von entsprechenden Basisklassen in System.Configuration) bereitstellen, um die Konfiguration zu validieren und typisiert auszulesen
- Geänderte Konfiguration lässt sich speichern
Das kommt jedoch mit einigen Einschränkungen:
Das Schreiben eigener, typisierter ConfigurationSection und ggf. weiterer Klassen ist zwar kein Hexenwerk, kann aber durchaus mit Aufwand verbunden sein. Und die resultierenden Klassen sind an das Konfigurationssystem gebunden.
Man ist auf XML festgelegt, normalerweise in Form einer einzigen XML-Datei (das Auslagern von Sections über ConfigSource mal ignoriert). Zwar kann man in eigenem Code durchaus eine Config-Datei gezielt lesen, aber die üblichen Schnittstellen über den ConfigurationManager bekommen davon nichts mit.
Damit ist das Konfigurationssystem in sich geschlossen und wenig erweiterbar. Wer Konfigurationswerte in der Datenbank oder in einem zentralen Service ablegen will, muss seine eigene Abstraktionsschicht darüberlegen. Aber selbst dann ist das nur eine Teillösung: bestehender Code – insbesondere 3rd-Party-Bibliotheken – weiß davon nichts. Und in aller Regel ist dies auf die appSettings und evtl. die ConnectionStrings beschränkt; andere ConfigSections bleiben außen vor.
Und wer schon mal UnitTests mit unterschiedlichen Konfigurationen schreiben musste, hatte durchaus einige Probleme zu lösen.
… zu den Microsoft Extensions
Die Microsoft Extensions bauen das Konfigurationssystem etwas anders auf:
- Provider stellen Name/Value-Paare zur Verfügung, mehrere Provider lassen sich kombinieren.
- Sections entstehen unabhängig von Providern über eine Namenskonvention.
- Die Abbildung auf typisierte Klassen nutzt Pocos – keine speziellen Basisklassen oder Attribute.
- Über Microsoft Extensions Options kommen Validierung und die Integration mit Dependency Injection ins Spiel.
Zunächst zu Providern: Diese stellen Konfigurationswerte als Name/Value-Paare zur Verfügung. Allein durch diese Abstrahierung sind bereits unterschiedlichste Quellen für Konfigurationswerte möglich: JSON-Dateien und Kommandozeilenargumente sind die üblichen, aber für UnitTests kann man die Konfiguration auch problemlos im Speicher aufbauen.
Für den Aufbau der Konfiguration kommt – wie an vielen Stellen in den Microsoft Extensions – das Builder-Pattern zum Einsatz. Die Provider werden dem Builder bekannt gemacht, die fertige Konfiguration nutzt eine Liste dieser Provider:
1: var config = new ConfigurationBuilder()
2: .AddEnvironmentVariables()
3: .AddJsonFile("appSettings.json")
4: .AddCommandLine(args)
5: .Build();
Die letztgenannten Provider können Werte der vorherigen Provider übersteuern, so dass sich unterschiedliche Quellen mischen lassen: Standardwerte kommen aus der Konfigurationsdatei, aber für einen bestimmten Testlauf kann man sie mit Kommandozeilenargumenten übersteuern.
Sections ergeben sich aus einer Namenskonvention: Über die Trennung mit Doppelpunkt wird eine Hierarchie aufgebaut. Die JSON-Datei…
1: {
2: "section0": {
3: "key0": "value0",
4: "key1": "value1"
5: },
… wird als
- “section0:key0” = “value0”
- “section0:key1” = “value1”
geliefert.
Der Zugriff auf die Werte geht über die Schnittstelle IConfiguration, die wahlweise mit vollständigem Bezeichner oder über Sections auf Werte zugreifen kann:
1: var value0 = _config["section0:key0"];
2:
3: var configSection = _config.GetSection("section0");
4: var value1 = configSection["key1"];
Man kann eine Section auch problemlos auf ein Objekt abbilden:
1: var section = new Section();
2: _config.GetSection("section0").Bind(section);
Das wird man üblicherweise aber nicht tun, sondern stattdessen die Integration mit Dependency Injection nutzen, die Microsoft Extensions Options zur Verfügung stellt.
Nutzung von Dependency Injection über Microsoft Extensions Options
Dependency Injection werde ich in einem späteren Beitrag noch behandeln, aber schon mal als Vorgriff:
Hat man die Konfiguration beim DI-Container bekannt gemacht, muss man nur noch die spezielle Konfigurationsklasse anmelden, und kann sie dann für Constructor Injection nutzen. Hierbei werden Konfigurationsdaten – jetzt in der Regel als “options” bezeichnet – über eine generische Schnittstelle kenntlich gemacht:
1: // Registrierung für Dependency Injection:
2: services.Configure<Section>(config.GetSection("section0"));
3:
4: // Nutzung
5: public class MyClass
6: {
7: public MyClass(IOptions<Section> options)
8: {
9: var value0 = options.Value.Key0;
10: }
11: }
Durch die Nutzung über die generische Schnittstelle IOptions<> wird die Konfiguration erst beim Zugriff auf das Value-Property ausgelesen und auf das Poco abgebildet.
Microsoft Extensions Options bietet darüber hinaus weitere Möglichkeiten:
- Die Options können per Code initialisiert werden.
- Die Options können auf unterschiedliche Arten validiert werden.
- Es gibt weitere Schnittstellen, um sich über Konfigurationsänderungen benachrichtigen zu lassen.
- …
Microsoft Extensions Configuration ist also für das eigentliche Konfigurationssystem zuständig, während Microsoft Extensions Options die Brücke zu Dependency Injection schlägt.
Eigene Provider
Einen eigenen Provider zu bauen ist denkbar einfach: Man benötigt eine Klasse, die IConfigurationSource implementiert, die einzige Methode Build() muss einen IConfigurationProvider zurückliefern. Die Source wird während des Build()-Aufrufs des ConfigurationBuilder benötigt, zur Laufzeit ist der Provider für das Liefern von Konfigurationswerten zuständig. Wer von der Basisklasse ConfigurationProvider ableitet, muss nur die Methode Load() überschreiben und das Property Data befüllen.
Ein einfaches Beispiel bietet der Memory-Provider mit Source und Provider.
Wer so einen eigenen Provider anbietet, sollte sich mit einer Konvention vertraut machen, die sich auch an anderen Stellen durch die Microsoft Extensions zieht: Für die Registrierung sollte man eine Klasse mit Erweiterungsmethoden zur Verfügung stellen; diese sollten nicht im Namespace der Bibliothek liegen, sondern im Namespace des Features. Am Beispiel des Memory-Providers:
1: namespace Microsoft.Extensions.Configuration
2: {
3: public static class MemoryConfigurationBuilderExtensions
4: {
5: public static IConfigurationBuilder AddInMemoryCollection(this IConfigurationBuilder configurationBuilder)
6: {
7: configurationBuilder.Add(new MemoryConfigurationSource());
8: return configurationBuilder;
9: }
Durch die Wahl des Namespaces Microsoft.Extensions.Configuration ist sichergestellt, dass das Feature registriert werden kann, sobald eine Referenz auf die Bibliothek gesetzt ist. Diese Konvention wird uns auch an anderen Stellen wieder begegnen.
Übrigens: Wer an das Auslesen der Konfigurationswerte aus der Datenbank oder ähnliches denkt, muss sich einem Problem stellen: Er kann in der Source nicht auf Konfiguration zugreifen, da diese ja gerade erst aufgebaut wird. Das Bereitstellen etwa des Connection-Strings wird also schwierig. Hier muss man sich selbst um eine zweistufige Abarbeitung kümmern. Da sich Konfiguration aber derart einfach aufbauen lässt, ist das kein grundsätzliches Problem.
Die Grenzen
Wie eingangs erwähnt halte ich den Ansatz der Microsoft Extensions für deutlich besser gelungen als das klassische System.Configuration. Aber natürlich hat ein solches System auch seine Grenzen. Im direkten Vergleich sind dies:
Speichern: Die Microsoft Extensions unterstützen zwar das Nachladen, wenn sich die Konfiguration geändert hat, was mit seinen Benachrichtigungsmechanismen deutlich über System.Configuration hinausgeht. Aber das Speichern von geänderten Werten ist nicht vorgesehen. Immerhin: Wenn man per Code einen Konfigurationswert setzt, wird er in allen Providern gesetzt; man hat also die Information, was geändert wurde.
Konfiguration benötigt Konfiguration: Ein Provider, der seinerseits Konfiguration benötigt (z.B. einen Connection String für den Zugriff auf die Datenbank) ist im Konzept nicht vorgesehen. Ich komme in einem späteren Beitrag zum Hosting auf dieses Thema zurück.
Standards: Mit System.Configuration bekommt man einige Standards, auf die man sich verlassen kann: Die AppSettings und die ConnectionStrings sind in aller Regel dort, wo man sie erwartet und der ConfigurationManager bietet entsprechende Properties für den Zugriff an. Die Microsoft Extensions haben solche Vorgaben nicht fest verbaut; man muss sich schlicht darauf verlassen, dass dies als Konvention weiter besteht.
Kein Dependency Injection: Das Konzept der Microsoft Extensions geht davon aus, dass zuerst die Konfiguration aufgebaut wird, danach Dependency Injection. Will man also Dependency Injection für den eigenen Provider nutzen, muss man auch hier ein zweistufiges Konzept umsetzen und etwa nachträglich den DI-Container an den eigenen Provider übergeben.
Fazit
Man kann das Konfigurationssystem der Microsoft Extensions eigentlich nur als gelungen bezeichnen: Es hat klare Konzepte, ist sauber strukturiert, flexibel erweiterbar und bietet alles, was man braucht.
Nicht zuletzt räumt es mit den in System.Configuration vorhandenen Problemen auf:
- Typisierung lässt sich leicht und über Standardwege erreichen, man muss nicht auf proprietäre Basisklassen setzen.
- Konfiguration ist nicht auf XML-Dateien (bzw. eine XML-Datei) beschränkt.
- Man kann mehrere Quellen kombinieren.
- UnitTests lassen sich problemlos umsetzen.
Die Einschränkungen sind dabei meiner Ansicht nach vernachlässigbar. Insbesondere sind sie keine Showstopper, sondern können mit eigenen Lösungsansätzen leicht umgangen werden – sie sind eben nur nicht im eigentlichen Konzept verankert.
Hier noch die relevanten Links zu den Quelltexten und zur Dokumentation: