Asp.­NET Core - Implementando un proveedor de configuración personalizado

Escrito por  Sergio Parra

Imagino que tienes ciertas configuraciones de tu aplicativo, digamos, en una tabla de SQL Server, ¿sabes que puedes implementar un proveedor de configuración personalizado para poder leerla? ¡Incluso puedes hacer que las configuraciones en tu aplicación se refresquen automáticamente cuando hay un cambio en la tabla en tiempo de ejecución! ¿Quieres saber cómo? Sigue leyendo.

Lo primero que deberemos hacer es definir un par de clases: una que implemente IConfigurationSource y otra que herede de ConfigurationProvider.

IConfigurationSource

Este interfaz representa nuestro origen de configuración de pares de claves/valor. En este caso el interfaz expone un método Build() que retorna un IConfigurationProvider que se implementará más adelante.

1public class SqlServerConfigurationSource : IConfigurationSource
2{
3    public string? ConnectionString { get; set; }
4    public string? CustomQuery { get; set; } =  $"select [Key], [Value] from dbo.Settings";
5    public string? Schema { get; set; } = "dbo";
6    public string? Table { get; set; } = "Settings";
7    public string? KeyColumn { get; set; } = "Key";
8    public string? ValueColumn { get; set; } = "Value";
9    public string? Prefix { get; set; }
10    internal ISqlServerWatcher? SqlServerWatcher { get; set; }
11    public IConfigurationProvider Build(IConfigurationBuilder builder) => new SqlServerConfigurationProvider(this);
12}
13

En esta clase definimos también otra serie de propiedades como la cadena de conexión a nuestro servidor Sql Server, la consulta para devolver los datos de la tabla de configuración, o incluso si tenemos definido nuestro "watcher" que cada cierto tiempo revisa si hay cambios en la tabla de configuración.

ConfigurationProvider

Esta clase base permite devolver pares de claves/valor de un origen de configuración.

1public class SqlServerConfigurationProvider : ConfigurationProvider, IDisposable
2{
3    private readonly SqlServerConfigurationSource? source;
4    private readonly string? query;
5    private readonly IDisposable? changeTokenRegistration;
6    public SqlServerConfigurationProvider(SqlServerConfigurationSource source)
7    {
8        this.source = source;
9        query = this.source?.CustomQuery;
10        if (this.source?.SqlServerWatcher is not null)
11        {
12            changeTokenRegistration = ChangeToken.OnChange(
13                                                () =>
14 this.source.SqlServerWatcher.Watch(),
15 this.Load
16            );
17        }
18    }
19
20    public override void Load()
21    {
22        var data = new Dictionary<string, string>();
23        using var connection = new SqlConnection(source?.ConnectionString);
24        var query = new SqlCommand(this.query, connection);
25        query.Connection.Open();
26        using var reader = query.ExecuteReader();
27        if (reader?.HasRows == true)
28        {
29            while (reader?.Read() == true)
30            {
31                data.Add(!string.IsNullOrWhiteSpace(source?.Prefix) ?
32  $"{source?.Prefix}:{reader[source?.KeyColumn]}":
33 reader[$"{source?.KeyColumn}"].ToString()!,
34 reader[$"{source?.ValueColumn}"].ToString()!);
35            }
36        }                  
37
38        Data = data;
39    }
40
41    public void Dispose()
42    {
43        changeTokenRegistration?.Dispose();
44        source?.SqlServerWatcher?.Dispose();
45    }
46}
47

En la implementación del método Load() lo que se hace simplemente es conectar a la base de datos de nuestro servidor Sql Server, ejecutar la consulta y devolver un diccionario con nuestros pares clave/valor. Vemos en el constructor que se define el "watcher" que ejecutará el método Load() cada vez que lo necesite.

Implementando métodos de extensión

Para poder usar nuestro flamante proveedor personalizado, recomiendo que se implemente métodos de extensión para ello. El código es muy sencillo y creo que poco hay que comentar más.

1public static class SqlServerConfigurationBuilderExtensions
2{
3    public static IConfigurationBuilder AddSqlServer(this IConfigurationBuilder builder, string connectionString) =>
4                                           
5 builder.AddSqlServer(sqlBuilder =>
6 sqlBuilder.UseConnectionString(connectionString));
7
8    public static IConfigurationBuilder AddSqlServer(
9 this IConfigurationBuilder builder,
10 string connectionString,
11 TimeSpan? refreshInterval = null) =>
12                                            builder.Add(new SqlServerConfigurationSource
13                                            {
14                                                ConnectionString = connectionString,
15                                                SqlServerWatcher = refreshInterval.HasValue ?
16                                                                   new SqlServerWatcher(refreshInterval.Value) :
17                                                                   null
18                                            });
19
20    public static IConfigurationBuilder AddSqlServer(this IConfigurationBuilder builder,
21 Action<ISqlServerConfigurationSourceBuilder> sqlBuilderAction)
22    {
23        var sqlBuilder = new SqlServerConfigurationSourceBuilder();
24        sqlBuilderAction(sqlBuilder);
25        var source = sqlBuilder.Build();
26        return builder.Add(source);
27    }
28}
29

