Intermédiaire

[ASP.NET Core] La librairie OptionsModel

ASP.NET logoASP.NET Core a introduit un modèle unifié permettant la gestion de la configuration de toutes les librairies sous-jacentes (ASP.NET MVC, Razor, etc.), lui-même distribué sous la forme d’une librairie s’appelant OptionsModel.

Qu’est-ce que OptionsModel ?

Le projet possède son propre repository GitHub. Il n’est composé que de quelques classes et interfaces mais leur étude reste néanmoins intéressante.

Si vous avez déjà commencé à explorer un peu des articles de blogs sur ASP.NET Core et notamment sur ASP.NET MVC 6, ou si vous avez ouvert les exemples de code distribués par Microsoft, vous êtes sûrement tombés nez-à-nez avec les lignes ci-dessous. Elles sont extraites du modèle de projet d’application Web ASP.NET Core.

services.Configure<FacebookAuthenticationOptions>(options =>
{
    options.AppId = Configuration["Authentication:Facebook:AppId"];
    options.AppSecret = Configuration["Authentication:Facebook:AppSecret"];
});

services.Configure<MicrosoftAccountAuthenticationOptions>(options =>
{
    options.ClientId = Configuration["Authentication:MicrosoftAccount:ClientId"];
    options.ClientSecret = Configuration["Authentication:MicrosoftAccount:ClientSecret"];
});

Ces appels ont lieu dans la classe Startup du projet, au niveau de la méthode ConfigureServices. La méthode Configure étant une méthode d’extension définie sur l’interface IServiceCollection, au même titre que les méthodes AddMvc, AddCors, etc.

Cette méthode Configure est générique, mais il n’y a pas de contrainte de type appliquée. Cela signifie que les types FacebookAuthentificationOptions et MicrosoftAccountAuthentificationOptions utilisés plus haut, ou n’importe quel autre type qui serait utilisé avec la méthode Configure, n’ont pas nécessairement de liens de parenté.

Et c’est là que réside tout l’intérêt de cette librairie : représenter la configuration d’une librairie à partir d’une classe purement POCO (Plain Old CLR Object). La seule limitation imposée par la libraire est que votre classe POCO d’options doit obligatoirement contenir un constructeur par défaut – c’est à dire public et n’attendant aucun paramètre. Il est alors impossible d’injecter des services issus du conteneur IoC dans la classe d’options.

Configuration des options

Bien sûr, la librairie apporte tout de même son lot de classes utilitaires. Celle qui est indissociable des classes POCO d’options est la classe ConfigureOptions, qui implémente l’interface IConfigureOptions. T étant le type POCO des options de la librairie à configurer.

L’interface est définie de la manière suivante.

public interface IConfigureOptions<in TOptions>
{
    int Order { get; }

    void Configure(TOptions options, string name = "");
}

Généralement, la plupart des librairies n’implémentent pas directement l’interface IConfigureOptions mais dérivent plutôt directement de la classe ConfigureOptions.

Ainsi, dans le cas d’ASP.NET MVC, nous pouvons trouver la classe MvcCoreMvcOptionsSetup dérivant de la classe ConfigureOptions pour le type MvcOptions.

public class MvcCoreMvcOptionsSetup : ConfigureOptions<MvcOptions>
{
    public MvcCoreMvcOptionsSetup()
        : base(ConfigureMvc)
    {
        Order = DefaultOrder.DefaultFrameworkSortOrder;
    }

