Accueil › L'ossature › Chapitre 12

Configuration, options, logs

Une application doit se comporter différemment sur ton portable et en production, sans qu'une seule ligne de code change. Et quand elle dysfonctionne à 3 h du matin, il faut pouvoir comprendre pourquoi. Deux sujets, un même objectif : ne pas coder en dur, ne pas deviner.

La configuration est un empilement de calques

L'image

Des calques posés l'un sur l'autre. Chaque source recouvre partiellement la précédente : le calque du bas donne les valeurs générales, celui du haut les remplace ponctuellement. Le dernier posé gagne.

priorité croissante ↓ (le dernier chargé écrase les précédents) 1. appsettings.json valeurs communes, versionnées dans Git 2. appsettings.{Environment}.json Development / Staging / Production 3. dotnet user-secrets (Development seulement) hors du dossier du projet 4. variables d'environnement le mécanisme de la production 5. arguments de ligne de commande --Feature:Actif=true Optionnel : coffre-fort (Azure Key Vault, AWS Secrets Manager) inséré à l'endroit voulu
appsettings.json
{
  "ConnectionStrings": {
    "Defaut": "Server=localhost;Database=Boutique;Trusted_Connection=True"
  },
  "Smtp": {
    "Hote": "localhost",
    "Port": 25,
    "ExpediteurParDefaut": "no-reply@boutique.fr"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
    }
  }
}
La même valeur, exprimée dans chaque source
# variable d'environnement : les niveaux sont séparés par DEUX tirets bas
Smtp__Port=587
ConnectionStrings__Defaut="Server=prod;..."

# argument de ligne de commande : deux-points
dotnet run --Smtp:Port=587

# secret de développement (stocké hors du projet, jamais dans Git)
dotnet user-secrets init
dotnet user-secrets set "Smtp:MotDePasse" "vraiSecret"
Le double tiret bas

Smtp:Port ne fonctionne pas comme variable d'environnement sur tous les systèmes : le deux-points n'est pas un caractère valide dans un nom de variable sous Linux. La forme portable est Smtp__Port (deux tirets bas). C'est la cause nº 1 des « ma configuration n'est pas prise en compte dans le conteneur ».

Les secrets ne vont jamais dans le code

ContexteOù mettre le mot de passe
Ta machine, en développementdotnet user-secrets (fichier dans ton profil utilisateur)
Intégration continueLes secrets du dépôt (GitHub Actions secrets, variables protégées)
Conteneur / KubernetesVariables d'environnement injectées, ou secrets montés en fichiers
CloudAzure Key Vault, AWS Secrets Manager, avec identité managée (aucun secret à stocker)
Jamaisappsettings.json versionné, code source, message de commit, capture d'écran
Un secret commité est un secret compromis

Supprimer la ligne dans un commit suivant ne suffit pas : l'historique Git la conserve. La seule réponse correcte est de révoquer et régénérer le secret. Ajoute appsettings.*.json local et .env à ton .gitignore, et active la détection de secrets sur ton dépôt.

Le pattern Options : de la configuration typée

Sans
public class Envoyeur(IConfiguration cfg)
{
    public void Envoyer()
    {
        var hote = cfg["Smtp:Hote"];              // string, peut être null
        var port = int.Parse(cfg["Smtp:Port"]!);  // 💥 si absent ou mal écrit
        // faute de frappe dans la clé =
        // erreur à l'exécution, en production
    }
}
Avec
public class SmtpOptions
{
    public const string Section = "Smtp";
    [Required] public string Hote { get; init; } = "";
    [Range(1, 65535)] public int Port { get; init; } = 25;
    public string? MotDePasse { get; init; }
}

public class Envoyeur(IOptions<SmtpOptions> options)
{
    private readonly SmtpOptions _o = options.Value;
    public void Envoyer() => Connecter(_o.Hote, _o.Port);
}
Program.cs — validation dès le démarrage
builder.Services.AddOptions<SmtpOptions>()
    .Bind(builder.Configuration.GetSection(SmtpOptions.Section))
    .ValidateDataAnnotations()      // applique [Required], [Range]…
    .ValidateOnStart();             // ← échoue AU DÉMARRAGE, pas au premier email
ValidateOnStart() : la ligne qui sauve des soirées

Sans elle, une configuration invalide n'explose qu'au premier usage — potentiellement des heures après le déploiement, sur le premier client qui déclenche un envoi d'email. Avec elle, l'application refuse de démarrer et le message dit exactement quelle clé manque. Le déploiement échoue proprement, l'ancienne version reste en ligne.

InterfaceComportementQuand
IOptions<T>Lu une fois, singleton, jamais rafraîchiLe défaut, dans 95 % des cas
IOptionsSnapshot<T>Constant pendant une requête, relu à la suivanteValeur modifiable à chaud, cohérente sur la requête (scoped)
IOptionsMonitor<T>Toujours la dernière valeur, avec notification de changementSingletons, tâches de fond, drapeaux de fonctionnalité

Environnements

// La variable ASPNETCORE_ENVIRONMENT décide de tout
// (« Development » sur ta machine via launchSettings.json, « Production » par défaut ailleurs)

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();      // pile d'appels détaillée : JAMAIS en production
    app.MapOpenApi();
}
else
{
    app.UseExceptionHandler();
    app.UseHsts();
}

// Environnement personnalisé
if (app.Environment.IsEnvironment("Recette")) { /* ... */ }

