次の方法で共有


コンパイル時オプションの検証ソースの生成

オプション パターンでは、オプションを検証するためのさまざまなメソッドが表示されます。 これらのメソッドには、データ注釈属性の使用やカスタム検証コントロールの使用が含まれます。 データ注釈属性は実行時に検証され、パフォーマンス コストが発生する可能性があります。 この記事では、オプション検証ソース ジェネレーターを使用して、コンパイル時に最適化された検証コードを生成する方法について説明します。

IValidateOptions 実装の自動生成

オプション パターンの記事では、オプションを検証するためのIValidateOptions<TOptions> インターフェイスを実装する方法を示します。 オプション検証ソース ジェネレーターでは、options クラスのデータ注釈属性を利用して、 IValidateOptions インターフェイスの実装を自動的に作成できます。

次のコンテンツは 、Options パターン に示されている注釈属性の例を受け取り、オプション検証ソース ジェネレーターを使用するように変換します。

以下の 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 オブジェクトのコンシューマーがプロパティ値の設定を忘れないようにするのに役立ちますが、検証ソース生成機能には関係ありません。

次のコードは、構成セクションを 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が存在すると、オプション検証ソース ジェネレーターは、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に加えて、MinLengthAttributeLengthAttribute、およびRangeAttributeにも適用されます。

カスタム データ注釈属性を開発しているユーザーには、検証にリフレクションを使用しないことをお勧めします。 代わりに、リフレクションに依存しない厳密に型指定されたコードを作成することをお勧めします。 この方法により、AOT ビルドとのスムーズな互換性が確保されます。

こちらも参照ください

オプション パターン