Evolución de la configuración de .NET





Todo programador se imaginó -bueno, o tal vez quiera imaginar- a sí mismo como piloto de avión, cuando tienes un proyecto enorme, un enorme panel de sensores, métricas e interruptores para ello, con el que puedes configurar fácilmente todo como debe. Bueno, al menos no correr para levantar manualmente el chasis usted mismo. Tanto las métricas como las gráficas están todas bien, pero hoy quiero hablaros de esos mismos tumblers y botones que pueden cambiar los parámetros del comportamiento de la aeronave, configurarlo.



La importancia de las configuraciones es difícil de subestimar. Todo el mundo utiliza uno u otro enfoque para configurar sus aplicaciones, y en principio no tiene nada de complicado, pero ¿es realmente así de sencillo? Propongo mirar el "antes" y el "después" en la configuración y entender los detalles: cómo funciona, qué nuevas características tenemos y cómo utilizarlas al máximo. Aquellos que no estén familiarizados con la configuración en .NET Core obtendrán los conceptos básicos, y aquellos que estén familiarizados obtendrán alimentos en los que pensar y utilizar nuevos enfoques en su trabajo diario.



Configuración previa a .NET Core



En 2002, se introdujo .NET Framework y, dado que era el momento del bombo XML, los desarrolladores de Microsoft decidieron "tenerlo en todas partes" y, como resultado, obtuvimos configuraciones XML que todavía están vivas. En la cabecera de la tabla, tenemos una clase ConfigurationManager estática a través de la cual obtenemos representaciones de cadena de valores de parámetros. La configuración en sí se parecía a esto:



<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <appSettings>
    <add key="Title" value=".NET Configuration evo" />
    <add key="MaxPage" value="10" />
  </appSettings>
</configuration>


El problema se solucionó, los desarrolladores obtuvieron una opción de personalización, que es mejor que los archivos INI, pero con sus propias peculiaridades. Entonces, por ejemplo, el soporte para diferentes valores de configuración para diferentes tipos de entornos de aplicación se implementa mediante transformaciones XSLT del archivo de configuración. Podemos definir nuestros propios esquemas XML para elementos y atributos si queremos algo más complejo en términos de agrupación de datos. Los pares clave-valor tienen un tipo estrictamente de cadena, y si necesitamos un número o una fecha, entonces "hagámoslo usted mismo de alguna manera":



string title = ConfigurationManager.AppSettings["Title"];
int maxPage = int.Parse(ConfigurationManager.AppSettings["MaxPage"]);


En 2005 agregamos secciones de configuración , permitieron agrupar parámetros, construir sus propios esquemas, evitar conflictos de nombres. También presentamos archivos * .settings y un diseñador especial para ellos.







Ahora puede obtener una clase generada y fuertemente tipada que represente datos de configuración. El diseñador le permite editar cómodamente los valores, la clasificación por columnas del editor está disponible. Los datos se recuperan utilizando la propiedad predeterminada de la clase generada, que proporciona el objeto de configuración Singleton.



DateTime date = Properties.Settings.Default.CustomDate;
int displayItems = Properties.Settings.Default.MaxDisplayItems;
string name = Properties.Settings.Default.ApplicationName;


También agregamos alcances de valores de parámetros de configuración. El área de Usuario es responsable de los datos del usuario, que puede modificar y guardar durante la ejecución del programa. El guardado se realiza en un archivo separado a lo largo de la ruta% AppData% \ * Nombre de la aplicación *. El alcance de la aplicación le permite recuperar los valores de los parámetros sin necesidad de redefinirlos por parte del usuario.



A pesar de las buenas intenciones, todo se volvió más complicado.



  • De hecho, estos son los mismos archivos XML que comenzaron a aumentar de tamaño más rápido y, como resultado, se volvieron incómodos de leer.
  • La configuración se lee del archivo XML una vez y necesitamos volver a cargar la aplicación para aplicar los cambios a los datos de configuración.
  • Las clases generadas a partir de archivos * .settings se marcaron con el modificador sellado, por lo que esta clase no se pudo heredar. Además, este archivo podría cambiarse, pero si ocurre una regeneración, perdemos todo lo que escribimos nosotros mismos.
  • Trabajar con datos solo en base a valores clave. Para obtener un enfoque estructurado para trabajar con configuraciones, debemos implementarlo adicionalmente nosotros mismos.
  • La fuente de datos solo puede ser un archivo, no se admiten proveedores externos.
  • Además, tenemos un factor humano: los parámetros privados ingresan al sistema de control de versiones y quedan expuestos.


Todos estos problemas permanecen en .NET Framework hasta el día de hoy.



