Évolution de la configuration .NET





Chaque programmeur s'est imaginé - enfin, ou pourrait vouloir s'imaginer - lui-même en tant que pilote d'avion, lorsque vous avez un énorme projet, un énorme panel de capteurs, de métriques et de commutateurs, avec lequel vous pouvez facilement tout configurer comme il se doit. Eh bien, au moins ne pas courir pour soulever manuellement le châssis vous-même. Les métriques et les graphiques sont tous bons, mais aujourd'hui, je veux vous parler des mêmes interrupteurs à bascule et boutons qui peuvent changer les paramètres du comportement de l'avion et le configurer.



L'importance des configurations est difficile à sous-estimer. Tout le monde utilise l'une ou l'autre approche pour configurer ses applications et, en principe, il n'y a rien de compliqué à cela, mais est-ce aussi simple que cela? Je propose de regarder «avant» et «après» dans la configuration et de comprendre les détails: comment ce qui fonctionne, quelles nouvelles fonctionnalités nous avons et comment les utiliser au maximum. Ceux qui ne sont pas familiarisés avec la configuration dans .NET Core obtiendront les bases, et ceux qui sont familiers pourront réfléchir et utiliser de nouvelles approches dans leur travail quotidien.



Configuration pré-.NET Core



En 2002, le .NET Framework a été introduit, et comme c'était l'époque du battage médiatique XML, les développeurs de Microsoft ont décidé de «l'avoir partout», et par conséquent, nous avons obtenu des configurations XML qui sont toujours vivantes. En tête du tableau, nous avons une classe ConfigurationManager statique à travers laquelle nous obtenons des représentations sous forme de chaîne de valeurs de paramètres. La configuration elle-même ressemblait à ceci:



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


Le problème a été résolu, les développeurs ont obtenu une option de personnalisation, qui est meilleure que les fichiers INI, mais avec ses propres particularités. Ainsi, par exemple, la prise en charge de différentes valeurs de paramètres pour différents types d'environnements d'application est implémentée à l'aide des transformations XSLT du fichier de configuration. Nous pouvons définir nos propres schémas XML pour les éléments et les attributs si nous voulons quelque chose de plus complexe en termes de regroupement de données. Les paires clé-valeur ont un type strictement chaîne, et si nous avons besoin d'un nombre ou d'une date, alors "faisons-le vous-même en quelque sorte":



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


En 2005, nous avons ajouté des sections de configuration , elles ont permis de regrouper les paramètres, de créer leurs propres schémas, d'éviter les conflits de noms. Nous avons également présenté des fichiers * .settings et un concepteur spécial pour eux.







Vous pouvez maintenant obtenir une classe générée et fortement typée qui représente les données de configuration. Le concepteur vous permet de modifier facilement les valeurs; le tri par colonnes de l'éditeur est disponible. Les données sont récupérées à l'aide de la propriété Default de la classe générée, qui fournit l'objet de configuration Singleton.



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


Nous avons également ajouté des portées des valeurs des paramètres de configuration. La zone utilisateur est responsable des données utilisateur, qui peuvent être modifiées par lui et sauvegardées pendant l'exécution du programme. L'enregistrement a lieu dans un fichier séparé le long du chemin% AppData% \ * Nom de l'application *. L'étendue Application vous permet de récupérer des valeurs de paramètres sans être redéfinie par l'utilisateur.



Malgré les bonnes intentions, tout est devenu plus compliqué.



  • En fait, ce sont les mêmes fichiers XML qui ont commencé à grossir plus rapidement et, par conséquent, sont devenus peu pratiques à lire.
  • La configuration est lue une fois à partir du fichier XML et nous devons recharger l'application pour appliquer les modifications aux données de configuration.
  • Les classes générées à partir des fichiers * .settings étaient marquées avec le modificateur scellé, donc cette classe ne pouvait pas être héritée. De plus, ce fichier pourrait être modifié, mais si une régénération se produit, nous perdons tout ce que nous avons écrit nous-mêmes.
  • Travailler avec des données uniquement selon le schéma clé-valeur. Pour obtenir une approche structurée du travail avec les configurations, nous devons également l'implémenter nous-mêmes.
  • La source de données ne peut être qu'un fichier, les fournisseurs externes ne sont pas pris en charge.
  • De plus, nous avons un facteur humain: les paramètres privés entrent dans le système de contrôle de version et sont exposés.


