在 选项模式中,将显示用于验证选项的各种方法。 这些方法包括使用数据注释属性或使用自定义验证程序。 数据注释属性在运行时进行验证,可能会产生性能成本。 本文演示如何利用选项验证源生成器在编译时生成优化的验证代码。
自动 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:指定属性为必需。
- RegularExpressionAttribute:指定属性值必须与指定的正则表达式模式匹配。
- RangeAttribute:指定属性值必须位于指定范围内。
小窍门
除了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 编译时,代码可能会生成 IL2025 和 IL3050 等警告。 若要缓解这些警告,建议使用配置源生成器。 若要启用配置源生成器,请将属性 <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
添加到项目文件。
利用编译时源生成选项验证,可以生成性能优化的验证代码,并消除反射需求,从而生成更流畅的 AOT 兼容的应用生成。 以下代码演示如何使用选项验证源生成器:
using Microsoft.Extensions.Options;
namespace ConsoleJson.Example;
[OptionsValidator]
public partial class ValidateSettingsOptions : IValidateOptions<SettingsOptions>
{
}
空分部类上 OptionsValidatorAttribute 的存在指示选项验证源生成器创建验证 IValidateOptions
的 SettingsOptions
接口实现。 由选项验证源生成器生成的代码将类似于以下示例:
// <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>();
注释
使用选项验证源生成器时不需要调用 OptionsBuilderDataAnnotationsExtensions.ValidateDataAnnotations<TOptions>(OptionsBuilder<TOptions>) 。
当应用程序尝试访问 options 对象时,将执行生成的选项验证代码来验证 options 对象。 以下代码片段演示如何访问 options 对象:
var settingsOptions =
app.Services.GetRequiredService<IOptions<SettingsOptions>>().Value;
数据注释属性已被替换
仔细检查生成的代码后,你将发现最初应用于该属性RangeAttribute的原始数据批注属性SettingsOptions.Scale
已替换为自定义属性,例如__SourceGen__RangeAttribute
。 进行这种替换是因为 RangeAttribute
依赖于反射来进行验证。 相比之下,__SourceGen__RangeAttribute
是经过性能优化的自定义属性,不依赖于反射,使代码 AOT 兼容。 将相同的属性替换模式应用于MaxLengthAttribute、MinLengthAttribute、LengthAttribute以及RangeAttribute。
对于任何开发自定义数据注释属性的人来说,建议不要使用反射进行验证。 相反,建议编写不依赖于反射的强类型代码。 此方法可确保与 AOT 生成顺利兼容。