.NET • .NET Core • Microsoft Extensions

Options (1) – Der Pflichtteil

25. Oktober 2022

Konzeptionell und funktional bieten die Extensions Options mehr, als auf den ersten Blick erkennbar ist.

In einem früheren Beitrag habe ich bereits System.Configuration und die Pendants in den Microsoft Extensions – Configuration und Options – gegenübergestellt. Allerdings sind die Microsoft Extensions Options dabei etwas zu kurz gekommen. Das holen wir jetzt nach…

In diesem Beitrag betrachte ich den Pflichtteil, der am ehesten mit System.Configuration vergleichbar ist. In zwei weiteren Beiträgen werde ich Options mit mehreren Instanzen und dynamisch berechnete Options beschreiben.

autor Alexander Jung

Alexander Jung

Chief eXpert Alexander.Jung@sdx-ag.de

Zum Abholen…

Microsoft Extensions Options (ab jetzt einfach „Options“) sind konzeptionell aus der Aufteilung von System.Configuration (Konfigurationsdatei + typisierte Sections) in Microsoft Extensions Configuration (Bereitstellung von Name/Value Pairs aus unterschiedlichen Konfigurationsquellen) und Microsoft Extensions Options (Typisierung – nicht nur auf Basis von Konfiguration) hervorgegangen.

Die passende Dokumentation findet sich hier:

Wichtig zum Verständnis: Options arbeiten eng mit Microsoft Extensions Configuration zusammen und sind das Pendant zu System.Configuration Sections. Sowohl der konzeptionelle Ansatz als auch die funktionalen Möglichkeiten gehen aber deutlich darüber hinaus.

Aber der Reihe nach…

 

Options definieren und referenzieren

Das häufigste Szenario sind einmalig vorhandene Konfigurationswerte für eine Funktionalität. Diese lassen sich einfach über ein simples POCO abbilden:

public class TestServiceOptions
{
    public const string SectionName = "TestService";

    public string Key1 { get; set; } = “Value1”
    public int Key2 { get; set; }
    public string Key3 { get; set; }
}

Konvention in den Microsoft Extensions ist, dass die Options-Klasse als {Feature}Options benannt wird.

Eine zusätzliche Konvention, die ich für mich etabliert habe: Ich packe den Namen der Section in der Konfigurationsdatei als Konstante SectionName in die Klasse. (Wir werden später noch andere Konstanten sehen). Und üblicherweise – aber nicht immer – ist dies der Klassenname (also das Feature) ohne den Zusatz „Options“.

Die Nutzung ist ebenso einfach: Man nutzt IOptions<> mit der der Options-Klasse als Typargument:

public class TestService
{
    private readonly IOptions<TestServiceOptions> _options;

    public TestService(IOptions<TestServiceOptions> options)
    {
        _options = options;
    }

    public string GetInfo()
    {
        var o = _options.Value;
        return $"Einstellungen: {o.Key1}, {o.Key2}, {o.Key3}.";
    }
}

Daraus folgt die offensichtliche Frage: Woher kommen die Werte?

Options mit Werten versorgen

Options werden über Dependency Injection registriert, wobei es auch mehrere Registrierungen zu einer Options-Klasse geben kann. Sobald die Options abgerufen werden, durchlaufen sie einen Prozess, bei dem alle via Dependency Injection registrierten Anweisungen der Reihe nach abgearbeitet werden. Diese Registrierungen können die Options per Code setzen oder auf die Konfiguration mappen – für beides gibt es entsprechende Configure()-Methoden.

Zum Setzten per Code wird einfach ein Lambda übergeben:

services.Configure<TestServiceOptions>(o => o.Key1 = “NewValue1”);

Das Mapping auf die Konfiguration geschieht durch Übergabe der entsprechenden Section:

services.Configure<TestServiceOptions>(configuration.GetSection(TestServiceOptions.SectionName));

Dabei werden die in der Konfiguration vorhandenen Werte gesetzt. Nicht vorhandene Werte werden aber nicht angefasst, das ist also keine Serialisierung.

Tip: Dafür muss eine Nuget-Referenz auf Microsoft.Extensions.Options. ConfigurationExtensions gesetzt werden.

Hinweis: Es gibt noch eine dritte Methode (über IConfigureOptions), die aber seltener benötigt wird. Wir werden in einem nächsten Beitrag ein Beispiel dazu sehen.

Üblicherweise kapselt man diese Aufrufe in eine Registrierungsmethode für das Feature, wie im folgenden Beispiel:

