Udostępnij przez


Generowanie źródła do walidacji opcji czasu kompilacji

We wzorcu opcji przedstawiono różne metody sprawdzania poprawności opcji. Metody te obejmują używanie atrybutów adnotacji danych lub zastosowanie niestandardowego modułu sprawdzania poprawności. Atrybuty adnotacji danych są weryfikowane w czasie wykonywania i mogą powodować koszty wydajności. W tym artykule pokazano, jak używać generatora źródeł weryfikacji opcji do tworzenia zoptymalizowanego kodu weryfikacji w czasie kompilacji.

Automatyczne generowanie implementacji IValidateOptions

W artykule dotyczącym wzorca opcji pokazano, jak zaimplementować IValidateOptions<TOptions> interfejs do sprawdzania poprawności opcji. Generator źródeł weryfikacji opcji może automatycznie utworzyć implementację interfejsu IValidateOptions , wykorzystując atrybuty adnotacji danych w klasie options.

Poniższa zawartość przyjmuje przykład atrybutów adnotacji, który jest wyświetlany we wzorcu Opcje i konwertuje go na użycie generatora źródła weryfikacji opcji.

Rozważmy następujący plik appsettings.json:

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

Następująca klasa wiąże się z sekcją "MyCustomSettingsSection" konfiguracji i stosuje kilka DataAnnotations reguł:

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; }
}

W poprzedniej SettingsOptions klasie ConfigurationSectionName właściwość zawiera nazwę sekcji konfiguracji, z która ma być powiązana. W tym scenariuszu obiekt options zawiera nazwę sekcji konfiguracji. Używane są następujące atrybuty adnotacji danych:

  • RequiredAttribute: określa, że właściwość jest wymagana.
  • RegularExpressionAttribute: Określa, że wartość właściwości musi być zgodna z określonym wzorcem wyrażenia regularnego.
  • RangeAttribute: Określa, że wartość właściwości musi należeć do określonego zakresu.

Wskazówka

Oprócz RequiredAttribute, właściwości używają również modyfikatora wymaganego. Pomaga to zapewnić, że użytkownicy obiektu opcji nie zapominają o ustawieniu wartości właściwości, chociaż nie są one powiązane z funkcją generowania źródła weryfikacji.

Poniższy kod ilustruje sposób powiązania sekcji konfiguracji z obiektem options i weryfikowania adnotacji danych:

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();

Wskazówka

Gdy kompilacja AOT jest włączona przez dołączenie <PublishAot>true</PublishAot> do pliku csproj , kod może generować ostrzeżenia, takie jak IL2025 i IL3050. Aby wyeliminować te ostrzeżenia, zaleca się użycie generatora źródła konfiguracji. Aby włączyć generator źródła konfiguracji, dodaj właściwość <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> do pliku projektu.

Korzystając z generowania kodu źródłowego podczas kompilacji na potrzeby weryfikacji opcji, można wygenerować kod weryfikacji zoptymalizowany pod kątem wydajności i wyeliminować potrzebę refleksji, co zapewnia bezproblemowe kompilowanie aplikacji zgodnych z funkcją AOT. Poniższy kod pokazuje, jak używać generatora źródeł weryfikacji opcji:

using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

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

Obecność OptionsValidatorAttribute w pustej klasie częściowej instruuje generator źródła walidacji opcji, aby utworzyć implementację interfejsu IValidateOptions, która weryfikuje SettingsOptions. Kod wygenerowany przez generator źródeł weryfikacji opcji będzie podobny do następującego przykładu:

// <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;
        }
    }
}

Wygenerowany kod jest zoptymalizowany pod kątem wydajności i nie opiera się na odbiciu. Jest również zgodny z AOT. Wygenerowany kod jest umieszczany w pliku o nazwie Validators.g.cs.

Uwaga / Notatka

Nie musisz wykonywać żadnych dodatkowych kroków, aby włączyć generator źródeł weryfikacji opcji. Jest ona domyślnie włączona automatycznie, gdy projekt odwołuje się do microsoft.Extensions.Options w wersji 8 lub nowszej albo podczas kompilowania aplikacji ASP.NET.

Jedynym krokiem, który należy wykonać, jest dodanie następującego kodu uruchamiania:

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

Uwaga / Notatka

Wywołanie OptionsBuilderDataAnnotationsExtensions.ValidateDataAnnotations<TOptions>(OptionsBuilder<TOptions>) nie jest wymagane w przypadku korzystania z generatora źródeł weryfikacji opcji.

Gdy aplikacja próbuje uzyskać dostęp do obiektu options, wygenerowany kod weryfikacji opcji jest wykonywany w celu zweryfikowania obiektu options. Poniższy fragment kodu ilustruje sposób uzyskiwania dostępu do obiektu options:

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

Zamieniono atrybuty adnotacji danych

Przy dokładnym badaniu wygenerowanego kodu zauważysz, że oryginalne atrybuty adnotacji danych, takie jak RangeAttribute, które zostały początkowo zastosowane do właściwości SettingsOptions.Scale, zostały zastąpione niestandardowymi atrybutami, takimi jak __SourceGen__RangeAttribute. To podstawienie jest wykonywane, ponieważ RangeAttribute opiera się na odbiciu na potrzeby walidacji. Natomiast __SourceGen__RangeAttribute to niestandardowy atrybut zoptymalizowany pod kątem wydajności, który nie zależy od refleksji, co sprawia, że kod jest zgodny z AOT. Ten sam wzorzec zastępowania atrybutów zostanie zastosowany na MaxLengthAttribute, MinLengthAttribute, LengthAttribute oraz RangeAttribute.

Dla tych, którzy opracowują niestandardowe atrybuty adnotacji danych, zaleca się powstrzymanie się od używania refleksji do weryfikacji. Zamiast tego zaleca się tworzenie silnie typizowanego kodu, który nie opiera się na refleksji. Takie podejście zapewnia bezproblemową zgodność z kompilacjami AOT.

Zobacz także

Wzorzec opcji