Tous ces problèmes demeurent dans le .NET Framework à ce jour.



Configuration .NET Core



Dans .NET Core, ils ont repensé la configuration et tout créé à partir de zéro, supprimé la classe statique ConfigurationManager et résolu de nombreux problèmes «avant». Qu'avons-nous obtenu de nouveau? Comme auparavant - l'étape de formation des données de configuration et l'étape de consommation de ces données, mais avec un cycle de vie plus flexible et prolongé.



Configuration et remplissage des données de configuration



Ainsi, pour l'étape de génération de données, nous pouvons utiliser de nombreuses sources, sans nous limiter uniquement aux fichiers. La configuration se fait via IConfgurationBuilder - la base à laquelle nous pouvons ajouter des sources de données. Les packages NuGet sont disponibles pour différents types de sources:

Format Méthode d'extension pour ajouter une source à IConfigurationBuilder Package NuGet
Json AddJsonFile Microsoft.Extensions.Configuration.Json
XML AddXmlFile Microsoft.Extensions.Configuration.Xml
INI AddIniFile Microsoft.Extensions.Configuration.Ini
Arguments de ligne de commande AddCommandLine Microsoft.Extensions.Configuration.CommandLine
Variables d'environnement Ajouter des variables d'environnement Microsoft.Extensions.Configuration.EnvironmentVariables
Secrets d'utilisateur AddUserSecrets Microsoft.Extensions.Configuration.UserSecrets
KeyPerFile AddKeyPerFile Microsoft.Extensions.Configuration.KeyPerFile
Azure KeyVault AddAzureKeyVault Microsoft.Extensions.Configuration.AzureKeyVault


Chaque source est ajoutée en tant que nouveau calque et remplace les paramètres avec des clés correspondantes. Voici l'exemple Program.cs fourni par défaut dans le modèle d'application ASP.NET Core (version 3.1).



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


Je veux me concentrer sur CreateDefaultBuilder . A l'intérieur de la méthode, nous verrons comment se déroule la configuration initiale des sources.



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;
}


Nous obtenons donc que la base de toute la configuration sera le fichier appsettings.json; de plus, s'il existe un fichier pour un environnement spécifique, il aura une priorité plus élevée et remplacera ainsi les valeurs correspondantes de la base. Et il en est de même pour chaque source ultérieure. L'ordre d'addition affecte la valeur finale. Visuellement, tout ressemble à ceci:







si vous souhaitez utiliser votre commande, vous pouvez simplement l'effacer et définir comment vous en avez besoin.



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


Chaque source de configuration comprend deux parties:



  • Implémentation de IConfigurationSource. Fournit une source de valeurs de configuration.
  • Implémentation de IConfigurationProvider. Convertit les données d'origine en valeur-clé résultante.


En implémentant ces composants, nous pouvons obtenir notre propre source de données pour la configuration. Voici un exemple de la façon dont vous pouvez implémenter l'obtention de paramètres à partir d'une base de données via Entity Framework.



Comment utiliser et récupérer des données



Maintenant que tout est clair avec le réglage et le remplissage des données de configuration, je propose de regarder comment nous pouvons utiliser ces données et comment les obtenir plus facilement. Dans la nouvelle approche de la configuration des projets, il y a un grand biais vers le format JSON populaire, et ce n'est pas surprenant, car avec son aide, nous pouvons créer toutes les structures de données, regrouper des données et avoir un fichier lisible en même temps. Prenons par exemple le fichier de configuration suivant:



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


Toutes les données forment un dictionnaire clé-valeur plat, la clé de configuration est formée à partir de la hiérarchie de clé de fichier entière pour chaque valeur. Une structure similaire aurait l'ensemble de données suivant:



Caractéristiques: Tableau de bord: Titre Tableau de bord par défaut
Caractéristiques: Tableau de bord: EnableCurrencyRates vrai
Caractéristiques: Surveillance: EnableRPSLog faux
Caractéristiques: Surveillance: EnableStorageStatistic vrai
Caractéristiques: Surveillance: StartTime 09h00


Nous pouvons obtenir la valeur à l'aide de l'objet IConfiguration . Par exemple, voici comment nous pouvons obtenir les paramètres:



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");


