Aller au contenu

Gérer les paramètres d'une application .NET avec le pattern Options

Introduction

Une application .NET utilise souvent des paramètres de configuration pour définir le comportement des services qu'elle contient, tels que l'URL d'un service externe, le nombre maximal de tentatives et le délais d'attente pour consommer un service, l'activation/désactivation d'une fonctionnalité, etc. Dans une application ASP.NET Core, une partie de ces paramètres est souvent déclarée dans un fichier de paramétrage tel que le fichier appsettings.json.

Le pattern Options permet de regrouper des paramètres dans des classes avec des propriétés typées, de les enregistrer dans le conteneur d'injection de dépendances. .NET fournit notamment trois interfaces pour les utiliser :

  • IOptions<T> pour obtenir une configuration qui n'évolue pas pendant l'exécution de l'application.

  • IOptionsSnapshot<T> pour obtenir une nouvelle vue de la configuration dans chaque portée (scope) d'injection de dépendances.

  • IOptionsMonitor<T> pour consulter la valeur courante et être informé de ses modifications.

Ces interfaces répondent à des besoins différents. Leur choix dépend principalement de la durée de vie du service consommateur et de la manière dont l'application doit prendre en compte les changements de configuration.

Définir les paramètres dans appsettings.json

Prenons l'exemple d'une application chargée d'envoyer des notifications. Son fichier appsettings.json contient une section Notifications :

{
  "Notifications": {
    "SenderName": "AlgoWin",
    "MaxRetryCount": 3,
    "SendingEnabled": true
  }
}

Les paramètres sont regroupés dans une section dédiée. Cette organisation évite de multiplier les clés à la racine du document et facilite leur association à une classe C#.

ASP.NET Core charge appsettings.json, puis le fichier propre à l'environnement, tel que appsettings.Development.json. D'autres sources peuvent compléter ou remplacer ces valeurs, notamment les variables d'environnement, les arguments de ligne de commande ou un fournisseur de configuration externe. Lorsqu'une même clé est fournie plusieurs fois, la dernière source de configuration qui la définit est prioritaire.

Accéder directement à la configuration avec IConfiguration

L'interface IConfiguration donne accès à l'ensemble de la configuration de l'application. Elle permet de lire une valeur à partir de son chemin :

public sealed class NotificationService
{
    private readonly IConfiguration _configuration;

    public NotificationService(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    public void DisplayConfiguration()
    {
        string? senderName =
            _configuration["Notifications:SenderName"];

        int maxRetryCount =
            _configuration.GetValue<int>("Notifications:MaxRetryCount");

        Console.WriteLine($"Expéditeur : {senderName}");
        Console.WriteLine($"Nombre maximal de tentatives : {maxRetryCount}");
    }
}

Cette approche convient pour une lecture ponctuelle. Elle présente néanmoins plusieurs limites lorsqu'elle est utilisée dans le code métier :

  • les chemins de configuration sont écrits sous la forme de chaînes de caractères ;

  • une faute dans une clé n'est pas détectée par le compilateur ;

  • la conversion et la validation des valeurs doivent être gérées explicitement ;

  • le service dépend de toute la configuration alors qu'il ne consomme qu'une seule section.

Pour une configuration structurée et utilisée par plusieurs composants, le pattern Options constitue généralement une solution plus robuste.

Créer une classe d'options fortement typée

La section Notifications peut être représentée par une classe C# :

public sealed class NotificationOptions
{
    public const string SectionName = "Notifications";

    public string SenderName { get; set; } = string.Empty;

    public int MaxRetryCount { get; set; }

    public bool SendingEnabled { get; set; }
}

Chaque propriété correspond à une clé de la section JSON. La constante SectionName centralise le nom de cette section afin d'éviter de le répéter dans le code.

La liaison entre la configuration et la classe est ensuite enregistrée dans Program.cs :

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<NotificationOptions>(
    builder.Configuration.GetSection(NotificationOptions.SectionName));

builder.Services.AddControllers();

var app = builder.Build();

app.MapControllers();

app.Run();

Le configuration binder associe les clés de la section aux propriétés de même nom. La liaison n'est pas sensible à la casse. La classe obtenue peut alors être injectée par l'intermédiaire de l'une des interfaces du pattern Options.

Utiliser IOptions<T> pour une configuration stable

IOptions<T> fournit la valeur des options au moyen de sa propriété Value :

using Microsoft.Extensions.Options;

public sealed class NotificationService
{
    private readonly NotificationOptions _options;