Journaliser : parler à son futur soi

NiveauSensExempleEn production
TraceDétail extrêmeValeur de chaque variableJamais (fuite de données)
DebugDiagnostic de développement« cache manqué pour la clé X »Non
InformationÉvénement métier normal« Commande 42 validée »Oui, avec parcimonie
WarningAnormal mais géré« Nouvel essai après échec réseau »Oui
ErrorÉchec d'une opération« Paiement refusé, exception X »Oui — à surveiller
CriticalL'application est en danger« Base inaccessible »Oui — à alerter
Log concaténé
_log.LogInformation(
    "Commande " + id + " validée en " + ms + "ms");

// Résultat : du texte plat.
// Impossible de filtrer « toutes les commandes
// de plus de 500 ms » sans expression
// régulière hasardeuse.
Log structuré
_log.LogInformation(
    "Commande {CommandeId} validée en {DureeMs}ms",
    id, ms);

// Le message ET les champs nommés sont
// enregistrés séparément :
// CommandeId=42, DureeMs=812
// → requêtable, agrégeable, graphable.
Piège : l'interpolation de chaîne détruit la structure

_log.LogInformation($"Commande {id} validée") compile et affiche correctement, mais le champ id est fondu dans le texte : plus aucun champ exploitable, et un message différent à chaque fois (donc impossible à regrouper). Utilise les accolades comme modèle et passe les valeurs en arguments. Un analyseur peut te le signaler automatiquement.

Les usages qui servent vraiment
public class ServicePaiement(ILogger<ServicePaiement> log)   // le T donne la « catégorie »
{
    public async Task PayerAsync(int commandeId, decimal montant)
    {
        // Une portée : TOUS les logs émis à l'intérieur porteront ces champs
        using var portee = log.BeginScope("Paiement de la commande {CommandeId}", commandeId);

        log.LogInformation("Début du paiement de {Montant:C}", montant);
        try
        {
            await _psp.DebiterAsync(montant);
            log.LogInformation("Paiement accepté");
        }
        catch (PspException ex)
        {
            // L'exception en PREMIER paramètre : la pile est conservée
            log.LogError(ex, "Paiement refusé pour {Montant:C}", montant);
            throw;
        }
    }
}
Régler la verbosité sans recompiler (appsettings.Production.json)
{
  "Logging": {
    "LogLevel": {
      "Default": "Warning",
      "Boutique": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
    }
  }
}

La catégorie est le nom complet du type (Boutique.Services.ServicePaiement), donc le préfixe "Boutique" couvre toute ton application. La dernière ligne fait taire le SQL généré par EF Core, très bavard en Information.

Ce qu'on ne journalise JAMAIS
Journalisation à coût zéro, et télémétrie

Chaque appel LogInformation alloue (boxing des arguments, tableau de paramètres) même si le niveau est désactivé. Sur un chemin très chaud, utilise le générateur de source, qui produit du code sans allocation et vérifie le nombre d'arguments à la compilation :

internal static partial class Journal
{
    [LoggerMessage(EventId = 1001, Level = LogLevel.Information,
        Message = "Commande {commandeId} validée en {dureeMs}ms")]
    public static partial void CommandeValidee(ILogger logger, int commandeId, long dureeMs);
}

Journal.CommandeValidee(_log, 42, 812);     // zéro allocation si le niveau est désactivé

Pour aller au-delà du texte, .NET expose métriques et traces via OpenTelemetry — les trois signaux (logs, métriques, traces) partagent le même identifiant de corrélation, ce qui permet de passer d'une alerte à la trace complète d'une requête. ASP.NET Core 11 émet nativement les attributs OpenTelemetry pour chaque requête. Voir chapitre 19.

Vérifie que c'est passé

🎯 Quiz — 4 questions

1. En production, une variable d'environnement Smtp__Port=587 et appsettings.json avec "Port": 25. Quelle valeur est utilisée ?

C'est exactement le mécanisme qui permet de déployer la même image de conteneur partout et de ne changer que les variables.

2. Quel est l'intérêt de .ValidateOnStart() ?

Échouer vite et bruyamment au déploiement vaut mille fois mieux qu'une exception aléatoire trois heures plus tard chez un client.

3. _log.LogInformation($"Client {id} supprimé") — quel est le problème ?

Écris LogInformation("Client {ClientId} supprimé", id). Le message devient un modèle stable et ClientId un champ requêtable.

4. Un service singleton doit voir une valeur de configuration modifiée à chaud. Que lui injecter ?

IOptions capture la valeur au premier accès et ne bouge plus. IOptionsMonitor expose toujours la dernière version et notifie les changements.

Fiches de révision

Qui gagne entre les sources ?
Le dernier chargé : ligne de commande > variables d'environnement > user-secrets > appsettings.{Env} > appsettings.
Séparateur en variable d'environnement ?
Deux tirets bas : Smtp__Port.
Où mettre un secret en développement ?
dotnet user-secrets set "Cle" "valeur" — hors du dossier du projet.
IOptions vs IOptionsMonitor ?
Options = figé. Monitor = toujours à jour, pour les singletons et tâches de fond.
Log structuré ?
Modèle avec accolades + valeurs en arguments : LogInformation("... {Id}", id).
Log d'une exception ?
L'exception en premier paramètre : LogError(ex, "message {X}", x).
À retenir