Configuración de .NET Core



En .NET Core, reinventaron la configuración y crearon todo desde cero, eliminaron la clase estática ConfigurationManager y resolvieron muchos de los problemas que existían "antes". ¿Qué obtuvimos de nuevo? Como antes, la etapa de formación de los datos de configuración y la etapa de consumo de estos datos, pero con un ciclo de vida más flexible y extendido.



Configuración y llenado con datos de configuración



Entonces, para la etapa de generación de datos, podemos usar muchas fuentes, sin limitarnos solo a archivos. La configuración se realiza a través de IConfgurationBuilder, la base a la que podemos agregar fuentes de datos. Los paquetes NuGet están disponibles para varios tipos de fuentes:

Formato Método de extensión para agregar fuente a IConfigurationBuilder Paquete NuGet
Json AddJsonFile Microsoft.Extensions.Configuration.Json
XML AddXmlFile Microsoft.Extensions.Configuration.Xml
INI AddIniFile Microsoft.Extensions.Configuration.Ini
Argumentos de la línea de comandos AddCommandLine Microsoft.Extensions.Configuration.CommandLine
Variables de entorno AddEnvironmentVariables Microsoft.Extensions.Configuration.EnvironmentVariables
Secretos de usuario AddUserSecrets Microsoft.Extensions.Configuration.UserSecrets
KeyPerFile AddKeyPerFile Microsoft.Extensions.Configuration.KeyPerFile
Azure KeyVault AddAzureKeyVault Microsoft.Extensions.Configuration.AzureKeyVault


Cada fuente se agrega como una nueva capa y anula los parámetros con claves coincidentes. Aquí está el ejemplo de Program.cs que viene de forma predeterminada en la plantilla de la aplicación ASP.NET Core (versión 3.1).



public static IHostBuilder CreateHostBuilder(string[] args) => 
    Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => 
        { webBuilder.UseStartup<Startup>(); });


Quiero dibujar el foco principal en CreateDefaultBuilder . Dentro del método, veremos cómo se produce la configuración inicial de fuentes.



public static IWebHostBuilder CreateDefaultBuilder(string[] args)
{
    var builder = new WebHostBuilder();

    ...

    builder.ConfigureAppConfiguration((hostingContext, config) =>
    {
        IHostingEnvironment env = hostingContext.HostingEnvironment;

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

        if (env.IsDevelopment())
        {
            Assembly appAssembly = Assembly.Load(new AssemblyName(env.ApplicationName));
            if (appAssembly != null)
            {
                config.AddUserSecrets(appAssembly, optional: true);
            }
        }

        config.AddEnvironmentVariables();

        if (args != null)
        {
            config.AddCommandLine(args);
        }
    })
            
    ...

    return builder;
}


Entonces obtenemos que la base para toda la configuración será el archivo appsettings.json; Además, si hay un archivo para un entorno específico, tendrá una prioridad más alta y, por lo tanto, anulará los valores coincidentes de la base. Y así con cada fuente posterior. El orden de la suma afecta el valor final. Visualmente, todo se ve así:







si desea utilizar su pedido, simplemente puede borrarlo y definir cómo lo necesita.



Host.CreateDefaultBuilder(args)
    .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); })
    .ConfigureAppConfiguration((context,
                                builder) =>
     {
         builder.Sources.Clear();
         
         //  
     });


Cada fuente de configuración tiene dos partes:



  • Implementación de IConfigurationSource. Proporciona una fuente de valores de configuración.
  • Implementación de IConfigurationProvider. Convierte los datos originales en el valor-clave resultante.


Al implementar estos componentes, podemos obtener nuestra propia fuente de datos para la configuración. Aquí hay un ejemplo de cómo puede implementar la obtención de parámetros de una base de datos a través de Entity Framework.



Cómo utilizar y recuperar datos



Ahora que todo está claro con la configuración y el llenado de datos de configuración, propongo echar un vistazo a cómo podemos usar estos datos y cómo obtenerlos de manera más conveniente. El nuevo enfoque para configurar proyectos tiene un gran sesgo hacia el popular formato JSON, y esto no es sorprendente, porque con su ayuda podemos construir cualquier estructura de datos, agrupar datos y tener un archivo legible al mismo tiempo. Tomemos el siguiente archivo de configuración, por ejemplo:



{
  "Features" : {
    "Dashboard" : {
      "Title" : "Default dashboard",
      "EnableCurrencyRates" : true
    },
    "Monitoring" : {
      "EnableRPSLog" : false,
      "EnableStorageStatistic" : true,
      "StartTime": "09:00"
    }
  }
}


