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 : IConfigurationSource2{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, IDisposable2{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.Load16 );17 }18 }1920 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 }3738 Data = data;39 }4041 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 SqlServerConfigurationBuilderExtensions2{3 public static IConfigurationBuilder AddSqlServer(this IConfigurationBuilder builder, string connectionString) =>45 builder.AddSqlServer(sqlBuilder =>6 sqlBuilder.UseConnectionString(connectionString));78 public static IConfigurationBuilder AddSqlServer(9 this IConfigurationBuilder builder,10 string connectionString,11 TimeSpan? refreshInterval = null) =>12 builder.Add(new SqlServerConfigurationSource13 {14 ConnectionString = connectionString,15 SqlServerWatcher = refreshInterval.HasValue ?16 new SqlServerWatcher(refreshInterval.Value) :17 null18 });1920 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 ISqlServerConfigurationSourceBuilder2{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);1112 SqlServerConfigurationSource Build();13}1415public class SqlServerConfigurationSourceBuilder : ISqlServerConfigurationSourceBuilder16{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; }2526 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 }3233 ConnectionString = connectionString;3435 return this;36 }3738 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 }4445 CustomQuery = query;4647 return this;48 }4950 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 }5657 Table = table;58 return this;59 }6061 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 }6768 KeyColumn = keyColumn;6970 return this;71 }7273 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 }7980 ValueColumn = valueColumn;8182 return this;83 }8485 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 }9192 Schema = schema;9394 return this;95 }9697 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 }103104 Prefix = prefix;105106 return this;107 }108109 public ISqlServerConfigurationSourceBuilder ConfigureRefresh(TimeSpan refreshInterval)110 {111 if (refreshInterval < TimeSpan.Zero)112 {113 throw new ArgumentException($"Refresh interval must be positive.");114 }115116 RefreshInterval = refreshInterval;117118 return this;119 }120121 public SqlServerConfigurationSource Build()122 {123 var instance = new SqlServerConfigurationSource { ConnectionString = ConnectionString };124125 if (Table != null)126 {127 instance.Table = Table;128 }129130 if (KeyColumn != null)131 {132 instance.KeyColumn = KeyColumn;133 }134135 if (ValueColumn != null)136 {137 instance.ValueColumn = ValueColumn;138 }139140 if (Schema != null)141 {142 instance.Schema = Schema;143 }144145 if (Prefix != null)146 {147 instance.Prefix = Prefix;148 }149150 if (CustomQuery != null)151 {152 instance.CustomQuery = CustomQuery;153 }154155 if (RefreshInterval != null)156 {157 instance.SqlServerWatcher = new SqlServerWatcher(RefreshInterval.Value);158 }159160 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);23var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");45//Here we added our configuration provider.6if (!string.IsNullOrWhiteSpace(connectionString))7{8 builder.Configuration.AddSqlServer(sqlBuilder =>9 sqlBuilder10 .UseConnectionString(connectionString)11 .UseCustomQuery("SELECT [Key], [Value] from dbo.Settings") // set query to retrieve configuration12 .WithPrefix("AppSettings") // set configuration key prefix. Ex: AppSettings:Message13 .ConfigureRefresh(TimeSpan.FromSeconds(20))); // set refreshing configuration timespan14}1516builder.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 : IDisposable2{3 IChangeToken Watch();4}56internal class SqlServerWatcher : ISqlServerWatcher7{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 }1718 private void Change(object? state) => cancellationTokenSource?.Cancel();1920 public IChangeToken Watch()21 {22 cancellationTokenSource = new CancellationTokenSource();23 changeToken = new CancellationChangeToken(cancellationTokenSource.Token);2425 return changeToken;26 }2728 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.
Podéis descargaros el código de ejemplo en https://github.com/sparraguerra/compartimoss/tree/master/AspNetCoreCustomConfigurationProviders
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