Et ce n'est déjà pas mal, nous avons un bon moyen d'obtenir des données qui sont converties au type de données requis, mais en quelque sorte pas aussi cool que nous le souhaiterions. Si nous recevons des données comme indiqué ci-dessus, nous nous retrouverons avec un code répétitif et nous commettrons des erreurs dans les noms des clés. Au lieu de valeurs individuelles, vous pouvez assembler un objet de configuration complet. Cela nous aidera à lier des données à un objet via la méthode Bind. Exemple de récupération de classe et de données:



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);


Dans le premier cas, nous lions par le nom de la section, et dans le second, nous obtenons une section et nous lions à partir de celle-ci. La section vous permet de travailler avec une vue partielle de la configuration - de cette façon, vous pouvez contrôler l'ensemble de données avec lequel nous travaillons. Les sections sont également utilisées dans les méthodes d'extension standard - par exemple, l'obtention d'une chaîne de connexion utilise la section "ConnectionStrings".



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

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


Options - vue de configuration typée



La création manuelle d'un objet de configuration et la liaison aux données n'est pas pratique, mais il existe une solution sous la forme d' options . Les options sont utilisées pour obtenir une vue fortement typée d'une configuration. La classe de vue doit être publique avec un constructeur sans paramètres et propriétés publiques pour attribuer une valeur, l'objet est rempli par réflexion. Plus de détails peuvent être trouvés dans la source .



Pour commencer à utiliser Options, nous devons enregistrer le type de configuration à l'aide de la méthode d'extension Configure pour IServiceCollection en indiquant la section que nous projetterons sur notre classe.



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


Après cela, nous pouvons recevoir des configurations en injectant une dépendance sur les interfaces IOptions, IOptionsMonitor, IOptionsSnapshot. Nous pouvons obtenir l'objet MonitoringConfig depuis l'interface IOptions via la propriété 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
    }
}


Une fonctionnalité de l'interface IOptions est que dans le conteneur d'injection de dépendances, la configuration est enregistrée en tant qu'objet avec le cycle de vie Singleton. La première fois qu'une valeur est demandée par la propriété Value, un objet est initialisé avec des données qui existent tant que cet objet existe. IOptions ne prend pas en charge l'actualisation des données. Il existe des interfaces IOptionsSnapshot et IOptionsMonitor pour prendre en charge les mises à jour.



Le IOptionsSnapshot dans le conteneur DI est enregistré avec le cycle de vie Scoped, ce qui permet d'obtenir un nouvel objet de configuration sur demande avec une nouvelle portée de conteneur. Par exemple, lors d'une requête Web, nous recevrons le même objet, mais pour une nouvelle requête, nous recevrons un nouvel objet avec des données mises à jour.



IOptionsMonitor est enregistré en tant que Singleton, à la seule différence que chaque configuration est reçue avec les données réelles au moment de la demande. En outre, IOptionsMonitor vous permet d'enregistrer un gestionnaire d'événements de modification de configuration si vous devez répondre à l'événement de modification de données lui-même.



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
    }
}


Il est également possible d'obtenir IOptionsSnapshot et IOptionsMontitor par nom - cela est nécessaire si vous avez plusieurs sections de configuration correspondant à une classe et que vous souhaitez en obtenir une spécifique. Par exemple, nous avons les données suivantes:



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


Le type à utiliser pour la projection:



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


Nous enregistrons les configurations avec un nom spécifique:



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


Nous pouvons recevoir des valeurs comme suit:



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 vous regardez le code source de la méthode d'extension avec laquelle nous enregistrons le type de configuration, vous pouvez voir que le nom par défaut est Options.Default, qui est une chaîne vide. On passe donc toujours implicitement un nom pour les configurations.



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


Étant donné que la configuration peut être représentée par une classe, nous pouvons également ajouter une validation de valeur de paramètre en marquant les propriétés à l'aide d'attributs de validation de l'espace de noms System.ComponentModel.DataAnnotations. Par exemple, nous spécifions que la valeur de la propriété Type doit être obligatoire. Mais nous devons également indiquer lors de l'enregistrement de la configuration que la validation doit avoir lieu en principe. Il existe une méthode d'extension ValidateDataAnnotations pour cela.



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

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


La particularité d'une telle validation est qu'elle n'aura lieu qu'au moment de la réception de l'objet de configuration. Cela rend difficile de comprendre que la configuration n'est pas valide au démarrage de l'application. Il y a un problème sur GitHub pour ce problème . Une solution à ce problème peut être l'approche présentée dans l'article Ajout de la validation aux objets de configuration fortement typés dans ASP.NET Core.



