Patrón de opciones en .NET

El patrón de opciones usa clases para proporcionar acceso fuertemente tipado a grupos de configuraciones relacionadas. Cuando los valores de configuración están aislados por escenario en clases independientes, la aplicación se ajusta a dos principios de ingeniería de software importantes:

Las opciones también proporcionan un mecanismo para validar los datos de configuración. Para obtener más información, consulte la sección Opciones de validación.

Enlace de configuración jerárquica

La mejor manera de leer valores de configuración relacionados es usar el patrón de opciones. El patrón de opciones se puede aplicar mediante la interfaz IOptions<TOptions>, donde el parámetro de tipo genérico TOptions está restringido a class. IOptions<TOptions> se puede proporcionar posteriormente mediante la inserción de dependencias. Para más información, vea Inserción de dependencias en .NET.

Por ejemplo, para leer los valores de configuración resaltados de un archivo appsettings.json :

{
    "SecretKey": "Secret key value",
    "TransientFaultHandlingOptions": {
        "Enabled": true,
        "AutoRetryDelay": "00:00:07"
    },
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft": "Warning",
            "Microsoft.Hosting.Lifetime": "Information"
        }
    }
}

Cree la siguiente clase TransientFaultHandlingOptions:

public sealed class TransientFaultHandlingOptions
{
    public bool Enabled { get; set; }
    public TimeSpan AutoRetryDelay { get; set; }
}

Al usar el patrón de opciones, una clase de opciones:

  • Debe ser no abstracta con un constructor público sin parámetros.
  • Debe contener propiedades de lectura y escritura para enlazar (los campos no están enlazados)

El código siguiente forma parte del archivo de C# Program.cs y:

  • Llama a ConfigurationBinder.Bind para enlazar la clase TransientFaultHandlingOptions a la sección "TransientFaultHandlingOptions".
  • Muestra los datos de configuración.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using ConsoleJson.Example;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Configuration.Sources.Clear();

IHostEnvironment env = builder.Environment;

builder.Configuration
    .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
    .AddJsonFile($"appsettings.{env.EnvironmentName}.json", true, true);

TransientFaultHandlingOptions options = new();
builder.Configuration.GetSection(nameof(TransientFaultHandlingOptions))
    .Bind(options);

Console.WriteLine($"TransientFaultHandlingOptions.Enabled={options.Enabled}");
Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={options.AutoRetryDelay}");

using IHost host = builder.Build();

// Application code should start here.

await host.RunAsync();

// <Output>
// Sample output:

En el código anterior, el archivo de configuración JSON tiene su sección "TransientFaultHandlingOptions" enlazada a la instancia TransientFaultHandlingOptions. Esto hidrata las propiedades de los objetos C# con los valores correspondientes de la configuración.

ConfigurationBinder.Get<T> enlaza y devuelve el tipo especificado. Puede ser más conveniente usar ConfigurationBinder.Get<T> que ConfigurationBinder.Bind. En el código siguiente se muestra cómo puede usar ConfigurationBinder.Get<T> con la clase TransientFaultHandlingOptions:

var options =
    builder.Configuration.GetSection(nameof(TransientFaultHandlingOptions))
        .Get<TransientFaultHandlingOptions>();

Console.WriteLine($"TransientFaultHandlingOptions.Enabled={options.Enabled}");
Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={options.AutoRetryDelay}");

En el código anterior, se usa ConfigurationBinder.Get<T> para adquirir una instancia del objeto TransientFaultHandlingOptions con sus valores de propiedad rellenados desde la configuración subyacente.

Importante

La clase ConfigurationBinder expone varias API, como .Bind(object instance) y .Get<T>(), que no están restringidas a class. Al utilizar cualquiera de las interfaces de opciones, debe cumplir las restricciones de la clase de opciones mencionadas anteriormente.

Un enfoque alternativo a la hora de usar el patrón de opciones consiste en enlazar la sección "TransientFaultHandlingOptions" y agregarla al contenedor del servicio de inserción de dependencias. En el siguiente código se agrega TransientFaultHandlingOptions al contenedor de servicios con Configure y se enlaza a la configuración:

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.Configure<TransientFaultHandlingOptions>(
    builder.Configuration.GetSection(
        key: nameof(TransientFaultHandlingOptions)));

En el ejemplo anterior, builder es una instancia de HostApplicationBuilder.

Sugerencia

