.NET • .NET Core • Microsoft Extensions

Options (2) – mehrere Instanzen

1. November 2022

named options – die Lösung, wenn mehr als ein Satz an Konfigurationswerten in einer Konfigurationsdatei benötigt wird.

Im ersten Beitrag wurden zwei typische Anwendungsfälle für Microsoft Extensions Options nicht betrachtet:

  • Options mit mehreren Instanzen
  • Dynamisch berechnete Options

Diese beiden Anwendungsfälle kommen zwar nicht jeden Tag vor, aber doch oft genug, um sich mit ihnen zu beschäftigen.

Dieser Beitrag beschäftigt sich mit dem ersten Fall, für den zweiten kommt ein weiterer Beitrag.

autor Alexander Jung

Alexander Jung

Chief eXpert Alexander.Jung@sdx-ag.de

Options mit mehreren Instanzen

Der letzte Beitrag betrachtete den häufigsten, aber auch einfachsten Fall: Ich habe eine Funktionalität, zu der ich einen Satz an Konfigurationswerten in einer Konfigurationsdatei hinterlegen will. Eine Section, eine Datenstruktur. Einfach.

Es gibt jedoch genügend Beispiele für Fälle, in denen ich zwar eine Datenstruktur (d.h. die Options-Klasse) habe, aber ich möchte mehr wie einen Satz an Werten bereitstellen. Typische Beispiele:

  • Ich verarbeite CSV-Dateien aus unterschiedlichen Quellen. Die Options-Klasse ist die gleiche, aber ich habe unterschiedliche Werte je Quelle.
  • Ich möchte Emails je nach Kontext an unterschiedliche Empfänger senden.
  • Ich möchte eigene Konfigurationswerte zu meinen ASP.NET Core Controllern oder WCF-Services hinterlegen.

Hier kommt das bisher Beschriebene an seine Grenzen, aber Microsoft Extensions Options bietet hier eine Lösung an: named options.

Beispielanforderung

Nehmen wir ein konkretes Beispiel: Ich habe das Hosting von WCF-Services implementiert und möchte konfigurieren, ob beim Start die veröffentlichten URLs protokolliert werden.

Hinweis: Ein tieferes Verständnis der WCF sollte für das Folgende nicht nötig sein.

In Anlehnung an die klassische Konfiguration der WCF und gängige Konventionen der Microsoft Extensions hätte ich gerne folgende Konfiguration:

"serviceModel": {
    "services": {
        "default": {
            "LogEndpoints": true
        },
        "SDX.Flurfunk.WcfService.Service1": {
            "LogEndpoints": false
        }
    }
}

Gleichartige Einstellungen in eine übergreifende Section für das Feature zu packen – um sie von Logging und anderen Themen unterscheiden zu können – ist in jedem Fall guter Stil.

Wie man sieht, möchte ich gerne einen Standardwert für alle Services hinterlegen, sowie bei einzelnen Services davon abweichen.

Die Options-Klasse heißt ServiceHostOptions, ich habe die übergeordnete Section aber serviceModel (und nicht serviceHost“) genannt und packe noch services darunter. Ich weiche von der üblichen Konvention ab, weil dies die Struktur in der klassischen Konfigurationsdatei ist.

Die Klasse ist denkbar einfach:

public class ServiceHostOptions
{
    public const string ParentSection = "serviceModel:services";
    public const string DefaultSection = "default";

    public bool LogEndpoints { get; set; } = true;
}

Die Registrierung der WCF Services soll über entsprechende Methoden stattfinden (die wir gleich noch bereitzustellen müssen):

private static IServiceCollection AddWcfServices(this IServiceCollection services, IConfiguration configuration)
{
    services.AddServiceHost<Service1>(configuration);
    services.AddServiceHost<Service2>(configuration);
    services.AddServiceHost<ServiceGeheim>(configuration, o => o.LogEndpoints = false);
    return services;
}

Um es interessanter zu machen: ServiceGeheim soll unabhängig von der Konfiguration nicht protokolliert werden…

Soweit meine Anforderungen. Abgesehen von der Möglichkeit, meine WCF-Services unabhängig voneinander zu konfigurieren, unterscheidet sich das nicht vom einfachen Fall, der im letzten Beitrag betrachtet wurde.

Benannte Options

Im letzten Beitrag wurden Options einfach anhand des Typs registriert:

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

Das führt aber zur Laufzeit zu genau einer Ausprägung der Werte; wir brauchen aber unterschiedliche Werte für jeden WCF-Service.

Hier kommt die Möglichkeit ins Spiel, Options mit einem Namen zu registrieren:

services.Configure<ServiceHostOptions>(name, configuration.GetSection(sectionName));

Der Trick besteht nun darin, den Namen des Services in name und sectionName zu verwenden.

Wenn ich die Options dann unter ihrem Namen abhole (dazu gleich mehr), findet der im letzten Beitrag beschriebene Ablauf zum Aufbau der Options-Werte statt – aber für jede benannte Instanz getrennt. Dabei kommt noch ein Sonderfall zum Tragen: Registrierungen mit dem Namen null werden für alle Options-Instanzen dieses Typs – unabhängig vom Namen – verwendet. Das ist ideal, um die unter default abgelegten Werte als Standardwerte für alle Varianten zu setzen.