Inconvénients des options et comment les contourner



La configuration via Options a également ses inconvénients. Pour l'utilisation, nous devons ajouter une dépendance, et chaque fois que nous devons accéder à la propriété Value / CurrentValue pour obtenir un objet de valeur. Vous pouvez obtenir un code plus propre en obtenant un objet de configuration propre sans le wrapper Options. La solution la plus simple au problème peut être une inscription supplémentaire dans le conteneur d'une dépendance de type configuration pure.



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


La solution est simple, on ne force pas le code final à connaître les IOptions, mais on perd la flexibilité pour des actions de configuration supplémentaires si on en a besoin. Pour résoudre ce problème, nous pouvons utiliser le motif "Bridge", qui nous permettra d'obtenir une couche supplémentaire dans laquelle nous pourrons effectuer des actions supplémentaires avant de recevoir l'objet.



Pour atteindre cet objectif, nous devons refactoriser l'exemple de code actuel. La classe de configuration ayant une restriction sous la forme d'un constructeur sans paramètres, nous ne pouvons pas passer l'objet IOptions / IOptionsSnapshot / IOptionsMontitor au constructeur; pour cela, nous allons séparer la lecture de la configuration de la vue finale.



Par exemple, disons que nous voulons spécifier la propriété StartTime de la classe MonitoringConfig avec une représentation sous forme de chaîne de minutes avec une valeur de "09", qui ne correspond pas au format standard.



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);
}


Pour pouvoir obtenir une configuration propre, nous devons l'enregistrer dans le conteneur d'injection de dépendances.



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


Cette approche vous permet de créer un cycle de vie complètement distinct pour la formation d'un objet de configuration. Il est possible d'ajouter votre propre validation des données, ou de mettre en œuvre en plus l'étape de décryptage des données si vous les recevez sous forme cryptée.



Assurer la sécurité des données



Une tâche de configuration importante est la sécurité des données. Les configurations de fichiers ne sont pas sécurisées car les données sont stockées en texte clair, qui est facile à lire; souvent, les fichiers sont dans le même répertoire que l'application. Par erreur, vous pouvez valider les valeurs dans le système de contrôle de version, qui peut déclassifier les données, mais imaginez s'il s'agit de code public! La situation est si courante qu'il existe même un outil prêt à l'emploi pour trouver de telles fuites - Gitleaks . Il existe un article séparé qui donne des statistiques et la variété des données divulguées.



Souvent, un projet doit avoir des paramètres séparés pour différents environnements (Release / Debug, etc.). Par exemple, comme l'une des solutions, vous pouvez utiliser la substitution des valeurs finales à l'aide des outils d'intégration et de livraison continues, mais cette option ne protège pas les données pendant le développement. L'outil User Secrets est conçu pour protéger le développeur . Il est inclus dans le SDK .NET Core (3.0.100 et supérieur). Quel est le principe de base de cet outil? Tout d'abord, nous devons initialiser notre projet pour travailler avec la commande init.



dotnet user-secrets init


La commande ajoute un élément UserSecretsId au fichier projet .csproj. Avec ce paramètre, nous obtenons un stockage privé qui stockera un fichier JSON normal. La différence est qu'il ne se trouve pas dans le répertoire de votre projet, il ne sera donc disponible que sur l'ordinateur actuel. Le chemin pour Windows est% APPDATA% \ Microsoft \ UserSecrets \ <user_secrets_id> \ secrets.json, et pour Linux et MacOS ~ / .microsoft / usersecrets / <user_secrets_id> /secrets.json. Nous pouvons ajouter la valeur de l'exemple ci-dessus avec la commande set:



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


Une liste complète des commandes disponibles se trouve dans la documentation.



La sécurité des données en production est mieux assurée en utilisant un stockage spécialisé, tel que: AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, Consul, ZooKeeper. Pour en connecter certains, il existe déjà des packages NuGet prêts à l'emploi, et pour certains, il est facile de les implémenter vous-même, car il y a accès à l'API REST.



Conclusion



Les problèmes modernes nécessitent des solutions modernes. Parallèlement au passage des monolithes aux infrastructures dynamiques, les approches de configuration ont également subi des changements. Indépendamment de l'emplacement et du type de sources de données de configuration, il était nécessaire de réagir rapidement aux modifications de données. Avec .NET Core, nous avons un bon outil pour implémenter toutes sortes de scénarios de configuration d'application.



All Articles