Todos los datos forman un diccionario plano de clave-valor, la clave de configuración se forma a partir de toda la jerarquía de claves de archivo para cada valor. Una estructura similar tendría el siguiente conjunto de datos:



Funciones: Tablero: Título Panel de control predeterminado
Funciones: Panel de control: EnableCurrencyRates cierto
Características: Monitoreo: EnableRPSLog falso
Funciones: Monitoreo: EnableStorageStatistic cierto
Características: Monitoreo: StartTime 09:00


Podemos obtener el valor usando el objeto IConfiguration . Por ejemplo, así es como podemos obtener los parámetros:



string title = Configuration["Features:Dashboard:Title"];
string title1 = Configuration.GetValue<string>("Features:Dashboard:Title");
bool currencyRates = Configuration.GetValue<bool>("Features:Dashboard:EnableCurrencyRates");
bool enableRPSLog = Configuration.GetValue<bool>("Features:Monitoring:EnableRPSLog");
bool enableStorageStatistic = Configuration.GetValue<bool>("Features:Monitoring:EnableStorageStatistic");
TimeSpan startTime = Configuration.GetValue<TimeSpan>("Features:Monitoring:StartTime");


Y esto ya no está mal, tenemos una buena manera de obtener datos que se envían al tipo de datos requerido, pero de alguna manera no es tan bueno como nos gustaría. Si recibimos datos como se indica arriba, terminaremos con un código duplicado y cometeremos errores en los nombres de las claves. En lugar de valores individuales, puede ensamblar un objeto de configuración completo. Vincular datos a un objeto mediante el método Bind nos ayudará con esto. Ejemplo de recuperación de datos y clases:



public class MonitoringConfig
{
    public bool EnableRPSLog { get; set; }
    public bool EnableStorageStatistic { get; set; }
    public TimeSpan StartTime { get; set; }
}

var monitorConfiguration = new MonitoringConfig();
Configuration.Bind("Features:Monitoring", monitorConfiguration);

var monitorConfiguration1 = new MonitoringConfig();
IConfigurationSection configurationSection = Configuration.GetSection("Features:Monitoring");
configurationSection.Bind(monitorConfiguration1);


En el primer caso, enlazamos por el nombre de la sección, y en el segundo, obtenemos una sección y enlazamos a partir de ella. La sección le permite trabajar con una vista parcial de la configuración, de esta manera puede controlar el conjunto de datos con el que estamos trabajando. Las secciones también se usan en métodos de extensión estándar; por ejemplo, para obtener una cadena de conexión se usa la sección "ConnectionStrings".



string connectionString = Configuration.GetConnectionString("Default");

public static string GetConnectionString(this IConfiguration configuration, string name)
{
    return configuration?.GetSection("ConnectionStrings")?[name];
}


Opciones: vista de configuración escrita



Crear un objeto de configuración manualmente y vincularlo a datos no es práctico, pero hay una solución en la forma de usar Opciones . Las opciones se utilizan para obtener una vista fuertemente tipada de una configuración. La clase de vista debe ser pública con un constructor sin parámetros y propiedades públicas para asignar un valor, el objeto se rellena mediante reflexión. Se pueden encontrar más detalles en la fuente .



Para comenzar a usar Options, necesitamos registrar el tipo de configuración usando el método Configure extension para IServiceCollection indicando la sección que proyectaremos en nuestra clase.



public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.Configure<MonitoringConfig>(Configuration.GetSection("Features:Monitoring"));
}


Después de eso, podemos obtener las configuraciones inyectando la dependencia en las interfaces IOptions, IOptionsMonitor, IOptionsSnapshot. Podemos obtener el objeto MonitoringConfig de la interfaz IOptions a través de la propiedad Value.



public class ExampleService
{
    private IOptions<MonitoringConfig> _configuration;
    public ExampleService(IOptions<MonitoringConfig> configuration)
    {
        _configuration = configuration;
    }
    public void Run()
    {
        TimeSpan timeSpan = _configuration.Value.StartTime; // 09:00
    }
}


Una característica de la interfaz IOptions es que en el contenedor de inyección de dependencia, la configuración se registra como un objeto con el ciclo de vida Singleton. La primera vez que la propiedad Value solicita un valor, un objeto se inicializa con datos que existen mientras exista este objeto. IOptions no admite la actualización de datos. Hay interfaces IOptionsSnapshot e IOptionsMonitor para admitir actualizaciones.



El IOptionsSnapshot en el contenedor DI se registra con el ciclo de vida de Scoped, lo que hace posible obtener un nuevo objeto de configuración a pedido con un nuevo alcance de contenedor. Por ejemplo, durante una solicitud web recibiremos el mismo objeto, pero para una nueva solicitud recibiremos un nuevo objeto con datos actualizados.