Si te fijas en el último método, usaremos un "builder" personalizado para establecer propiedades o configuraciones en nuestro proveedor para usarlo de una forma más sencilla.

Nuestro "builder" se define así

1public interface ISqlServerConfigurationSourceBuilder
2{
3    ISqlServerConfigurationSourceBuilder UseConnectionString(string connectionString);
4    ISqlServerConfigurationSourceBuilder UseCustomQuery(string query);
5    ISqlServerConfigurationSourceBuilder WithTable(string table);
6    ISqlServerConfigurationSourceBuilder WithKeyColumn(string keyColumn);
7    ISqlServerConfigurationSourceBuilder WithValueColumn(string valueColumn);
8    ISqlServerConfigurationSourceBuilder WithSchema(string valueColumn);
9    ISqlServerConfigurationSourceBuilder WithPrefix(string prefix);
10    ISqlServerConfigurationSourceBuilder ConfigureRefresh(TimeSpan refreshInterval);
11
12    SqlServerConfigurationSource Build();
13}
14
15public class SqlServerConfigurationSourceBuilder : ISqlServerConfigurationSourceBuilder
16{
17    public string? ConnectionString { get; private set; }
18    public string? CustomQuery { get; private set; }        
19    public string? Table { get; private set; }
20    public string? KeyColumn { get; private set; }
21    public string? ValueColumn { get; private set; }
22    public string? Schema { get; private set; }
23    public string? Prefix { get; private set; }
24    public TimeSpan? RefreshInterval { get; private set; }
25
26    public ISqlServerConfigurationSourceBuilder UseConnectionString(string connectionString)
27    {
28        if (string.IsNullOrWhiteSpace(connectionString))
29        {
30            throw new ArgumentNullException(connectionString, $"Connection string could not be null or empty!");
31        }
32
33        ConnectionString = connectionString;
34
35        return this;
36    }
37
38    public ISqlServerConfigurationSourceBuilder UseCustomQuery(string query)
39    {
40        if (string.IsNullOrWhiteSpace(query))
41        {
42            throw new ArgumentNullException(query, $"Query could not be null or empty!");
43        }
44
45        CustomQuery = query;
46
47        return this;
48    }
49
50    public ISqlServerConfigurationSourceBuilder WithTable(string table)
51    {
52        if (string.IsNullOrWhiteSpace(table))
53        {
54            throw new ArgumentNullException(table, $"Table could not be null or empty!");
55        }
56
57        Table = table;
58        return this;
59    }
60
61    public ISqlServerConfigurationSourceBuilder WithKeyColumn(string keyColumn)
62    {
63        if (string.IsNullOrWhiteSpace(keyColumn))
64        {
65            throw new ArgumentNullException(keyColumn, $"Key column could not be null or empty!");
66        }
67
68        KeyColumn = keyColumn;
69
70        return this;
71    }
72
73    public ISqlServerConfigurationSourceBuilder WithValueColumn(string valueColumn)
74    {
75        if (string.IsNullOrWhiteSpace(valueColumn))
76        {
77            throw new ArgumentNullException(valueColumn, $"Value column could not be null or empty!");
78        }
79
80        ValueColumn = valueColumn;
81
82        return this;
83    }
84
85    public ISqlServerConfigurationSourceBuilder WithSchema(string schema)
86    {
87        if (string.IsNullOrWhiteSpace(schema))
88        {
89            throw new ArgumentNullException(schema, $"Schema could not be null or empty!");
90        }
91
92        Schema = schema;
93
94        return this;
95    }
96
97    public ISqlServerConfigurationSourceBuilder WithPrefix(string prefix)
98    {
99        if (string.IsNullOrWhiteSpace(prefix))
100        {
101            throw new ArgumentNullException(prefix, $"Prefix could not be null or empty!");
102        }
103
104        Prefix = prefix;
105
106        return this;
107    }
108
109    public ISqlServerConfigurationSourceBuilder ConfigureRefresh(TimeSpan refreshInterval)
110    {
111        if (refreshInterval < TimeSpan.Zero)
112        {
113            throw new ArgumentException($"Refresh interval must be positive.");
114        }                
115
116        RefreshInterval = refreshInterval;
117
118        return this;
119    }
120
121    public SqlServerConfigurationSource Build()
122    {
123        var instance = new SqlServerConfigurationSource { ConnectionString = ConnectionString };
124
125        if (Table != null)
126        {
127            instance.Table = Table;
128        }                
129
130        if (KeyColumn != null)
131        {
132            instance.KeyColumn = KeyColumn;
133        }                
134
135        if (ValueColumn != null)
136        {
137            instance.ValueColumn = ValueColumn;
138        }
139
140        if (Schema != null)
141        {
142            instance.Schema = Schema;
143        }
144
145        if (Prefix != null)
146        {
147            instance.Prefix = Prefix;
148        }
149
150        if (CustomQuery != null)
151        {
152            instance.CustomQuery = CustomQuery;
153        }
154
155        if (RefreshInterval != null)
156        {
157            instance.SqlServerWatcher = new SqlServerWatcher(RefreshInterval.Value);
158        }            
159
160        return instance;
161    }
162}
163