    public NotificationService(IOptions<NotificationOptions> options)
    {
        _options = options.Value;
    }

    public void Send(string message)
    {
        if (!_options.SendingEnabled)
        {
            return;
        }

        Console.WriteLine(
            $"Envoi de '{message}' par {_options.SenderName} " +
            $"avec {_options.MaxRetryCount} tentative(s) au maximum.");
    }
}

La valeur est créée lors de son premier accès, puis mise en cache. Si le fichier appsettings.json est modifié pendant l'exécution, l'instance fournie par IOptions<T> n'est pas actualisée.

IOptions<T> convient donc lorsque les paramètres sont considérés comme immuables pendant toute la durée d'exécution de l'application. Cette interface peut être injectée dans des services ayant n'importe quelle durée de vie, y compris des services singleton.

Elle ne doit en revanche pas être choisie lorsqu'une modification de la configuration doit être appliquée sans redémarrage.

Utiliser IOptionsSnapshot<T> pour actualiser les options entre les requêtes

IOptionsSnapshot<T> est un service scoped. Dans une application ASP.NET Core, une portée est normalement créée pour chaque requête HTTP. Les options sont calculées lors de leur premier accès dans cette portée, puis conservées jusqu'à la fin de celle-ci.

Le contrôleur suivant utilise donc une vue cohérente des paramètres pendant toute la requête :

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;

[ApiController]
[Route("api/notifications")]
public sealed class NotificationsController : ControllerBase
{
    private readonly IOptionsSnapshot<NotificationOptions> _options;

    public NotificationsController(
        IOptionsSnapshot<NotificationOptions> options)
    {
        _options = options;
    }

    [HttpGet("configuration")]
    public ActionResult<NotificationOptions> GetConfiguration()
    {
        return Ok(_options.Value);
    }
}

Si appsettings.json est modifié pendant que l'application fonctionne, la requête en cours continue d'utiliser son instantané. Une requête suivante pourra obtenir les nouvelles valeurs, à condition que le fournisseur de configuration détecte les changements.

Ce comportement est particulièrement adapté aux contrôleurs et aux services scoped : tous les traitements exécutés dans une même requête partagent une configuration stable, tandis que les requêtes ultérieures peuvent bénéficier de sa mise à jour.

La recréation des options dans chaque portée entraîne cependant un coût supplémentaire. Par ailleurs, IOptionsSnapshot<T> ne peut pas être injectée directement dans un service singleton, car un service à longue durée de vie ne doit pas dépendre d'un service scoped.

Utiliser IOptionsMonitor<T> pour suivre la configuration courante

IOptionsMonitor<T> est un service singleton. Sa propriété CurrentValue renvoie la valeur courante des options :

using Microsoft.Extensions.Options;

public sealed class NotificationService
{
    private readonly IOptionsMonitor<NotificationOptions> _optionsMonitor;

    public NotificationService(
        IOptionsMonitor<NotificationOptions> optionsMonitor)
    {
        _optionsMonitor = optionsMonitor;
    }

    public void Send(string message)
    {
        NotificationOptions options = _optionsMonitor.CurrentValue;

        if (!options.SendingEnabled)
        {
            return;
        }

        Console.WriteLine(
            $"Envoi de '{message}' par {options.SenderName} " +
            $"avec {options.MaxRetryCount} tentative(s) au maximum.");
    }
}

Contrairement à IOptions<T>, la valeur retournée peut évoluer pendant la durée de vie de l'application. Le service lit CurrentValue au moment où il exécute son traitement et utilise ainsi la configuration disponible à cet instant.

Comme IOptionsMonitor<T> est un singleton, elle peut notamment être injectée dans un BackgroundService ou dans un autre service singleton. Elle est bien adaptée aux traitements continus qui doivent prendre en compte des changements sans être recréés.

Être averti d'une modification avec OnChange

IOptionsMonitor<T> expose également la méthode OnChange. Elle permet d'exécuter un traitement lorsque les options sont modifiées :

using Microsoft.Extensions.Options;

public sealed class NotificationConfigurationObserver : IDisposable
{
    private readonly ILogger<NotificationConfigurationObserver> _logger;
    private readonly IDisposable? _subscription;

