编译时选项验证源生成

选项模式中,将显示用于验证选项的各种方法。 这些方法包括使用数据注释属性或使用自定义验证程序。 数据注释属性在运行时进行验证,可能会产生性能成本。 本文演示如何利用选项验证源生成器在编译时生成优化的验证代码。

自动 IValidateOptions 实现生成

选项模式文章说明了如何实现用于验证选项的IValidateOptions<TOptions>接口。 选项验证源生成器可以通过利用选项类上的数据注释属性自动创建 IValidateOptions 接口实现。

接下来的内容采用选项模式中显示的注释属性示例,并将其转换为使用选项验证源生成器。

请考虑以下 appsettings.json 文件:

{
    "MyCustomSettingsSection": {
        "SiteTitle": "Amazing docs from awesome people!",
        "Scale": 10,
        "VerbosityLevel": 32
    }
}

下面的类绑定到 "MyCustomSettingsSection" 配置节,并应用若干 DataAnnotations 规则:

using System.ComponentModel.DataAnnotations;

namespace ConsoleJson.Example;

public sealed class SettingsOptions
{
    public const string ConfigurationSectionName = "MyCustomSettingsSection";

    [Required]
    [RegularExpression(@"^[a-zA-Z''!'\s]{1,40}$")]
    public required string SiteTitle { get; set; }

    [Required]
    [Range(0, 1_000,
        ErrorMessage = "Value for {0} must be between {1} and {2}.")]
    public required int? Scale { get; set; }

    [Required]
    public required int? VerbosityLevel { get; set; }
}

在前面的 SettingsOptions 类中,ConfigurationSectionName 属性包含要绑定到的配置部分的名称。 在此方案中,选项对象提供其配置部分的名称。 使用以下数据注释属性:

小窍门

除了RequiredAttribute外,属性还使用所需的修饰符。 这有助于确保选项对象的使用者不要忘记设置属性值,尽管它与验证源生成功能无关。

以下代码演示了如何将配置节绑定到 options 对象并验证数据注释:

using ConsoleJson.Example;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services
    .AddOptions<SettingsOptions>()
    .Bind(builder.Configuration.GetSection(SettingsOptions.ConfigurationSectionName));

builder.Services
    .AddSingleton<IValidateOptions<SettingsOptions>, ValidateSettingsOptions>();

using IHost app = builder.Build();

var settingsOptions =
    app.Services.GetRequiredService<IOptions<SettingsOptions>>().Value;

await app.RunAsync();

小窍门

<PublishAot>true</PublishAot> 文件中包含 来启用 AOT 编译时,代码可能会生成 IL2025IL3050 等警告。 若要缓解这些警告,建议使用配置源生成器。 若要启用配置源生成器,请将属性 <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> 添加到项目文件。

利用编译时源生成选项验证,可以生成性能优化的验证代码,并消除反射需求,从而生成更流畅的 AOT 兼容的应用生成。 以下代码演示如何使用选项验证源生成器:

using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

[OptionsValidator]
public partial class ValidateSettingsOptions : IValidateOptions<SettingsOptions>
{
}

空分部类上 OptionsValidatorAttribute 的存在指示选项验证源生成器创建验证 IValidateOptionsSettingsOptions 接口实现。 由选项验证源生成器生成的代码将类似于以下示例:

// <auto-generated/>
#nullable enable
#pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103
namespace ConsoleJson.Example
{
    partial class ValidateSettingsOptions
    {
        /// <summary>
        /// Validates a specific named options instance (or all when <paramref name="name"/> is <see langword="null" />).
        /// </summary>
        /// <param name="name">The name of the options instance being validated.</param>
        /// <param name="options">The options instance.</param>
        /// <returns>Validation result.</returns>
        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.SourceGeneration", "8.0.9.3103")]
        [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
             Justification = "The created ValidationContext object is used in a way that never call reflection")]
        public global::Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, global::ConsoleJson.Example.SettingsOptions options)
        {
            global::Microsoft.Extensions.Options.ValidateOptionsResultBuilder? builder = null;
            var context = new global::System.ComponentModel.DataAnnotations.ValidationContext(options);
            var validationResults = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationResult>();
            var validationAttributes = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(2);

            context.MemberName = "SiteTitle";
            context.DisplayName = string.IsNullOrEmpty(name) ? "SettingsOptions.SiteTitle" : $"{name}.SiteTitle";
            validationAttributes.Add(global::__OptionValidationStaticInstances.__Attributes.A1);
            validationAttributes.Add(global::__OptionValidationStaticInstances.__Attributes.A2);
            if (!global::System.ComponentModel.DataAnnotations.Validator.TryValidateValue(options.SiteTitle, context, validationResults, validationAttributes))
            {
                (builder ??= new()).AddResults(validationResults);
            }

            context.MemberName = "Scale";
            context.DisplayName = string.IsNullOrEmpty(name) ? "SettingsOptions.Scale" : $"{name}.Scale";
            validationResults.Clear();
            validationAttributes.Clear();
            validationAttributes.Add(global::__OptionValidationStaticInstances.__Attributes.A1);
            validationAttributes.Add(global::__OptionValidationStaticInstances.__Attributes.A3);
            if (!global::System.ComponentModel.DataAnnotations.Validator.TryValidateValue(options.Scale, context, validationResults, validationAttributes))
            {
                (builder ??= new()).AddResults(validationResults);
            }

            context.MemberName = "VerbosityLevel";
            context.DisplayName = string.IsNullOrEmpty(name) ? "SettingsOptions.VerbosityLevel" : $"{name}.VerbosityLevel";
            validationResults.Clear();
            validationAttributes.Clear();
            validationAttributes.Add(global::__OptionValidationStaticInstances.__Attributes.A1);
            if (!global::System.ComponentModel.DataAnnotations.Validator.TryValidateValue(options.VerbosityLevel, context, validationResults, validationAttributes))
            {
                (builder ??= new()).AddResults(validationResults);
            }

            return builder is null ? global::Microsoft.Extensions.Options.ValidateOptionsResult.Success : builder.Build();
        }
    }
}
namespace __OptionValidationStaticInstances
{
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.SourceGeneration", "8.0.9.3103")]
    file static class __Attributes
    {
        internal static readonly global::System.ComponentModel.DataAnnotations.RequiredAttribute A1 = new global::System.ComponentModel.DataAnnotations.RequiredAttribute();

        internal static readonly global::System.ComponentModel.DataAnnotations.RegularExpressionAttribute A2 = new global::System.ComponentModel.DataAnnotations.RegularExpressionAttribute(
            "^[a-zA-Z''-'\\s]{1,40}$");

        internal static readonly __OptionValidationGeneratedAttributes.__SourceGen__RangeAttribute A3 = new __OptionValidationGeneratedAttributes.__SourceGen__RangeAttribute(
            (int)0,
            (int)1000)
        {
            ErrorMessage = "Value for {0} must be between {1} and {2}."
        };
    }
}
namespace __OptionValidationStaticInstances
{
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.SourceGeneration", "8.0.9.3103")]
    file static class __Validators
    {
    }
}
namespace __OptionValidationGeneratedAttributes
{
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.SourceGeneration", "8.0.9.3103")]
    [global::System.AttributeUsage(global::System.AttributeTargets.Property | global::System.AttributeTargets.Field | global::System.AttributeTargets.Parameter, AllowMultiple = false)]
    file class __SourceGen__RangeAttribute : global::System.ComponentModel.DataAnnotations.ValidationAttribute
    {
        public __SourceGen__RangeAttribute(int minimum, int maximum) : base()
        {
            Minimum = minimum;
            Maximum = maximum;
            OperandType = typeof(int);
        }
        public __SourceGen__RangeAttribute(double minimum, double maximum) : base()
        {
            Minimum = minimum;
            Maximum = maximum;
            OperandType = typeof(double);
        }
        public __SourceGen__RangeAttribute(global::System.Type type, string minimum, string maximum) : base()
        {
            OperandType = type;
            NeedToConvertMinMax = true;
            Minimum = minimum;
            Maximum = maximum;
        }
        public object Minimum { get; private set; }
        public object Maximum { get; private set; }
        public bool MinimumIsExclusive { get; set; }
        public bool MaximumIsExclusive { get; set; }
        public global::System.Type OperandType { get; }
        public bool ParseLimitsInInvariantCulture { get; set; }
        public bool ConvertValueInInvariantCulture { get; set; }
        public override string FormatErrorMessage(string name) =>
                string.Format(global::System.Globalization.CultureInfo.CurrentCulture, GetValidationErrorMessage(), name, Minimum, Maximum);
        private bool NeedToConvertMinMax { get; }
        private bool Initialized { get; set; }
        public override bool IsValid(object? value)
        {
            if (!Initialized)
            {
                if (Minimum is null || Maximum is null)
                {
                    throw new global::System.InvalidOperationException("The minimum and maximum values must be set to valid values.");
                }
                if (NeedToConvertMinMax)
                {
                    System.Globalization.CultureInfo culture = ParseLimitsInInvariantCulture ? global::System.Globalization.CultureInfo.InvariantCulture : global::System.Globalization.CultureInfo.CurrentCulture;
                    Minimum = ConvertValue(Minimum, culture) ?? throw new global::System.InvalidOperationException("The minimum and maximum values must be set to valid values.");
                    Maximum = ConvertValue(Maximum, culture) ?? throw new global::System.InvalidOperationException("The minimum and maximum values must be set to valid values.");
                }
                int cmp = ((global::System.IComparable)Minimum).CompareTo((global::System.IComparable)Maximum);
                if (cmp > 0)
                {
                    throw new global::System.InvalidOperationException("The maximum value '{Maximum}' must be greater than or equal to the minimum value '{Minimum}'.");
                }
                else if (cmp == 0 && (MinimumIsExclusive || MaximumIsExclusive))
                {
                    throw new global::System.InvalidOperationException("Cannot use exclusive bounds when the maximum value is equal to the minimum value.");
                }
                Initialized = true;
            }

            if (value is null or string { Length: 0 })
            {
                return true;
            }

            System.Globalization.CultureInfo formatProvider = ConvertValueInInvariantCulture ? global::System.Globalization.CultureInfo.InvariantCulture : global::System.Globalization.CultureInfo.CurrentCulture;
            object? convertedValue;

            try
            {
                convertedValue = ConvertValue(value, formatProvider);
            }
            catch (global::System.Exception e) when (e is global::System.FormatException or global::System.InvalidCastException or global::System.NotSupportedException)
            {
                return false;
            }

            var min = (global::System.IComparable)Minimum;
            var max = (global::System.IComparable)Maximum;

            return
                (MinimumIsExclusive ? min.CompareTo(convertedValue) < 0 : min.CompareTo(convertedValue) <= 0) &&
                (MaximumIsExclusive ? max.CompareTo(convertedValue) > 0 : max.CompareTo(convertedValue) >= 0);
        }
        private string GetValidationErrorMessage()
        {
            return (MinimumIsExclusive, MaximumIsExclusive) switch
            {
                (false, false) => "The field {0} must be between {1} and {2}.",
                (true, false) => "The field {0} must be between {1} exclusive and {2}.",
                (false, true) => "The field {0} must be between {1} and {2} exclusive.",
                (true, true) => "The field {0} must be between {1} exclusive and {2} exclusive.",
            };
        }
        private object? ConvertValue(object? value, System.Globalization.CultureInfo formatProvider)
        {
            if (value is string stringValue)
            {
                value = global::System.Convert.ChangeType(stringValue, OperandType, formatProvider);
            }
            else
            {
                value = global::System.Convert.ChangeType(value, OperandType, formatProvider);
            }
            return value;
        }
    }
}