IOptionsMonitor se registra como Singleton, con la única diferencia de que cada configuración se recibe con los datos reales en el momento de la solicitud. Además, IOptionsMonitor le permite registrar un controlador de eventos de cambio de configuración si necesita responder al evento de cambio de datos en sí.



public class ExampleService
{
    private IOptionsMonitor<MonitoringConfig> _configuration;
    public ExampleService(IOptionsMonitor<MonitoringConfig> configuration)
    {
        _configuration = configuration;
        configuration.OnChange(config =>
        {
            Console.WriteLine(" ");
        });
    }
    
    public void Run()
    {
        TimeSpan timeSpan = _configuration.CurrentValue.StartTime; // 09:00
    }
}


También es posible obtener IOptionsSnapshot e IOptionsMontitor por nombre; esto es necesario si tiene varias secciones de configuración correspondientes a una clase y desea obtener una específica. Por ejemplo, tenemos los siguientes datos:



{
  "Cache": {
    "Main": {
      "Type": "global",
      "Interval": "10:00"
    },
    "Partial": {
      "Type": "personal",
      "Interval": "01:30"
    }
  }
}


El tipo que se utilizará para la proyección:



public class CachePolicy
{
    public string Type { get; set; }
    public TimeSpan Interval { get; set; }
}


Registramos configuraciones con un nombre específico:



services.Configure<CachePolicy>("Main", Configuration.GetSection("Cache:Main"));
services.Configure<CachePolicy>("Partial", Configuration.GetSection("Cache:Partial"));


Podemos recibir valores de la siguiente manera:



public class ExampleService
{
    public ExampleService(IOptionsSnapshot<CachePolicy> configuration)
    {
        CachePolicy main = configuration.Get("Main");
        TimeSpan mainInterval = main.Interval; // 10:00
            
        CachePolicy partial = configuration.Get("Partial");
        TimeSpan partialInterval = partial.Interval; // 01:30
    }
}


Si miras el código fuente del método de extensión con el que registramos el tipo de configuración, puedes ver que el nombre predeterminado es Options.Default, que es una cadena vacía. Así que implícitamente siempre pasamos el nombre de las configuraciones.



public static IServiceCollection Configure<TOptions>(this IServiceCollection services, IConfiguration config) where TOptions : class
            => services.Configure<TOptions>(Options.Options.DefaultName, config);


Dado que la configuración puede ser representada por una clase, también podemos agregar la validación del valor del parámetro marcando las propiedades usando atributos de validación del espacio de nombres System.ComponentModel.DataAnnotations. Por ejemplo, especificamos que el valor de la propiedad Tipo debe ser obligatorio. Pero también debemos indicar al registrar la configuración que la validación debe ocurrir en principio. Hay un método de extensión ValidateDataAnnotations para esto.



public class CachePolicy
{
    [Required]
    public string Type { get; set; }
    public TimeSpan Interval { get; set; }
}

services.AddOptions<CachePolicy>()
        .Bind(Configuration.GetSection("Cache:Main"))
        .ValidateDataAnnotations();


La peculiaridad de dicha validación es que sucederá solo en el momento de recibir el objeto de configuración. Esto dificulta entender que la configuración no es válida cuando se inicia la aplicación. Hay un problema en GitHub por este problema . Una solución a este problema puede ser el enfoque presentado en el artículo Agregar validación a objetos de configuración fuertemente tipados en ASP.NET Core.



Desventajas de las opciones y cómo sortearlas



La configuración a través de Opciones también tiene sus inconvenientes. Para usar, necesitamos agregar una dependencia, y cada vez que obtengamos un objeto de valor, necesitamos acceder a la propiedad Value / CurrentValue. Puede lograr un código más limpio al obtener un objeto de configuración limpio sin el contenedor de Opciones. La solución más simple al problema puede ser un registro adicional en el contenedor de una dependencia de tipo de configuración pura.



services.Configure<MonitoringConfig>(Configuration.GetSection("Features:Monitoring"));
services.AddScoped<MonitoringConfig>(provider => provider.GetRequiredService<IOptionsSnapshot<MonitoringConfig>>().Value);


La solución es sencilla, no obligamos al código final a conocer las IOptions, pero perdemos la flexibilidad para acciones de configuración adicionales si las necesitamos. Para solucionar este problema, podemos utilizar el patrón "Puente", que nos permitirá conseguir una capa adicional en la que podremos realizar acciones adicionales antes de recibir el objeto.



