Compartir a través de


Generación de orígenes de validación de opciones en tiempo de compilación

En el patrón de opciones, se presentan varios métodos para validar las opciones. Estos métodos incluyen el uso de atributos de anotación de datos o el uso de un validador personalizado. Los atributos de anotación de datos se validan en tiempo de ejecución y pueden incurrir en costos de rendimiento. En este artículo se muestra cómo usar el generador de orígenes de validación de opciones para generar código de validación optimizado en tiempo de compilación.

Generación de implementación automática de IValidateOptions

En el artículo sobre el patrón de opciones se muestra cómo implementar la IValidateOptions<TOptions> interfaz para validar las opciones. El generador de orígenes de validación de opciones puede crear automáticamente la implementación de la IValidateOptions interfaz utilizando los atributos de anotación de datos en la clase de opciones.

El contenido siguiente toma el ejemplo de atributos de anotación que se muestra en patrón Options y lo convierte para usar el generador de orígenes de validación de opciones.

Fíjese en el siguiente archivo appsettings.json:

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

La siguiente clase se enlaza a la sección de configuración "MyCustomSettingsSection" y aplica un par de reglas 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; }
}

En la clase SettingsOptions anterior, la propiedad ConfigurationSectionName contiene el nombre de la sección de configuración a la que se enlazará. En este escenario, el objeto options proporciona el nombre de su sección de configuración. Se usan los siguientes atributos de anotación de datos:

  • RequiredAttribute: especifica que se requiere la propiedad.
  • RegularExpressionAttribute: especifica que el valor de propiedad debe coincidir con el patrón de expresión regular especificado.
  • RangeAttribute: especifica que el valor de propiedad debe estar dentro de un intervalo especificado.

Sugerencia

Además de RequiredAttribute, las propiedades también usan el modificador required. Esto ayuda a garantizar que los usuarios del objeto de opciones no olviden establecer el valor de la propiedad, aunque no se relaciona con la característica de generación de código fuente para la validación.

El código siguiente ejemplifica cómo enlazar la sección de configuración al objeto options y validar las anotaciones de datos:

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

Sugerencia

Cuando la compilación AOT está habilitada mediante la inclusión <PublishAot>true</PublishAot> en el archivo .csproj , el código podría generar advertencias como IL2025 e IL3050. Para mitigar estas advertencias, se recomienda usar el generador de configuración de origen. Para habilitar el generador de origen de configuración, agregue la propiedad <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> al archivo del proyecto.

Al aprovechar la generación de orígenes en tiempo de compilación para la validación de opciones, puede generar código de validación optimizado para el rendimiento y eliminar la necesidad de reflexión, lo que da lugar a una compilación de aplicaciones compatible con AOT más fluida. En el código siguiente se muestra cómo usar el generador de orígenes de validación de opciones:

using Microsoft.Extensions.Options;

namespace ConsoleJson.Example;

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

La presencia de OptionsValidatorAttribute en una clase parcial vacía indica al generador de fuentes de validación de opciones que cree la implementación de la interfaz IValidateOptions que valida SettingsOptions. El código generado por el generador de orígenes de validación de opciones será similar al ejemplo siguiente:

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

El código generado está optimizado para el rendimiento y no se basa en la reflexión. También es compatible con AOT. El código generado se coloca en un archivo denominado Validators.g.cs.

Nota:

No es necesario realizar ningún paso adicional para habilitar el generador de orígenes de validación de opciones. Se habilita automáticamente de forma predeterminada cuando el proyecto hace referencia a Microsoft.Extensions.Options versión 8 o posterior, o al compilar una aplicación de ASP.NET.

El único paso que debe realizar es agregar lo siguiente al código de inicio:

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

Nota:

No es necesario llamar a OptionsBuilderDataAnnotationsExtensions.ValidateDataAnnotations<TOptions>(OptionsBuilder<TOptions>) al usar el generador de origen de validación de opciones.

Cuando la aplicación intenta acceder al objeto options, se ejecuta el código generado para la validación de opciones para validar el objeto options. El siguiente fragmento de código muestra cómo acceder al objeto options:

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

Atributos de anotación de datos reemplazados

Después de examinar de cerca el código generado, observará que los atributos de anotación de datos originales, como RangeAttribute, que se aplicaron inicialmente a la propiedad SettingsOptions.Scale, se han sustituido por atributos personalizados como __SourceGen__RangeAttribute. Esta sustitución se realiza porque el RangeAttribute se basa en la reflexión para la validación. En cambio, __SourceGen__RangeAttribute es un atributo personalizado optimizado para el rendimiento y no depende de la reflexión, lo que hace que el código sea compatible con AOT. El mismo patrón de reemplazo de atributos se aplicará en MaxLengthAttribute, MinLengthAttributey LengthAttribute además de RangeAttribute.

Para cualquier persona que desarrolle atributos de anotación de datos personalizados, es aconsejable no usar la reflexión para la validación. En su lugar, se recomienda crear código fuertemente tipado que no se base en la reflexión. Este enfoque garantiza una compatibilidad fluida con las compilaciones de AOT.

Consulte también

Patrón de opciones