// 1
namespace Microsoft.Extensions.DependencyInjection
{
    // 2
    public static class TestServiceServiceCollectionExtensions
    {
        // 3 + 4
        public static IServiceCollection AddTestService(
            this IServiceCollection services,
            IConfiguration configuration,
            Action<TestServiceOptions> configureOptions)
        {
            services.AddTransient<TestService>();

            // 5 
            services.Configure<TestServiceOptions>(configuration.GetSection(TestServiceOptions.SectionName));
            services.Configure(configureOptions);

            return services;
        }
    }
}

Da die Konventionen der Microsoft Extensions immer noch relativ neu sind, möchte ich diese nochmal durchgehen:

  • Zu 1: Erweiterungsmethoden liegen im Namespace der Schnittstelle, die erweitert wird, hier für IServiceCollection. Dadurch reicht es, eine Referenz auf die Bibliothek zu setzen; zusätzliche using-Deklarationen sind nicht notwendig.
  • Zu 2: Die Namenskonvention der Erweiterungsklasse ist {Feature}{erweiterte Schnittstelle ohne I}Extensions.
    • Das ist manchmal etwas akademisch (hier etwa die Dopplung von “Service” im Namen). Aber die Einhaltung macht das Auffinden und Zuordnen dieser Klassen einfacher.
  • Zu 3: Die Erweiterungsmethode für Dependency Injection-Registrierungen folgt der Konvention:
    • Name der Methode ist Add{Feature}.
    • Die Methode gibt die übergebene Schnittstelle zurück (fluent interface pattern).
      (Es gibt Ausnahmen, etwa wenn ein Builder-Pattern zum Einsatz kommt.)
  • Zu 4: Wenn Options beteiligt sind, kommen Parameter für IConfiguration, sowie eine Action namens configureOptions für die Options-Klasse hinzu. (Plus entsprechende Überladungen für nur einen oder keinen dieser Parameter – das habe ich mir hier gespart.)
  • Zu 5: Üblicherweise wird zuerst services.Configure() mit der passenden Section aus der Konfiguration aufgerufen, dann für den Delegate.

Der Grund für Punkte 4 und 5 ist, die Options je nach Bedarf setzen zu können: Für die normale Anwendung eher in einer .json-Datei, also über IConfiguration, für UnitTests eher über den Delegate. Falls man beides benötigt, hat man per Code das letzte Wort.

Wie bei allen Konventionen gilt: Das ist kein Zwang, man könnte das auch anders machen. Aber das ist die etablierte Vorgehensweise und im Sinne von Konsistenz (und damit der Erwartungshaltung der Nutzer) sollte man nicht unnötig davon abweichen.

Options zur Laufzeit

Beginnen wir gleich mit einem Beispiel zur Nutzung:

[TestMethod]
public void Test_TestService()
{
    var host = Host
        .CreateDefaultBuilder()
        .ConfigureAppConfiguration(ConfigureAppConfiguration)
        .ConfigureServices(ConfigureServices)
        .Build();

    var options = host.Services.GetService<IOptions<TestServiceOptions>>();
    var o = options.Value;
    Assert.AreEqual("value1", o.Key1);
    Assert.AreEqual(12, o.Key2);
    Assert.AreEqual("from code", o.Key3);
}

Der Test verwendet Microsoft Extension Hosting um alles vorzubereiten, ruft die Options ab. Bis jetzt ist noch nicht viel passiert, erst wenn mit options.Value der Wert der Options abgerufen wird, wird ein Erzeugungsprozess durchlaufen wird, den wir uns gleich genauer anschauen.

Die Nutzung des HostBuilders in der Test-Methode hat sich für mich bewährt.

Man kann Konfigurationen und Dependency Injection-Registrierungen und Konventionen nutzen, die auch von der Anwendung selbst genutzt werden.

Erzeugungsprozess

Werden die Options zur Laufzeit abgerufen, dann durchläuft der Aufbau der Werte folgenden Prozess:

Erster Schritt ist die Erzeugung einer Instanz, wobei die üblichen Initialisierungen durch den Konstruktor vorgenommen werden. An sich ein trivialer Hinweis, aber man sollte die Gelegenheit nutzen, und die Eigenschaften mit sinnvollen Werten vorbelegen.

Danach durchlaufen sie alle via Dependency Injection registrierten Configure()-Anweisungen, in der Reihenfolge der Registrierung. Spätere Registrierungen können frühere also übersteuern.

Anschließend werden die PostConfigurations und die Validations abgearbeitet.

PostConfigurations sind sinnvoll für auf Basis der bisherigen Einstellungen berechnete Werte. Validations stellen die Validität sicher (nomen est omen). Auf beide gehe ich hier nicht im Detail ein; sie sind für das Verständnis des Konzeptes nachrangig und die Dokumentation erklärt das ausführlich.