生成的代码针对性能进行了优化,不依赖于反射。 它还与 AOT 兼容。 生成的代码放置在名为 Validators.g.cs的文件中。

注释

无需执行任何其他步骤即可启用选项验证源生成器。 默认情况下,当项目引用 Microsoft.Extensions.Options 8 或更高版本或生成 ASP.NET 应用程序时,它会自动启用。

唯一需要执行的步骤是将以下内容添加到启动代码:

builder.Services
    .AddSingleton<IValidateOptions<SettingsOptions>, ValidateSettingsOptions>();

当应用程序尝试访问 options 对象时,将执行生成的选项验证代码来验证 options 对象。 以下代码片段演示如何访问 options 对象:

var settingsOptions =
    app.Services.GetRequiredService<IOptions<SettingsOptions>>().Value;

数据注释属性已被替换

仔细检查生成的代码后,你将发现最初应用于该属性RangeAttribute的原始数据批注属性SettingsOptions.Scale已替换为自定义属性,例如__SourceGen__RangeAttribute。 进行这种替换是因为 RangeAttribute 依赖于反射来进行验证。 相比之下,__SourceGen__RangeAttribute 是经过性能优化的自定义属性,不依赖于反射,使代码 AOT 兼容。 将相同的属性替换模式应用于MaxLengthAttributeMinLengthAttributeLengthAttribute以及RangeAttribute

对于任何开发自定义数据注释属性的人来说,建议不要使用反射进行验证。 相反,建议编写不依赖于反射的强类型代码。 此方法可确保与 AOT 生成顺利兼容。

另请参阅

选项模式