    public NotificationConfigurationObserver(
        IOptionsMonitor<NotificationOptions> optionsMonitor,
        ILogger<NotificationConfigurationObserver> logger)
    {
        _logger = logger;

        _subscription = optionsMonitor.OnChange(options =>
        {
            _logger.LogInformation(
                "La configuration des notifications a changé. " +
                "Envoi activé : {SendingEnabled}. " +
                "Nombre maximal de tentatives : {MaxRetryCount}.",
                options.SendingEnabled,
                options.MaxRetryCount);
        });
    }

    public void Dispose()
    {
        _subscription?.Dispose();
    }
}

Le service peut être enregistré comme singleton :

builder.Services.AddSingleton<NotificationConfigurationObserver>();

L'abonnement retourné par OnChange doit être libéré lorsqu'il n'est plus nécessaire. Il faut également veiller à ce que le service soit effectivement instancié : l'enregistrement dans le conteneur ne crée pas immédiatement un singleton. Il doit être demandé par un autre service ou résolu au démarrage.

Le gestionnaire transmis à OnChange doit rester rapide et robuste. Un traitement long ou susceptible d'échouer devrait être délégué, par exemple au moyen d'une file de messages interne, plutôt que réalisé directement dans le rappel.

Activer le rechargement du fichier de configuration

Les interfaces IOptionsSnapshot<T> et IOptionsMonitor<T> ne surveillent pas elles-mêmes le fichier JSON. Elles s'appuient sur les notifications émises par les fournisseurs de configuration.

Dans une application créée avec WebApplication.CreateBuilder(args), les fichiers appsettings.json et appsettings.{Environment}.json sont ajoutés par défaut avec la détection des modifications activée. Il n'est donc généralement pas nécessaire de les déclarer une seconde fois.

Dans une application qui construit explicitement sa configuration, le rechargement doit être activé avec reloadOnChange :

IConfiguration configuration = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile(
        "appsettings.json",
        optional: false,
        reloadOnChange: true)
    .Build();

Le rechargement dépend néanmoins des capacités du fournisseur utilisé. Une source de configuration qui ne produit pas de notification de changement ne permettra pas à IOptionsSnapshot<T> ou IOptionsMonitor<T> de refléter automatiquement de nouvelles valeurs.

En environnement conteneurisé ou lorsque des fichiers sont montés sur un volume, le remplacement d'un fichier peut également ne pas déclencher les événements attendus par le système de fichiers. Le comportement doit être vérifié dans l'environnement réel de déploiement.

Valider les paramètres de configuration

Une configuration fortement typée ne garantit pas que ses valeurs sont valides. Une chaîne peut être vide et un nombre peut se trouver en dehors de l'intervalle attendu. Les annotations de validation permettent d'exprimer une partie de ces règles directement dans la classe :

using System.ComponentModel.DataAnnotations;

public sealed class NotificationOptions
{
    public const string SectionName = "Notifications";

    [Required]
    public string SenderName { get; set; } = string.Empty;

    [Range(0, 10)]
    public int MaxRetryCount { get; set; }