Durch diesen Prozess kann man den Wert einer Options-Klasse sehr flexibel schrittweise aufbauen: Aus den per Konstruktor gesetzten Standardwerten, den Werten aus diversen Konfigurationsanbietern (Kommandozeile, Umgebungsvariablen, .json-Dateien) und per Code. Und das je nach Anwendungsfall. Die Anwendung selbst würde etwa Konfigurationsdateien verwenden, die man durch Kommandozeilenparameter übersteuern kann, in UnitTests verwendet man Lambda-Ausdrücke.

 

Zugriff auf die Options

Jedes Mal wenn ich den Wert über IOptions<> abrufe, bekomme ich den Wert, der sich aus dem gerade beschriebenen Ablauf ergibt. Beim ersten Aufruf wird der beschriebene Prozess durchlaufen, danach wird das Ergebnis aus Performance-Gründen gecacht.

Allerdings können sich Konfigurationswerte auch ändern. Der JSON-Provider beispielsweise erkennt, ob sich die Datei geändert hat und lädt sie ggf. neu. Daher bieten die Options drei verschiedene Schnittstellen zum Zugriff an:

  • IOptions<>: Rufe ich die Options erneut über IOptions<> ab, bekomme ich wie beschrieben den gecachten, jetzt also möglicherweise veralteten Wert.
  • IOptionsMonitor<>: Die vollständige Kontrolle erhält man mit IOptionsMonitor<>. Hier werden die Werte zwar aus Performance-Gründen auch gecacht, allerdings wird dieser Cache bei einer Konfigurationsänderung verworfen. Außerdem bietet die Schnittstelle die Möglichkeit, einen Delegate zu registrieren, um sich bei Änderungen benachrichtigen zu lassen.
  • IOptionsSnapshot<>: Nur der Vollständigkeit halber (Erklärung unten): Verwende ich IOptionsSnapshot<>, so werden die Werte im Kontext des Scopes berechnet und gecacht. Web-Anwendungen, die einen Scope je Request erzeugen, erhalten also immer einen relativ aktuellen Wert.
Welche Schnittstelle verwenden?

IOptions<> ist eine völlig akzeptable Wahl, wenn Konfigurationsänderungen nicht zu erwarten sind. Bei WPF- oder Kommandozeilen-Anwendungen ist eine Konfigurationsänderung zur Laufzeit vermutlich keine Anforderung. Das gilt auch für Web-Anwendungen oder Windows Services, wenn Änderungen dort ohnehin nur über Deployment-Prozesse vorgenommen werden und einen Neustart nach sich ziehen.

 

Wenn Konfigurationsdaten sich hingegen ändern können, sollte IOptionsMonitor<> verwendet und die Werte jedes Mal vor der Verwendung (nicht im Konstruktor!) gelesen werden.

Nebenbei: Dies ist der Grund, warum man sich im Konstruktor immer – wieder per Konvention – das IOptionsMonitor<>, aber auch das IOptions<> – und nicht den abgerufenen Wert – merken sollte.

Nun kann es Fälle geben, in denen das nicht ausreicht. Zum Beispiel eine Hintergrundverarbeitung, die am Anfang einen Connection-String oder den Grad der Parallelisierung aus der Konfiguration liest, und dann in eine Verarbeitungsschleife geht. Über IOptionsMonitor<> kann man sich für solche Fälle über die Änderung informieren lassen und dann ggf. die Verarbeitung abbrechen und neu starten.

Da man die genannten Randbedingungen in wiederverwendbaren Bibliotheken oft nicht vorhersagen kann, sollte man dort gleich auf IOptionsMonitor<> setzen.

 

Bleibt noch der letzte Kandidat und die oben versprochene Erklärung: IOptionsSnapshot<> klingt erstmal nach einem guten Kompromiss, ist aber in der Praxis (meiner Erfahrung nach) ohne Bedeutung. Die Options in jedem Scope zu berechnen, ist mit einem Performance-Hit verbunden und IOptionsMonitor<> stellt faktisch die gleichen aktualisierten Werte bereit und ist noch dazu effizienter und flexibler. Man kann die Existenz dieser Schnittstelle also getrost ignorieren.

 

Fazit

Wenn man noch nicht mit Options gearbeitet hat, muss man sich etwas mit den Grundkonzepten auseinandersetzen, um das volle Potential nutzen zu können. Hat man die aber erstmal verinnerlicht, dann ist der Ansatz mit simplen POCOs einfach in der Umsetzung und flexibel in der Nutzung. Kein Vergleich zu den Purzelbäumen, die man bei System.Configuration schlagen muss.

Und um das noch anzusprechen: Dank dieser Einfachheit gibt es für die Verwendung einer generischen „appSettings“ Section in der Konfigurationsdatei keine Rechtfertigung mehr!

PS: Dies waren die Grundlagen; die folgenden Beiträge werden sich spezielleren Anwendungsfällen widmen.