El parámetro key es el nombre de la sección de configuración que se va a buscar. No tiene que coincidir con el nombre del tipo que la representa. Por ejemplo, podría tener una sección denominada "FaultHandling" y podría estar representada por la clase TransientFaultHandlingOptions. En esta instancia, en su lugar, debe pasar "FaultHandling" a la función GetSection. El operador nameof se utiliza por comodidad cuando la sección con nombre coincide con el tipo al que corresponde.

A partir del código anterior, el siguiente código lee las opciones de posición:

using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

public sealed class ExampleService(IOptions<TransientFaultHandlingOptions> options)
{
    private readonly TransientFaultHandlingOptions _options = options.Value;

    public void DisplayValues()
    {
        Console.WriteLine($"TransientFaultHandlingOptions.Enabled={_options.Enabled}");
        Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={_options.AutoRetryDelay}");
    }
}

En el código anterior, los cambios en el archivo de configuración de JSON producidos una vez iniciada la aplicación no se leen. Para leer los cambios una vez iniciada la aplicación, use IOptionsSnapshot o IOptionsMonitor para supervisar los cambios a medida que se producen y reaccionar en consecuencia.

Interfaces de opciones

IOptions<TOptions>:

IOptionsSnapshot<TOptions>:

IOptionsMonitor<TOptions>:

IOptionsFactory<TOptions> es responsable de crear nuevas instancias de opciones. Tiene un solo método Create. La implementación predeterminada toma todas las instancias registradas de IConfigureOptions<TOptions> y IPostConfigureOptions<TOptions>, y establece todas las configuraciones primero, seguidas de las configuraciones posteriores. Distingue entre IConfigureNamedOptions<TOptions> y IConfigureOptions<TOptions>, y solo llama a la interfaz adecuada.

IOptionsMonitorCache<TOptions> se usa por IOptionsMonitor<TOptions> para almacenar en caché las instancias de TOptions. IOptionsMonitorCache<TOptions> invalida instancias de opciones en la supervisión para que se pueda volver a calcular el valor (TryRemove). Los valores se pueden introducir manualmente y mediante TryAdd. Se usa el método Clear cuando todas las instancias con nombre se deben volver a crear a petición.

IOptionsChangeTokenSource<TOptions> se usa para capturar el objeto IChangeToken que realiza un seguimiento de los cambios en la instancia de TOptions subyacente. Para obtener más información sobre los primitivos de token de cambio, vea Notificaciones de cambios.

Ventajas de las interfaces de opciones

El uso de un tipo de contenedor genérico proporciona la capacidad de desacoplar la duración de la opción del contenedor de DI. La interfaz IOptions<TOptions>.Value proporciona una capa de abstracción, incluidas las restricciones genéricas, en el tipo de opciones. Esto proporciona las siguientes ventajas:

  • La evaluación de la T instancia de configuración se aplaza al acceso de IOptions<TOptions>.Value, en lugar de cuando se inserta. Esto es importante porque puede utilizar la opción T desde varios lugares y elegir la semántica de duración sin cambiar nada en T.
  • Al registrar opciones de tipo T, no es necesario registrar explícitamente el tipo T. Esto se ofrece por comodidad cuando se crea una biblioteca con valores predeterminados sencillos y no se desea obligar al autor de la llamada a registrar opciones en el contenedor de DI con una duración específica.
  • Desde la perspectiva de la API, permite restricciones en el tipo T (en este caso, T está restringido a un tipo de referencia).

Uso de IOptionsSnapshot para leer datos actualizados

Al usar IOptionsSnapshot<TOptions>, las opciones se calculan una vez por solicitud al acceder y se almacenan en caché durante la vigencia de la solicitud. Los cambios en la configuración se leen tras iniciarse la aplicación al usar proveedores de configuración que admiten la lectura de valores de configuración actualizados.

La diferencia entre IOptionsMonitor y IOptionsSnapshot es que:

  • IOptionsMonitor es un servicio singleton que recupera los valores de las opciones actuales en cualquier momento, lo que resulta especialmente útil en las dependencias singleton.
  • IOptionsSnapshot es un servicio con ámbito y proporciona una instantánea de las opciones en el momento en que se construye el objeto IOptionsSnapshot<T>. Las instantáneas de opciones están diseñadas para usarlas con dependencias transitorias y con ámbito.

El código siguiente usa IOptionsSnapshot<TOptions>.

using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

