mcpField
D
dotnet-options-pattern

dotnet-options-pattern

github.com/ronnythedev开发7762 次安装15 周前更新
代码仓库

关于 dotnet-options-pattern

面向 .NET 开发者的 Options 模式配置指南,说明如何将配置映射为强类型对象并按服务生命周期选择接口。内容包含验证、命名选项及后台服务用法模板。

主要功能

  1. 01强类型配置类结构
  2. 02IOptions、IOptionsSnapshot 与 IOptionsMonitor 选择
  3. 03数据注解和启动时验证
  4. 04命名选项注册与读取
  5. 05后台服务中的选项用法

适用场景

  1. 01为应用配置定义强类型选项
  2. 02按服务生命周期选择配置接口
  3. 03在启动时验证必需配置
  4. 04为同一类型注册多组命名配置

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/ronnythedev/dotnet-clean-architecture-skills --skill dotnet-options-pattern

安装后在 Claude Code 中输入/dotnet-options-pattern确认已加载。

详细介绍

.NET 配置的 Options 模式

概述

Options 模式为配置组提供强类型访问:

  • IOptions - 单例,启动时读取一次
  • IOptionsSnapshot - 作用域内,每个请求重新加载
  • IOptionsMonitor - 单例,实时变更通知

快速参考

接口生命周期支持重新加载命名 Options用例
IOptions<T>单例否否静态配置
IOptionsSnapshot<T>作用域内是是请求范围内的配置
IOptionsMonitor<T>单例是是单例服务、变更通知

Options 结构

/Application/Options/
├── DatabaseOptions.cs
├── JwtOptions.cs
├── CacheOptions.cs
├── EmailOptions.cs
└── FeatureFlagOptions.cs

模板:基本 Options 类

// src/{name}.application/Options/DatabaseOptions.cs
namespace {name}.application.options;

/// <summary>
/// Options classes should:
/// - Use the "Options" suffix
/// - Have public getters and setters
/// - Include validation via data annotations or IValidateOptions
/// </summary>
public sealed class DatabaseOptions
{
    /// <summary>
    /// Configuration section name in appsettings.json
    /// </summary>
    public const string SectionName = "Database";

    /// <summary>
    /// Connection string for the primary database
    /// </summary>
    public required string ConnectionString { get; set; }

    /// <summary>
    /// Maximum number of connections in the pool
    /// </summary>
    public int MaxPoolSize { get; set; } = 100;

    /// <summary>
    /// Minimum number of connections in the pool
    /// </summary>
    public int MinPoolSize { get; set; } = 5;

    /// <summary>
    /// Connection timeout in seconds
    /// </summary>
    public int ConnectionTimeout { get; set; } = 30;

    /// <summary>
    /// Enable connection pooling
    /// </summary>
    public bool EnablePooling { get; set; } = true;

    /// <summary>
    /// Enable query logging for debugging
    /// </summary>
    public bool EnableQueryLogging { get; set; } = false;
}
对应的 appsettings.json
{
  "Database": {
    "ConnectionString": "Host=localhost;Database=mydb;Username=postgres;Password=secret",
    "MaxPoolSize": 100,
    "MinPoolSize": 5,
    "ConnectionTimeout": 30,
    "EnablePooling": true,
    "EnableQueryLogging": false
  }
}

模板:使用数据注解验证的 Options

// src/{name}.application/Options/JwtOptions.cs
using System.ComponentModel.DataAnnotations;

namespace {name}.application.options;

public sealed class JwtOptions
{
    public const string SectionName = "Jwt";

    [Required(ErrorMessage = "JWT Secret is required")]
    [MinLength(32, ErrorMessage = "JWT Secret must be at least 32 characters")]
    public required string Secret { get; set; }

    [Required(ErrorMessage = "JWT Issuer is required")]
    public required string Issuer { get; set; }

    [Required(ErrorMessage = "JWT Audience is required")]
    public required string Audience { get; set; }

    [Range(1, 1440, ErrorMessage = "Access token expiration must be between 1 and 1440 minutes")]
    public int AccessTokenExpirationMinutes { get; set; } = 15;

    [Range(1, 43200, ErrorMessage = "Refresh token expiration must be between 1 and 43200 minutes")]
    public int RefreshTokenExpirationMinutes { get; set; } = 10080; // 7 days
}

模板:使用自定义验证的 Options

// src/{name}.application/Options/CacheOptions.cs
namespace {name}.application.options;

public sealed class CacheOptions
{
    public const string SectionName = "Cache";

    public bool Enabled { get; set; } = true;
    public string? RedisConnectionString { get; set; }
    public int DefaultExpirationMinutes { get; set; } = 5;
    public int SlidingExpirationMinutes { get; set; } = 2;
    public string KeyPrefix { get; set; } = string.Empty;
}