    public static void ConfigureMvc(MvcOptions options)
    {
        // Set up ModelBinding
        options.ModelBinders.Add(new BinderTypeBasedModelBinder());
        options.ModelBinders.Add(new ServicesModelBinder());
        options.ModelBinders.Add(new BodyModelBinder());
        options.ModelBinders.Add(new HeaderModelBinder());
        options.ModelBinders.Add(new TypeConverterModelBinder());
        options.ModelBinders.Add(new TypeMatchModelBinder());
        options.ModelBinders.Add(new CancellationTokenModelBinder());
        options.ModelBinders.Add(new ByteArrayModelBinder());
        options.ModelBinders.Add(new FormFileModelBinder());
        options.ModelBinders.Add(new FormCollectionModelBinder());
        options.ModelBinders.Add(new GenericModelBinder());
        options.ModelBinders.Add(new MutableObjectModelBinder());
        options.ModelBinders.Add(new ComplexModelDtoModelBinder());

        // Set up default output formatters.
        options.OutputFormatters.Add(new HttpNoContentOutputFormatter());
        options.OutputFormatters.Add(new StringOutputFormatter());
        options.OutputFormatters.Add(new StreamOutputFormatter());

        // Set up ValueProviders
        options.ValueProviderFactories.Add(new RouteValueValueProviderFactory());
        options.ValueProviderFactories.Add(new QueryStringValueProviderFactory());
        options.ValueProviderFactories.Add(new FormValueProviderFactory());

        // Set up metadata providers
        options.ModelMetadataDetailsProviders.Add(new DefaultBindingMetadataProvider());
        options.ModelMetadataDetailsProviders.Add(new DefaultValidationMetadataProvider());

        // Set up validators
        options.ModelValidatorProviders.Add(new DefaultModelValidatorProvider());

        // Add types to be excluded from Validation
        options.ValidationExcludeFilters.Add(new SimpleTypesExcludeFilter());
        options.ValidationExcludeFilters.Add(typeof(Type));

        // Any 'known' types that we bind should be marked as excluded from validation.
        options.ValidationExcludeFilters.Add(typeof(System.Threading.CancellationToken));
        options.ValidationExcludeFilters.Add(typeof(IFormFile));
        options.ValidationExcludeFilters.Add(typeof(IFormCollection));
    }
}

Un autre exemple avec la classe RazorViewEngineOptionsSetup pour la classe d’options RazorViewEngineOptions.

public class RazorViewEngineOptionsSetup : ConfigureOptions<RazorViewEngineOptions>
{
    /// <summary>
    /// Initializes a new instance of <see cref="RazorViewEngineOptions"/>.
    /// </summary>
    /// <param name="applicationEnvironment"><see cref="IApplicationEnvironment"/> for the application.</param>
    public RazorViewEngineOptionsSetup(IApplicationEnvironment applicationEnvironment)
        : base(options => ConfigureRazor(options, applicationEnvironment))
    {
        Order = DefaultOrder.DefaultFrameworkSortOrder;
    }

    private static void ConfigureRazor(RazorViewEngineOptions razorOptions,
                                       IApplicationEnvironment applicationEnvironment)
    {
        razorOptions.FileProvider = new PhysicalFileProvider(applicationEnvironment.ApplicationBasePath);
    }
}

Ces types sont alors enregistrés dans le conteneur IoC de la manière suivante. Notez ici que c’est la méthode TryAddEnumerable qui est utilisée. J’y reviendrai un peu plus tard.

services.TryAddEnumerable(ServiceDescriptor.Transient<IConfigureOptions<MvcOptions>, MvcCoreMvcOptionsSetup>());

services.TryAddEnumerable(ServiceDescriptor.Transient<IConfigureOptions<RazorViewEngineOptions>, RazorViewEngineOptionsSetup>());

Quand vous faîtes appel à la méthode d’extension Configure sur l’interface IServiceCollection, celle-ci va essayer d’enregistrer une implémentation de IConfigureOptions dédiée au type POCO d’options que vous avez spécifié. La lambda que vous passez à la méthode Configure se trouve alors simplement encapsulée dans une instance de ConfigureOptions (cf. la méthode ci-dessous).

public static IServiceCollection Configure<TOptions>([NotNull]this IServiceCollection services,
    [NotNull] Action<TOptions> setupAction)
{
    services.ConfigureOptions(new ConfigureOptions<TOptions>(setupAction));
    return services;
}

Les traitements que vous effectuez alors sur l’instance d’options passée à la lambda de configuration seront alors automatiquement appliqués après les traitements par défaut de librairie ciblée (ces derniers étant définis par exemple par MvcCoreMvcOptionsSetup ou par RazorViewEngineOptionsSetup).

Ci-dessous, deux exemples d’appels de la méthode Configure pour ces deux types d’options.

services.Configure<MvcOptions>((options) => setupAction(options.FormatterMappings));

services.Configure<RazorViewEngineOptions>(
    options =>
    {
        options.ViewLocationExpanders.Add(new LanguageViewLocationExpander(format));
    });