public sealed class ScopedService(IOptionsSnapshot<TransientFaultHandlingOptions> options)
{
    private readonly TransientFaultHandlingOptions _options = options.Value;

    public void DisplayValues()
    {
        Console.WriteLine($"TransientFaultHandlingOptions.Enabled={_options.Enabled}");
        Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={_options.AutoRetryDelay}");
    }
}

El código siguiente registra una instancia de configuración en la que se enlaza TransientFaultHandlingOptions:

builder.Services
    .Configure<TransientFaultHandlingOptions>(
        configurationRoot.GetSection(
            nameof(TransientFaultHandlingOptions)));

En el código anterior, el método Configure<TOptions> se usa para registrar una instancia de configuración con la que TOptions se enlazará y actualiza las opciones cuando la configuración cambie.

IOptionsMonitor

Para usar el monitor de opciones, los objetos de opciones se configuran de la misma manera desde una sección de configuración.

builder.Services
    .Configure<TransientFaultHandlingOptions>(
        configurationRoot.GetSection(
            nameof(TransientFaultHandlingOptions)));

En el ejemplo siguiente se usa IOptionsMonitor<TOptions>:

using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

public sealed class MonitorService(IOptionsMonitor<TransientFaultHandlingOptions> monitor)
{
    public void DisplayValues()
    {
        TransientFaultHandlingOptions options = monitor.CurrentValue;

        Console.WriteLine($"TransientFaultHandlingOptions.Enabled={options.Enabled}");
        Console.WriteLine($"TransientFaultHandlingOptions.AutoRetryDelay={options.AutoRetryDelay}");
    }
}

En el código anterior, los cambios en el archivo de configuración de JSON producidos una vez iniciada la aplicación se leen.

Sugerencia

Algunos sistemas de archivos, como contenedores de Docker y recursos compartidos de red, no pueden enviar notificaciones de cambio de forma confiable. Al usar la interfaz IOptionsMonitor<TOptions> en estos entornos, establezca la variable de entorno DOTNET_USE_POLLING_FILE_WATCHER en 1 o true para sondear el sistema de archivos en busca de cambios. El intervalo en el que se sondean los cambios es cada cuatro segundos y no se puede configurar.

Para obtener más información sobre los contenedores de Docker, vea Incluir una aplicación de .NET Core en un contenedor.

Compatibilidad de opciones con nombre con IConfigureNamedOptions

Opciones con nombre:

  • son útiles cuando varias secciones de configuración se enlazan a las mismas propiedades.
  • Distinguen mayúsculas de minúsculas.

Fíjese en el siguiente archivo appsettings.json:

{
  "Features": {
    "Personalize": {
      "Enabled": true,
      "ApiKey": "aGEgaGEgeW91IHRob3VnaHQgdGhhdCB3YXMgcmVhbGx5IHNvbWV0aGluZw=="
    },
    "WeatherStation": {
      "Enabled": true,
      "ApiKey": "QXJlIHlvdSBhdHRlbXB0aW5nIHRvIGhhY2sgdXM/"
    }
  }
}

En lugar de crear dos clases para enlazar Features:Personalize y Features:WeatherStation, se usará la clase siguiente para cada sección:

public class Features
{
    public const string Personalize = nameof(Personalize);
    public const string WeatherStation = nameof(WeatherStation);

    public bool Enabled { get; set; }
    public string ApiKey { get; set; }
}

El siguiente código configura las opciones con nombre:

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// Omitted for brevity...

builder.Services.Configure<Features>(
    Features.Personalize,
    builder.Configuration.GetSection("Features:Personalize"));

builder.Services.Configure<Features>(
    Features.WeatherStation,
    builder.Configuration.GetSection("Features:WeatherStation"));

En el código siguiente se muestran las opciones con nombre:

public class sealed Service
{
    private readonly Features _personalizeFeature;
    private readonly Features _weatherStationFeature;

    public Service(IOptionsSnapshot<Features> namedOptionsAccessor)
    {
        _personalizeFeature = namedOptionsAccessor.Get(Features.Personalize);
        _weatherStationFeature = namedOptionsAccessor.Get(Features.WeatherStation);
    }
}

Todas las opciones son instancias con nombre. Las instancias de IConfigureOptions<TOptions> se usan para seleccionar como destino la instancia de Options.DefaultName, que es string.Empty. IConfigureNamedOptions<TOptions> también implementa IConfigureOptions<TOptions>. La implementación predeterminada de IOptionsFactory<TOptions> tiene lógica para usar cada una de forma adecuada. La opción con nombre null se usa para seleccionar como destino todas las instancias con nombre, en lugar de una instancia con nombre determinada. ConfigureAll y PostConfigureAll usan esta convención.