Para lograr este objetivo, necesitamos refactorizar el código de ejemplo actual. Dado que la clase de configuración tiene una limitación en forma de constructor sin parámetros, no podemos pasar el objeto IOptions / IOptionsSnapshot / IOptionsMontitor al constructor; para esto separaremos la lectura de configuración de la vista final.



Por ejemplo, digamos que queremos especificar la propiedad StartTime de la clase MonitoringConfig con una representación de cadena de minutos con un valor de "09", que no se ajusta al formato estándar.



public class MonitoringConfigReader
{
    public bool EnableRPSLog { get; set; }
    public bool EnableStorageStatistic { get; set; }
    public string StartTime { get; set; }
}

public interface IMonitoringConfig
{
    bool EnableRPSLog { get; }
    bool EnableStorageStatistic { get; }
    TimeSpan StartTime { get; }
}

public class MonitoringConfig : IMonitoringConfig
{
    public MonitoringConfig(IOptionsMonitor<MonitoringConfigReader> option)
    {
        MonitoringConfigReader reader = option.Value;
        
        EnableRPSLog = reader.EnableRPSLog;
        EnableStorageStatistic = reader.EnableStorageStatistic;
        StartTime = GetTimeSpanValue(reader.StartTime);
    }
    
    public bool EnableRPSLog { get; }
    public bool EnableStorageStatistic { get; }
    public TimeSpan StartTime { get; }
    
    private static TimeSpan GetTimeSpanValue(string value) => TimeSpan.ParseExact(value, "mm", CultureInfo.InvariantCulture);
}


Para poder obtener una configuración limpia, necesitamos registrarla en el contenedor de inyección de dependencia.



services.Configure<MonitoringConfigReader>(Configuration.GetSection("Features:Monitoring"));
services.AddTransient<IMonitoringConfig, MonitoringConfig>();


Este enfoque le permite crear un ciclo de vida completamente independiente para la formación de un objeto de configuración. Es posible agregar su propia validación de datos, o implementar adicionalmente una etapa de descifrado de datos si lo recibe en forma encriptada.



Garantizar la seguridad de los datos



Una tarea de configuración importante es la seguridad de los datos. Las configuraciones de archivo son inseguras porque los datos se almacenan en texto claro, que es fácil de leer; a menudo, los archivos se encuentran en el mismo directorio que la aplicación. Por error, puede enviar los valores al sistema de control de versiones, que puede desclasificar los datos, ¡pero imagínese si es un código público! La situación es tan común que incluso existe una herramienta lista para usar para encontrar tales fugas: Gitleaks . Hay un artículo separado que brinda estadísticas y la variedad de datos divulgados.



A menudo, un proyecto debe tener parámetros separados para diferentes entornos (Release / Debug, etc.). Por ejemplo, como una de las soluciones, puede utilizar la sustitución de valores finales utilizando las herramientas de integración y entrega continuas, pero esta opción no protege los datos durante el desarrollo. La herramienta User Secrets está diseñada para proteger al desarrollador . Está incluido en .NET Core SDK (3.0.100 y superior). ¿Cuál es el principio fundamental de esta herramienta? Primero, necesitamos inicializar nuestro proyecto para trabajar con el comando init.



dotnet user-secrets init


El comando agrega un elemento UserSecretsId al archivo de proyecto .csproj. Con este parámetro, obtenemos un almacenamiento privado que almacenará un archivo JSON normal. La diferencia es que no se encuentra en el directorio de su proyecto, por lo que solo estará disponible en la computadora actual. La ruta para Windows es% APPDATA% \ Microsoft \ UserSecrets \ <user_secrets_id> \ secrets.json, y para Linux y MacOS ~ / .microsoft / usersecrets / <user_secrets_id> /secrets.json. Podemos agregar el valor del ejemplo anterior con el comando set:



dotnet user-secrets set "Features:Monitoring:StartTime" "09:00"


Puede encontrar una lista completa de los comandos disponibles en la documentación.



La seguridad de los datos en producción se garantiza mejor utilizando almacenamiento especializado, como: AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, Consul, ZooKeeper. Para conectar algunos, ya hay paquetes NuGet listos para usar, y para algunos es fácil implementarlos usted mismo, ya que hay acceso a la API REST.



Conclusión



Los problemas modernos requieren soluciones modernas. Junto con el paso de los monolitos a las infraestructuras dinámicas, los enfoques de configuración también han sufrido cambios. Existía la necesidad, independientemente de la ubicación y el tipo de fuentes de datos de configuración, la necesidad de una respuesta rápida a los cambios de datos. Junto con .NET Core, obtuvimos una buena herramienta para implementar todo tipo de escenarios de configuración de aplicaciones.



All Articles