Dans mon conteneur IOC, j’ai maintenant les éléments suivants :

  • Pour IConfigureOptions de MvcOptions, j’ai une instance de MvcCoreMvcOptionsSetup enregistrée automatiquement par la librairie, mais également une instance de ConfigureOptions générée à partir de ma lambda.
  • Pour IConfigureOptions de RazorViewEngineOptions, j’ai une instance de RazorViewEngineOptionsSetup enregistrée automatiquement par la librairie, mais également une instance de ConfigureOptions générée à partir de ma lambda.

Jusqu’à présent, nous avons vu comment déclarer une classe d’options, comment créer une classe POCO pour représenter les options d’une librairie, comment définir ces options, il nous reste encore à comprendre comment accéder et utiliser ces options.

Utiliser des options

C’est une autre interface de la librairie OptionsModel qui entre en jeu, l’interface IOptions. Elle est générique, le type qu’elle peut prendre en paramètre étant le type POCO d’options que l’on souhaite utiliser.

Automatiquement, n’importe où dans une application ASP.NET Core, il est possible de se faire injecter un IOptions pour un type T d’options. Ainsi, dans le code d’ASP.NET MVC, on peut trouver les extraits ci-dessous qui utilisent l’injection de dépendances pour récupérer les options à appliquer.

public class DefaultApplicationModelProvider : IApplicationModelProvider
{
    private readonly ICollection<IFilterMetadata> _globalFilters;

    public DefaultApplicationModelProvider(IOptions<MvcOptions> mvcOptionsAccessor)
    {
        _globalFilters = mvcOptionsAccessor.Options.Filters;
    }

    // ...
}

public class FormatFilter : IFormatFilter, IResourceFilter, IResultFilter
{
    public FormatFilter(IOptions<MvcOptions> options, IScopedInstance<ActionContext> actionContext)
    {
        IsActive = true;
        Format = GetFormat(actionContext.Value);

        if (string.IsNullOrEmpty(Format))
        {
            IsActive = false;
            return;
        }

        ContentType = options.Options.FormatterMappings.GetMediaTypeMappingForFormat(Format);
    }

    // ...
}

public class RazorViewEngine : IRazorViewEngine
{
    public RazorViewEngine(IRazorPageFactory pageFactory,
                           IRazorViewFactory viewFactory,
                           IOptions<RazorViewEngineOptions> optionsAccessor,
                           IViewLocationCache viewLocationCache)
    {
        _pageFactory = pageFactory;
        _viewFactory = viewFactory;
        _viewLocationExpanders = optionsAccessor.Options.ViewLocationExpanders;
        _viewLocationCache = viewLocationCache;
    }

    // ...
}

Mais d’où vient ce fameux IOptions ? Souvenez-vous, jusqu’à présent, nous n’avons fait qu’enregistrer des implémentations de IConfigureOptions.

L’enregistrement est réalisé par l’appel à méthode AddOptions. Cette dernière est appelée directement par AddMvc lorsque vous avez fait le choix d’utiliser ASP.NET MVC dans votre application ASP.NET Core. Vous pouvez bien sûr l’appeler directement vous-mêmes si vous ne choisissez pas d’utiliser un Framework d’aussi haut niveau qu’ASP.NET MVC.

public static IServiceCollection AddOptions([NotNull]this IServiceCollection services)
{
    services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptions<>), typeof(OptionsManager<>)));
    return services;
}

Notez ici que le type IOptions est associé à un OptionsManager. La définition de ce dernier est également contenue dans le code de la librairie OptionsModel. Je vous la cite ci-dessous.

public class OptionsManager<TOptions> : IOptions<TOptions> where TOptions : class,new()
{
    private TOptions _options;
    private IEnumerable<IConfigureOptions<TOptions>> _setups;

    public OptionsManager(IEnumerable<IConfigureOptions<TOptions>> setups)
    {
        _setups = setups;
    }

    public virtual TOptions Value
    {
        get
        {
            if (_options == null)
            {
                _options = _setups == null
                    ? new TOptions()
                    : _setups.Aggregate(new TOptions(),
                                        (options, setup) =>
                                        {
                                            setup.Configure(options);
                                            return options;
                                        });
            }
            return _options;
        }
    }
}