    public bool SendingEnabled { get; set; }
}

L'enregistrement peut ensuite activer cette validation :

builder.Services
    .AddOptions<NotificationOptions>()
    .Bind(builder.Configuration.GetSection(NotificationOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();

ValidateDataAnnotations applique les attributs présents sur la classe. ValidateOnStart force la création et la validation des options au démarrage de l'application. Une configuration incorrecte empêche ainsi l'application de démarrer, au lieu de provoquer une erreur plus tard lors du premier accès aux options.

Une règle qui ne peut pas être exprimée par un attribut peut être ajoutée avec Validate :

builder.Services
    .AddOptions<NotificationOptions>()
    .Bind(builder.Configuration.GetSection(NotificationOptions.SectionName))
    .ValidateDataAnnotations()
    .Validate(
        options => !options.SendingEnabled || options.MaxRetryCount > 0,
        "MaxRetryCount doit être supérieur à zéro lorsque l'envoi est activé.")
    .ValidateOnStart();

Pour des règles complexes ou réutilisables, il est également possible d'implémenter IValidateOptions<T>.

Lors d'un rechargement dynamique, il faut garder à l'esprit qu'une nouvelle configuration peut elle aussi être invalide. La validation au démarrage ne remplace donc pas la surveillance des erreurs de configuration en production.

Comparer IOptions<T>, IOptionsSnapshot<T> et IOptionsMonitor<T>

Interface Durée de vie Accès à la valeur Prise en compte des changements Usage principal
IOptions<T> Singleton Value Non Paramètres stables pendant toute l'exécution
IOptionsSnapshot<T> Scoped Value ou Get(name) Oui, dans une nouvelle portée Contrôleurs et services scoped nécessitant une configuration cohérente pendant une requête
IOptionsMonitor<T> Singleton CurrentValue ou Get(name) Oui, dès la mise à jour du fournisseur Services singleton, traitements en arrière-plan et notifications de changement

Le choix peut être résumé ainsi :

  • utiliser IOptions<T> lorsque les paramètres ne doivent pas changer avant le prochain redémarrage ;

  • utiliser IOptionsSnapshot<T> lorsqu'une nouvelle version des paramètres peut être appliquée entre deux requêtes, tout en conservant une valeur stable pendant chacune d'elles ;

  • utiliser IOptionsMonitor<T> lorsqu'un service doit toujours pouvoir consulter la valeur courante ou réagir immédiatement à un changement.

Éviter de figer involontairement une configuration dynamique

Même avec IOptionsMonitor<T>, la configuration peut être figée par erreur. Dans l'exemple suivant, la valeur courante est copiée une seule fois dans le constructeur :

public sealed class NotificationService
{
    private readonly NotificationOptions _options;

    public NotificationService(
        IOptionsMonitor<NotificationOptions> optionsMonitor)
    {
        _options = optionsMonitor.CurrentValue;
    }
}

Si NotificationService est un singleton, _options conservera l'instance obtenue lors de sa construction. Les changements ultérieurs ne seront pas observés.

Pour bénéficier du rechargement, le service doit conserver le moniteur et lire CurrentValue au moment du traitement, ou maintenir lui-même un état actualisé au moyen de OnChange :

public sealed class NotificationService
{
    private readonly IOptionsMonitor<NotificationOptions> _optionsMonitor;

    public NotificationService(
        IOptionsMonitor<NotificationOptions> optionsMonitor)
    {
        _optionsMonitor = optionsMonitor;
    }

    public void Send(string message)
    {
        NotificationOptions options = _optionsMonitor.CurrentValue;

        // Utilisation de la configuration courante...
    }
}

Ce point est essentiel : injecter IOptionsMonitor<T> ne suffit pas, à lui seul, à rendre un service dynamique. La manière dont sa valeur est lue détermine si les modifications seront réellement prises en compte.

Tester un service qui utilise des options

Pour un service dépendant de IOptions<T>, la méthode Options.Create permet de fournir facilement une valeur dans un test unitaire :

using Microsoft.Extensions.Options;

NotificationOptions notificationOptions = new()
{
    SenderName = "Test sender",
    MaxRetryCount = 2,
    SendingEnabled = true
};

IOptions<NotificationOptions> options =
    Options.Create(notificationOptions);

NotificationService service = new(options);

Pour tester la réaction aux changements d'un composant dépendant de IOptionsMonitor<T>, une petite implémentation de test ou un substitut peut contrôler CurrentValue et déclencher les gestionnaires enregistrés par OnChange. Le test peut ainsi vérifier séparément le comportement initial et celui qui suit une modification.

Bonnes pratiques

Pour conserver une configuration lisible et fiable :

  • regrouper les paramètres par fonctionnalité dans des sections cohérentes ;

  • créer des classes d'options dédiées plutôt qu'une classe globale contenant tous les paramètres ;

  • centraliser le nom de chaque section dans une constante ;

  • choisir l'interface en fonction de la durée de vie du service et du besoin réel de rechargement ;

  • valider les options et, lorsque cela est pertinent, utiliser ValidateOnStart ;

  • vérifier le rechargement dans l'environnement de déploiement réel ;

  • conserver l'abonnement retourné par OnChange et le libérer lorsqu'il n'est plus utile ;

  • ne pas stocker de mots de passe, de clés d'API ou d'autres secrets dans un fichier appsettings.json versionné. Utiliser plutôt les secrets utilisateur en développement et un gestionnaire de secrets adapté en production.