// src/{name}.application/Options/Validation/CacheOptionsValidator.cs
using Microsoft.Extensions.Options;

namespace {name}.application.options.validation;

/// <summary>
/// Custom validation using IValidateOptions for complex rules
/// </summary>
public sealed class CacheOptionsValidator : IValidateOptions<CacheOptions>
{
    public ValidateOptionsResult Validate(string? name, CacheOptions options)
    {
        var failures = new List<string>();

        if (options.Enabled && string.IsNullOrWhiteSpace(options.RedisConnectionString))
        {
            failures.Add("RedisConnectionString is required when caching is enabled");
        }

        if (options.DefaultExpirationMinutes < 1)
        {
            failures.Add("DefaultExpirationMinutes must be at least 1");
        }

        if (options.SlidingExpirationMinutes >= options.DefaultExpirationMinutes)
        {
            failures.Add("SlidingExpirationMinutes must be less than DefaultExpirationMinutes");
        }

        return failures.Count > 0
            ? ValidateOptionsResult.Fail(failures)
            : ValidateOptionsResult.Success;
    }
}

模板:在 DependencyInjection 中注册

// src/{name}.application/DependencyInjection.cs
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using {name}.application.options;
using {name}.application.options.validation;

namespace {name}.application;

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // ═══════════════════════════════════════════════════════════════
        // BASIC OPTIONS BINDING
        // ═══════════════════════════════════════════════════════════════
        services.Configure<DatabaseOptions>(
            configuration.GetSection(DatabaseOptions.SectionName));

        // ═══════════════════════════════════════════════════════════════
        // OPTIONS WITH DATA ANNOTATION VALIDATION
        // Validates at startup - fails fast if invalid
        // ═══════════════════════════════════════════════════════════════
        services.AddOptions<JwtOptions>()
            .Bind(configuration.GetSection(JwtOptions.SectionName))
            .ValidateDataAnnotations()
            .ValidateOnStart();  // Validates immediately at startup

        // ═══════════════════════════════════════════════════════════════
        // OPTIONS WITH CUSTOM VALIDATION
        // ═══════════════════════════════════════════════════════════════
        services.AddOptions<CacheOptions>()
            .Bind(configuration.GetSection(CacheOptions.SectionName))
            .ValidateDataAnnotations()
            .ValidateOnStart();

        services.AddSingleton<IValidateOptions<CacheOptions>, CacheOptionsValidator>();

        // ═══════════════════════════════════════════════════════════════
        // OPTIONS WITH POST-CONFIGURE
        // Modify options after binding
        // ═══════════════════════════════════════════════════════════════
        services.PostConfigure<DatabaseOptions>(options =>
        {
            // Apply environment-specific modifications
            if (string.IsNullOrEmpty(options.ConnectionString))
            {
                options.ConnectionString = Environment.GetEnvironmentVariable("DATABASE_URL")
                    ?? throw new InvalidOperationException("Database connection string not configured");
            }
        });

        return services;
    }
}

模板:使用 IOptions(单例服务)

// src/{name}.infrastructure/Services/JwtTokenService.cs
using Microsoft.Extensions.Options;
using {name}.application.options;

namespace {name}.infrastructure.services;

/// <summary>
/// Use IOptions<T> for:
/// - Singleton services
/// - Configuration that doesn't change at runtime
/// - Best performance (read once, cached forever)
/// </summary>
public sealed class JwtTokenService : IJwtTokenService
{
    private readonly JwtOptions _options;

    public JwtTokenService(IOptions<JwtOptions> options)
    {
        // .Value reads the options once
        _options = options.Value;
    }