Im Detail…

Registrierung

In meinem Beispiel bietet sich der Klassenname der WCF-Service-Klasse als Name an. Zusätzlich sollen die Standardwerte unter default abgelegt werden. All dies wird in der Methode AddServiceHost<>() umgesetzt:

public static IServiceCollection AddServiceHost<TService>(
    this IServiceCollection services,
    IConfiguration configuration,
    Action<ServiceHostOptions> configureOptions)
    where TService : class
{
    // 1 
    var parentSection = configuration.GetSection(ServiceHostOptions.ParentSection);

    // 5 
    if (Moniker.IsFirstTime(services)) 
        // 2
        services.Configure<ServiceHostOptions>(null, parentSection.GetSection(ServiceHostOptions.DefaultSection));

    // 3
    services.AddHostedService<ServiceHostHostedService<TService>>();
    services.AddScoped<TService>();

    // 4
    var name = typeof(TService).FullName;
    services.Configure<ServiceHostOptions>(name, parentSection.GetSection(name));
    services.Configure<ServiceHostOptions>(name, configureOptions);
    return services;
}
  • Zunächst bestimme ich die ParentSection (1), unter der sich das weitere Geschehen abspielt.
  • Da die Reihenfolge der Registrierungen wichtig ist, müssen wir zuerst die Standard-Section registrieren (2); wie oben beschrieben wird hier als Name null übergeben. (Es gibt auch eine ConfigureAll<>()-Methode, aber die macht das Gleiche.)
  • Das Hosting des WCF-Service selbst wird mit (3) sichergestellt, das ist für das Verständnis der Options aber nicht weiter relevant.
  • In (4) wird der Name der Options aus dem Klassennamen der WCF-Service-Klasse gebildet, anschließend erfolgen die Registrierungen unter diesem Namen.

Eines ist dabei zu beachten: Beim Aufbau einer benannten Options-Instanz werden die Registrierungen in der angegebenen Reihenfolge durchlaufen – sowohl die unter null abgelegten Registrierungen, als auch die für den gerade benötigten Namen. Da die Methode jedoch mehrfach – für jeden WCF Service – aufgerufen wird, würde (2) aus dem zweiten Aufruf die Teile (4) aus dem ersten Aufruf wieder überschreiben. Die Standard-Werte müssen als erstes, aber nur genau ein mal registriert werden. Man könnte dies in einem separaten Methodenaufruf tun, das ist allerdings wenig nutzerfreundlich. (Ein separater Aufruf bietet sich an, wenn man auch die Standardwerte über einen Callback setzten will.)

Hier habe ich aber eine Prüfung eingebaut (5), ob diese Registrierung bereits stattgefunden hat. Das erledigt eine kleine Hilfsklasse:

private class Moniker
{
    public static bool IsFirstTime(IServiceCollection services)
    {
        var registered = services.Where(sd => sd.ImplementationType == typeof(Moniker)).Any();
        if (registered)
            return false;
        services.AddTransient<Moniker>();
        return true;
    }
}

Sich selbst in die Collection einzutragen ist ein einfacher Weg, die Information zu verwalten. Die alternative Nutzung einer statischen Variable würde in UnitTests Probleme machen.

Verwendung

Bleibt noch die Frage zu klären, wie man an eine benannte Instanz der Options herankommt. Hier hilft IOptions<> nicht weiter, aber IOptionsMonitor<> bietet eine Methode Get(string name) an.

Der Hosted Service, der für den WCF-Service registriert wurde, nutzt dies:

public class ServiceHostHostedService<TService> : IHostedService
    where TService : class
{
    public ServiceHostHostedService(
        ILogger<ServiceHostHostedService<TService>> logger,
        IOptionsMonitor<ServiceHostOptions> optionsMonitor)
    {
        _logger = logger;
        _optionsMonitor = optionsMonitor;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _serviceHost = CreateServiceHost();
        _serviceHost.Open();

        // 1
        var name = typeof(TService).FullName;
        var o = _optionsMonitor.Get(name);
        if (o.LogEndpoints)
            _serviceHost.LogEndpoints(_logger);

        return Task.CompletedTask;
    }

Mit dem gleichen Namen, der auch in der Registrierung verwendet wurde – der Name der WCF-Service-Klasse (1) –, lässt sich der Wert für diesen WCF-Service abrufen.

Und der Beleg, dass das Ganze auch funktioniert:

WCF-Services-get-value (SDX AG)

Standard für die Ausgabe ist true (sowohl per Konstruktor als auch über die Konfiguration), Service1 ist per Konfiguration ausgeschlossen, ServiceGeheim per Code.

Fazit

Dieser Beitrag hat gezeigt, wie flexibel die gleiche Options-Klasse mit unterschiedlichen Werten verwenden kann, um mir die Konfiguration leichter zu machen.

Der nächste Beitrag widmet sich dynamisch berechneten Options und rundet das Thema ab.