Regardez bien le constructeur de la classe OptionsManager. A chaque fois qu’un composant souhaite se faire injecter un IOptions, l’IoC va instancier (ou réutiliser une instance existante – car il est enregistré en singleton) de OptionsManager pour le type d’options désiré. Et dans le constructeur de OptionsManager, l’IoC va injecter l’intégralité des IOptionsConfigure pour le type d’options dont il est question.

Souvenez-vous, l’enregistrement se fait via une méthode TryAddEnumerable, et bien cet appel trouve son explication ici. Ce n’est pas une seule instance de IOptionsConfigure que l’on injecte ici, mais belle et bien une collection.

Une instance du type d’options sera alors créée au premier accès à la propriété Value. Et c’est lors de cette création que l’ensemble des méthodes de configuration va être exécuté.

Définition d’options depuis une source de configuration

Enfin, voici une des fonctionnalités que j’apprécie le plus dans la librairie OptionsModel. En fait, il ne s’agit pas uniquement d’une fonctionnalité de OptionsModel, mais plutôt la rencontre entre deux librairies d’ASP.NET Core : l’API de configuration et OptionsModel.

En effet, dans l’API de configuration, il est possible d’utiliser la classe ConfigurationBinder et sa méthode Bind. Celle-ci prend une implémentation de IConfiguration et un objet en paramètre. Elle va simplement aller peupler les différentes propriétés de l’objet en fonction des données qu’elle arrive à tirer de la configuration.

Faîtes le test ! Voici une classe d’options d’exemple.

public class MyOptions
{
    public int TimeOut { get; set;}
}

Un fichier .ini de configuration.

TimeOut=30

Et enfin, le code qui fait appel au ConfigurationBinder.

public class Startup
{
    private readonly IConfiguration _configuration;

    public Startup(IApplicationEnvironment applicationEnvironment)
    {
        var configurationBuilder = new ConfigurationBuilder(applicationEnvironment.ApplicationBasePath);

        configurationBuilder.AddIniFile("configuration.ini");

        _configuration = configurationBuilder.Build();
    }

    public void ConfigureServices(IServiceCollection services)
    {
        var options = new MyOptions();
        ConfigurationBinder.Bind(_configuration, options);
    }

    public void Configure(IApplicationBuilder app)
    {
        app.Run(async (context) =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    }
}

Eh bien, je peux modifier mon code de la manière suivante. Cette fois-ci, je fais appel à la méthode Configure sans passer de lambda où je définis les propriétés de ma classe d’options moi-même. A la place, je lui passe une instance de IConfiguration.

public class Startup
{
    private readonly IConfiguration _configuration;

    public Startup(IApplicationEnvironment applicationEnvironment)
    {
        var configurationBuilder = new ConfigurationBuilder(applicationEnvironment.ApplicationBasePath);

        configurationBuilder.AddIniFile("configuration.ini");

        _configuration = configurationBuilder.Build();
    }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddOptions();

        services.Configure<MyOptions>(_configuration);
    }

    public void Configure(IApplicationBuilder app)
    {
        app.Run(async (context) =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    }
}

Ainsi, c’est lui qui va directement faire appel au ConfigurationBinder. Mes libraires, même MVC ou Razor, peuvent alors être configurées directement par un fichier .ini, .json, ou .xml. Plutôt joli, non ?

public static IServiceCollection Configure<TOptions>([NotNull]this IServiceCollection services,
    [NotNull] IConfiguration config)
{
    services.ConfigureOptions(new ConfigureFromConfigurationOptions<TOptions>(config));
    return services;
}    

public class ConfigureFromConfigurationOptions<TOptions> : ConfigureOptions<TOptions>
{
    public ConfigureFromConfigurationOptions([NotNull] IConfiguration config)
        : base(options => ConfigurationBinder.Bind(config, options))
    {
    }
}

Conclusion

J’espère que ce billet va vous aider à comprendre comment fonctionne le modèle Options d’ASP.NET Core. Il y a ici deux objectifs : bien comprendre le fonctionnement des options lorsque vous configurez l’usage d’une librairie, mais aussi réutiliser le même modèle dans le développement de vos propres librairies.

Nombre de vue : 108

AJOUTER UN COMMENTAIRE