    public string GenerateAccessToken(User user)
    {
        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_options.Secret));

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new Claim(JwtRegisteredClaimNames.Email, user.Email.Value),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };

        var token = new JwtSecurityToken(
            issuer: _options.Issuer,
            audience: _options.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(_options.AccessTokenExpirationMinutes),
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

模板:使用 IOptionsSnapshot(作用域服务)

// src/{name}.application/Features/Email/SendEmail/SendEmailHandler.cs
using Microsoft.Extensions.Options;
using {name}.application.options;

namespace {name}.application.features.email;

/// <summary>
/// Use IOptionsSnapshot<T> for:
/// - Scoped services (per-request in web apps)
/// - Configuration that may change between requests
/// - Named options support
///
/// NOTE: Cannot inject into singleton services!
/// </summary>
public sealed class SendEmailHandler : ICommandHandler<SendEmailCommand, Result>
{
    private readonly EmailOptions _options;
    private readonly IEmailSender _emailSender;

    public SendEmailHandler(
        IOptionsSnapshot<EmailOptions> options,
        IEmailSender emailSender)
    {
        // Gets fresh value for each request
        _options = options.Value;
        _emailSender = emailSender;
    }

    public async Task<Result> Handle(
        SendEmailCommand command,
        CancellationToken cancellationToken)
    {
        if (!_options.Enabled)
        {
            return Result.Success(); // Email disabled
        }

        await _emailSender.SendAsync(
            to: command.To,
            subject: command.Subject,
            body: command.Body,
            from: _options.FromAddress);

        return Result.Success();
    }
}

模板:使用 IOptionsMonitor(带变更通知的单例)

// src/{name}.infrastructure/Services/FeatureFlagService.cs
using Microsoft.Extensions.Options;
using {name}.application.options;

namespace {name}.infrastructure.services;

/// <summary>
/// Use IOptionsMonitor<T> for:
/// - Singleton services that need updated configuration
/// - Real-time configuration changes without restart
/// - Background services and hosted services
/// </summary>
public sealed class FeatureFlagService : IFeatureFlagService, IDisposable
{
    private readonly IOptionsMonitor<FeatureFlagOptions> _optionsMonitor;
    private readonly ILogger<FeatureFlagService> _logger;
    private readonly IDisposable? _changeListener;

    public FeatureFlagService(
        IOptionsMonitor<FeatureFlagOptions> optionsMonitor,
        ILogger<FeatureFlagService> logger)
    {
        _optionsMonitor = optionsMonitor;
        _logger = logger;

        // ═══════════════════════════════════════════════════════════════
        // CHANGE NOTIFICATION
        // Subscribe to configuration changes
        // ═══════════════════════════════════════════════════════════════
        _changeListener = _optionsMonitor.OnChange((options, name) =>
        {
            _logger.LogInformation(
                "Feature flags updated. DarkMode: {DarkMode}, BetaFeatures: {BetaFeatures}",
                options.EnableDarkMode,
                options.EnableBetaFeatures);
        });
    }

    public bool IsEnabled(string featureName)
    {
        // Always gets current value
        var options = _optionsMonitor.CurrentValue;

        return featureName switch
        {
            "DarkMode" => options.EnableDarkMode,
            "BetaFeatures" => options.EnableBetaFeatures,
            "NewCheckout" => options.EnableNewCheckout,
            _ => false
        };
    }

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

模板:命名 Options

// src/{name}.application/Options/StorageOptions.cs
namespace {name}.application.options;

public sealed class StorageOptions
{
    public const string SectionName = "Storage";

    // Named option keys
    public const string LocalStorage = "Local";
    public const string CloudStorage = "Cloud";

    public required string BasePath { get; set; }
    public int MaxFileSizeMb { get; set; } = 10;
    public string[] AllowedExtensions { get; set; } = Array.Empty<string>();
}
命名 Options 的 appsettings.json
{
  "Storage": {
    "Local": {
      "BasePath": "./uploads",
      "MaxFileSizeMb": 50,
      "AllowedExtensions": [".jpg", ".png", ".pdf"]
    },
    "Cloud": {
      "BasePath": "https://storage.blob.core.windows.net/uploads",
      "MaxFileSizeMb": 100,
      "AllowedExtensions": [".jpg", ".png", ".pdf", ".zip"]
    }
  }
}
注册
// src/{name}.infrastructure/DependencyInjection.cs
services.Configure<StorageOptions>(
    StorageOptions.LocalStorage,
    configuration.GetSection($"{StorageOptions.SectionName}:{StorageOptions.LocalStorage}"));

services.Configure<StorageOptions>(
    StorageOptions.CloudStorage,
    configuration.GetSection($"{StorageOptions.SectionName}:{StorageOptions.CloudStorage}"));
使用命名 Options
// src/{name}.infrastructure/Services/FileUploadService.cs
public sealed class FileUploadService : IFileUploadService
{
    private readonly StorageOptions _localOptions;
    private readonly StorageOptions _cloudOptions;

    public FileUploadService(IOptionsSnapshot<StorageOptions> optionsSnapshot)
    {
        // Access named options
        _localOptions = optionsSnapshot.Get(StorageOptions.LocalStorage);
        _cloudOptions = optionsSnapshot.Get(StorageOptions.CloudStorage);
    }

    public async Task<string> UploadAsync(
        Stream file,
        string fileName,
        StorageTarget target)
    {
        var options = target switch
        {
            StorageTarget.Local => _localOptions,
            StorageTarget.Cloud => _cloudOptions,
            _ => throw new ArgumentOutOfRangeException(nameof(target))
        };

        // Validate file size
        if (file.Length > options.MaxFileSizeMb * 1024 * 1024)
        {
            throw new InvalidOperationException(
                $"File exceeds maximum size of {options.MaxFileSizeMb}MB");
        }

        // Validate extension
        var extension = Path.GetExtension(fileName).ToLowerInvariant();
        if (!options.AllowedExtensions.Contains(extension))
        {
            throw new InvalidOperationException(
                $"File extension {extension} is not allowed");
        }

        // Upload to appropriate storage
        return await UploadToStorage(file, fileName, options.BasePath);
    }
}

模板:后台服务中的 Options

// src/{name}.infrastructure/BackgroundJobs/OutboxProcessorJob.cs
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using {name}.application.options;

namespace {name}.infrastructure.backgroundjobs;

/// <summary>
/// Background services are singletons - use IOptionsMonitor<T>
/// to get configuration updates without restart.
/// </summary>
public sealed class OutboxProcessorJob : BackgroundService
{
    private readonly IOptionsMonitor<OutboxOptions> _optionsMonitor;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OutboxProcessorJob> _logger;

    public OutboxProcessorJob(
        IOptionsMonitor<OutboxOptions> optionsMonitor,
        IServiceScopeFactory scopeFactory,
        ILogger<OutboxProcessorJob> logger)
    {
        _optionsMonitor = optionsMonitor;
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // Get current options - may have changed since last iteration
            var options = _optionsMonitor.CurrentValue;

            if (!options.Enabled)
            {
                _logger.LogDebug("Outbox processor is disabled");
                await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
                continue;
            }

            try
            {
                await ProcessOutboxMessages(options.BatchSize, stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing outbox messages");
            }

            // Interval can change at runtime
            await Task.Delay(
                TimeSpan.FromSeconds(options.ProcessingIntervalSeconds),
                stoppingToken);
        }
    }

    private async Task ProcessOutboxMessages(int batchSize, CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var processor = scope.ServiceProvider.GetRequiredService<IOutboxProcessor>();
        await processor.ProcessAsync(batchSize, ct);
    }
}

决策矩阵:该使用哪个接口?

┌────────────────────────────────────────────────────────────────┐
│                    Which Options Interface?                     │
├────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Is your service a Singleton?                                   │
│       │                                                         │
│       ├── YES ──► Do you need config updates at runtime?        │
│       │               │                                         │
│       │               ├── YES ──► Use IOptionsMonitor<T>        │
│       │               │                                         │
│       │               └── NO ───► Use IOptions<T>               │
│       │                                                         │
│       └── NO ──► Use IOptionsSnapshot<T>                        │
│                  (works for Scoped and Transient)               │
│                                                                 │
└────────────────────────────────────────────────────────────────┘

关键规则

  1. 定义 SectionName 常量 - 为配置路径提供唯一真实来源
  2. 启动时验证 - 使用 .ValidateOnStart() 快速失败
  3. 使用 required 关键字 - 用于必需配置(.NET 7+)
  4. 让接口与生命周期匹配 - 单例使用 IOptions,作用域服务使用 IOptionsSnapshot
  5. 后台服务使用 IOptionsMonitor - 它们是单例
  6. 优先使用数据注解 - 比自定义验证器更简单
  7. 不要在作用域服务中注入 IOptions - 无法看到变更
  8. 多个配置使用命名 Options - 类型相同,值不同
  9. 记录 Options 文档 - 使用 XML 注释支持 IntelliSense
  10. 设置合理的默认值 - 尽量减少必需配置

应避免的反模式

// ❌ WRONG: Accessing configuration directly
public class OrderService
{
    public OrderService(IConfiguration configuration)
    {
        var connectionString = configuration["Database:ConnectionString"];
    }
}

// ✅ CORRECT: Use strongly-typed options
public class OrderService
{
    public OrderService(IOptions<DatabaseOptions> options)
    {
        var connectionString = options.Value.ConnectionString;
    }
}

// ❌ WRONG: IOptionsSnapshot in singleton service
public class SingletonService  // Registered as Singleton!
{
    public SingletonService(IOptionsSnapshot<MyOptions> options) // Will fail at runtime!
    {
    }
}

// ✅ CORRECT: Use IOptionsMonitor for singletons that need updates
public class SingletonService
{
    public SingletonService(IOptionsMonitor<MyOptions> options)
    {
        var current = options.CurrentValue;
    }
}

// ❌ WRONG: Not validating options
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

// ✅ CORRECT: Validate options at startup
services.AddOptions<JwtOptions>()
    .Bind(configuration.GetSection("Jwt"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

// ❌ WRONG: Magic strings for section names
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
services.Configure<JwtOptions>(configuration.GetSection("JWT"));  // Inconsistent!

// ✅ CORRECT: Use constant for section name
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));

相关技能

  • 23-dotnet-logging-configuration - 使用 Options 配置日志
  • 12-dotnet-jwt-authentication - JWT Options 示例
  • 15-dotnet-quartz-background-jobs - 使用 Options 的后台服务
  • 01-dotnet-clean-architecture - 应用层放置位置

评论

支持 Markdown