Ya tenemos todas las piezas (sé que me falta explicarte el refresco de la configuración), así que te muestro cómo usarlo en tu aplicación Asp Net Core.

En tu Program.cs puedes hacer algo parecido a esto

1var builder = WebApplication.CreateBuilder(args);
2
3var  connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
4
5//Here we added our configuration provider.
6if (!string.IsNullOrWhiteSpace(connectionString))
7{
8    builder.Configuration.AddSqlServer(sqlBuilder =>
9                                                    sqlBuilder
10 .UseConnectionString(connectionString)
11 .UseCustomQuery("SELECT [Key], [Value] from dbo.Settings") // set query to retrieve configuration
12 .WithPrefix("AppSettings") // set configuration key prefix. Ex: AppSettings:Message
13 .ConfigureRefresh(TimeSpan.FromSeconds(20))); // set refreshing configuration timespan
14}
15
16builder.Services.Configure<AppSettings>(builder.Configuration.GetSection("AppSettings"));
17

Aquí hemos usado nuestro "builder", también puedes hacer algo más sencillo, usando los valores por defecto que has podido comprobar en la implementación del interfaz IConfigurationSource.

1builder.Configuration.AddSqlServer(connectionString)
2

Recarga de a configuración

Esto es lo realmente más interesante del artículo. Implementaremos un IChangeToken para propagar notificaciones que indican que se ha producido un cambio. Con esto, nuestro "watcher" quedaría de la siguiente forma

1public interface ISqlServerWatcher : IDisposable
2{
3    IChangeToken Watch();
4}
5
6internal class SqlServerWatcher : ISqlServerWatcher
7{
8    private readonly TimeSpan refreshInterval;
9    private IChangeToken? changeToken;
10    private readonly Timer timer;
11    private CancellationTokenSource? cancellationTokenSource;
12    public SqlServerWatcher(TimeSpan refreshInterval)
13    {
14        this.refreshInterval = refreshInterval;
15        timer = new Timer(callback: Change, null, TimeSpan.Zero, this.refreshInterval);
16    }
17
18    private void Change(object? state) => cancellationTokenSource?.Cancel();
19
20    public IChangeToken Watch()
21    {
22        cancellationTokenSource = new CancellationTokenSource();
23        changeToken = new CancellationChangeToken(cancellationTokenSource.Token);
24
25        return changeToken;
26    }
27
28    public void Dispose()
29    {
30        timer?.Dispose();
31        cancellationTokenSource?.Dispose();
32    }
33}
34

Como puedes observar en el código, se notifica un cambio cada X intervalo de tiempo definido en el constructor gracias a un Timer interno de la clase. Con esto conseguimos, que cada X tiempo, se ejecute el método Load() de nuestro SqlServerConfigurationProvider y se recargue los datos que haya en la tabla.

Pero para usar el refresco, hay un truco que paso a explicarte. Como sabes IOptions, lee la configuración una sola vez y la cachea durante toda la ejecución del programa. En vez de ese interfaz, debes usar IOptionsSnapshot que lee de la configuración en cada petición que hagamos a nuestra aplicación.

En el código de ejemplo hay una aplicación Asp Net Core Razor Pages que puedes trastear para comprobar el refresco de la configuración.

¿Dónde puedo encontrar el código de ejemplo?

Podéis descargaros el código de ejemplo en https://github.com/sparraguerra/compartimoss/tree/master/AspNetCoreCustomConfigurationProviders

Conclusiones

Hemos visto lo sencillo que es implementar nuestro proveedor de configuración personalizado. Prometo en un siguiente post, implementar uno para que consuma nuestra base de datos NoSQL favorita Azure CosmosDB. Aquí podéis ver más información sobre los proveedores de configuración personalizados Implement a custom configuration provider in .NET.

Happy coding!

Sergio Parra Guerra
Software & Cloud Architect at Encamina
https://twitter.com/sparraguerra

Siguemos en LinkedInSiguemos en Twitter
Powered by  ENCAMINA