API OptionsBuilder

OptionsBuilder<TOptions> se usa para configurar instancias TOptions. OptionsBuilder simplifica la creación de opciones con nombre, ya que es un único parámetro para la llamada AddOptions<TOptions>(string optionsName) inicial en lugar de aparecer en todas las llamadas posteriores. La validación de opciones y las sobrecargas ConfigureOptions que aceptan las dependencias de servicio solo están disponibles mediante OptionsBuilder.

OptionsBuilder se usa en la sección Opciones de validación.

Uso de servicios de DI para configurar opciones

Hay dos formas de acceder a los servicios desde la inserción de dependencias durante la configuración de opciones:

  • Si se pasa un delegado de configuración a Configure en OptionsBuilder<TOptions>. OptionsBuilder<TOptions> proporciona sobrecargas de Configure que permiten usar hasta cinco servicios para configurar las opciones:

    builder.Services
        .AddOptions<MyOptions>("optionalName")
        .Configure<ExampleService, ScopedService, MonitorService>(
            (options, es, ss, ms) =>
                options.Property = DoSomethingWith(es, ss, ms));
    
  • Si se crea un tipo que implementa IConfigureOptions<TOptions> o IConfigureNamedOptions<TOptions>, y se registra como un servicio.

Se recomienda pasar un delegado de configuración a Configure, ya que la creación de un servicio es más complicada. La creación de un tipo es equivalente a lo que el marco hace cuando se llama a Configure. La llamada a Configure registra una interfaz IConfigureNamedOptions<TOptions> genérica y transitoria, con un constructor que acepta los tipos de servicio genéricos especificados.

Opciones de validación

Opciones de validación permite que se validen los valores de opción.

Fíjese en el siguiente archivo appsettings.json:

{
  "MyCustomSettingsSection": {
    "SiteTitle": "Amazing docs from Awesome people!",
    "Scale": 10,
    "VerbosityLevel": 32
  }
}

La siguiente clase se enlaza a la sección de configuración "MyCustomSettingsSection" y aplica un par de reglas DataAnnotations:

using System.ComponentModel.DataAnnotations;

namespace ConsoleJson.Example;

public sealed class SettingsOptions
{
    public const string ConfigurationSectionName = "MyCustomSettingsSection";

    [Required]
    [RegularExpression(@"^[a-zA-Z''-'\s]{1,40}$")]
    public required string SiteTitle { get; set; }

    [Required]
    [Range(0, 1_000,
        ErrorMessage = "Value for {0} must be between {1} and {2}.")]
    public required int Scale { get; set; }

    [Required]
    public required int VerbosityLevel { get; set; }
}

En la clase SettingsOptions anterior, la propiedad ConfigurationSectionName contiene el nombre de la sección de configuración a la que se enlazará. En este escenario, el objeto options proporciona el nombre de su sección de configuración.

Sugerencia

El nombre de la sección de configuración es independiente del objeto de configuración al que se enlaza. Es decir, una sección de configuración denominada "FooBarOptions" se puede enlazar a un objeto de opciones denominado ZedOptions. Aunque puede ser habitual nombrarlos igual, no es necesario y puede provocar conflictos de nombres.

El código siguiente:

builder.Services
    .AddOptions<SettingsOptions>()
    .Bind(Configuration.GetSection(SettingsOptions.ConfigurationSectionName))
    .ValidateDataAnnotations();

El método de extensión ValidateDataAnnotations se define en el paquete NuGet Microsoft.Extensions.Options.DataAnnotations.

En el código siguiente se muestran los valores de configuración o errores de validación de informes:

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

public sealed class ValidationService
{
    private readonly ILogger<ValidationService> _logger;
    private readonly IOptions<SettingsOptions> _config;

    public ValidationService(
        ILogger<ValidationService> logger,
        IOptions<SettingsOptions> config)
    {
        _config = config;
        _logger = logger;

        try
        {
            SettingsOptions options = _config.Value;
        }
        catch (OptionsValidationException ex)
        {
            foreach (string failure in ex.Failures)
            {
                _logger.LogError("Validation error: {FailureMessage}", failure);
            }
        }
    }
}

El código siguiente aplica una regla de validación más compleja mediante un delegado:

builder.Services
    .AddOptions<SettingsOptions>()
    .Bind(Configuration.GetSection(SettingsOptions.ConfigurationSectionName))
    .ValidateDataAnnotations()
    .Validate(config =>
    {
        if (config.Scale != 0)
        {
            return config.VerbosityLevel > config.Scale;
        }

        return true;
    }, "VerbosityLevel must be > than Scale.");

La validación se produce en tiempo de ejecución, pero puede configurarla para que se produzca al iniciarse encadenando una llamada a ValidateOnStart:

builder.Services
    .AddOptions<SettingsOptions>()
    .Bind(Configuration.GetSection(SettingsOptions.ConfigurationSectionName))
    .ValidateDataAnnotations()
    .Validate(config =>
    {
        if (config.Scale != 0)
        {
            return config.VerbosityLevel > config.Scale;
        }

        return true;
    }, "VerbosityLevel must be > than Scale.")
    .ValidateOnStart();

A partir de .NET 8, puede usar una API alternativa, AddOptionsWithValidateOnStart<TOptions>(IServiceCollection, String), que habilita la validación al iniciarse para un tipo de opciones específico:

builder.Services
    .AddOptionsWithValidateOnStart<SettingsOptions>()
    .Bind(Configuration.GetSection(SettingsOptions.ConfigurationSectionName))
    .ValidateDataAnnotations()
    .Validate(config =>
    {
        if (config.Scale != 0)
        {
            return config.VerbosityLevel > config.Scale;
        }

        return true;
    }, "VerbosityLevel must be > than Scale.");

IValidateOptions para llevar a cabo una validación compleja

La siguiente clase implementa IValidateOptions<TOptions>:

using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

sealed partial class ValidateSettingsOptions(
    IConfiguration config)
    : IValidateOptions<SettingsOptions>
{
    public SettingsOptions? Settings { get; private set; } =
        config.GetSection(SettingsOptions.ConfigurationSectionName)
              .Get<SettingsOptions>();

    public ValidateOptionsResult Validate(string? name, SettingsOptions options)
    {
        StringBuilder? failure = null;
    
        if (!ValidationRegex().IsMatch(options.SiteTitle))
        {
            (failure ??= new()).AppendLine($"{options.SiteTitle} doesn't match RegEx");
        }

        if (options.Scale is < 0 or > 1_000)
        {
            (failure ??= new()).AppendLine($"{options.Scale} isn't within Range 0 - 1000");
        }

        if (Settings is { Scale: 0 } && Settings.VerbosityLevel <= Settings.Scale)
        {
            (failure ??= new()).AppendLine("VerbosityLevel must be > than Scale.");
        }

        return failure is not null
            ? ValidateOptionsResult.Fail(failure.ToString())
            : ValidateOptionsResult.Success;
    }

    [GeneratedRegex("^[a-zA-Z''-'\\s]{1,40}$")]
    private static partial Regex ValidationRegex();
}

IValidateOptions permite mover el código de validación dentro de una clase.

Nota

Este código de ejemplo se basa en el paquete NuGet Microsoft.Extensions.Configuration.Json.

Con el código anterior, la validación se habilita cuando se configuran servicios con el código siguiente:

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// Omitted for brevity...

builder.Services.Configure<SettingsOptions>(
    builder.Configuration.GetSection(
        SettingsOptions.ConfigurationSectionName));

builder.Services.TryAddEnumerable(
    ServiceDescriptor.Singleton
        <IValidateOptions<SettingsOptions>, ValidateSettingsOptions>());

Configuración posterior de las opciones

Establezca la configuración posterior con IPostConfigureOptions<TOptions>. Las tareas posteriores a la configuración se ejecutan una vez realizada toda la configuración de IConfigureOptions<TOptions>, y pueden resultar útiles en los escenarios en los que es necesario invalidar la configuración:

builder.Services.PostConfigure<CustomOptions>(customOptions =>
{
    customOptions.Option1 = "post_configured_option1_value";
});

PostConfigure está disponible para configurar posteriormente las opciones con nombre:

builder.Services.PostConfigure<CustomOptions>("named_options_1", customOptions =>
{
    customOptions.Option1 = "post_configured_option1_value";
});

Use PostConfigureAll para configurar posteriormente todas las instancias de configuración:

builder.Services.PostConfigureAll<CustomOptions>(customOptions =>
{
    customOptions.Option1 = "post_configured_option1_value";
});

Vea también