Como comenté en el anterior artículo (ASP.NET Core - Implementando un proveedor de configuración personalizado, veremos cómo implementar nuestro proveedor de configuración para ASP.NET Core empleando como fuente nuestro amado Azure CosmosDB.¡ Y veremos también cómo se puede hacer que las configuraciones en tu aplicación se refresquen automáticamente cuando hay un cambio en tiempo de ejecución empleando el ChangeFeed de CosmosDB!.
Siguiendo el ejemplo del artículo anterior vamos a 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 CosmosDbConfigurationSource : IConfigurationSource2{3 public string? ConnectionString { get; set; }4 public string? Endpoint { get; set; }5 public string? AuthKey { get; set; }6 public string? ContainerName { get; set; } = \"Settings\";7 public string? DatabaseName { get; set; } = \"settings\";8 public string? Prefix { get; set; }9 public bool? ChangeFeed { get; set; }10 public IConfigurationProvider Build(IConfigurationBuilder builder) =\> new CosmosDbConfigurationProvider(this);11}12
En esta clase definimos también otra serie de propiedades como la cadena de conexión, endpoint+authKey a nuestro servidor de CosmosDB, etc.
ConfigurationProvider
Esta clase base permite devolver pares de claves/valor de un origen de configuración.
1public class CosmosDbConfigurationProvider : ConfigurationProvider2{3 private readonly CosmosClient cosmosClient;4 private const string instanceName = \"host\";5 private const string processorName = \"changeFeedSample\";6 private const string leaseContainerName = \"leases\";7 private readonly ChangeFeedProcessor? processor;8 private readonly CosmosDbConfigurationSource? source;9 public CosmosDbConfigurationProvider(CosmosDbConfigurationSource source)10 {11 this.source = source ?? throw new ArgumentNullException(nameof(source));12 if (string.IsNullOrWhiteSpace(source.DatabaseName))13 {14 throw new ArgumentException(\"DatabaseName\");15 }16 if (string.IsNullOrWhiteSpace(source.ContainerName))17 {18 throw new ArgumentException(\"ContainerName\");19 }2021 cosmosClient = !string.IsNullOrWhiteSpace(source?.ConnectionString) ?22 new CosmosClient(source?.ConnectionString) :23 new CosmosClient(source?.Endpoint, source?.AuthKey);2425 if (source?.ChangeFeed == true)26 {27 processor = StartChangeFeedProcessorAsync(source.DatabaseName, leaseContainerName,28 source.ContainerName).GetAwaiter().GetResult(); ;29 }30 }3132 public override void Load()33 {34 var container = cosmosClient.GetContainer(source?.DatabaseName, source?.ContainerName);35 var queryOptions = new QueryRequestOptions { MaxItemCount = -1 };3637 QueryDefinition query = new(\$\"SELECT \* FROM {source?.ContainerName} c\");3839 using var resultSetIterator =40 container.GetItemQueryIterator\<JObject\>(query, requestOptions: new QueryRequestOptions { MaxConcurrency = 1 });4142 while (resultSetIterator.HasMoreResults)43 {44 var response = Task.Run(async () =\> await resultSetIterator.ReadNextAsync()).Result;4546 foreach (var result in response)47 {48 var allConfiguration = ParseProperties(result);49 foreach (var configurationItem in allConfiguration)50 {51 var key = !string.IsNullOrWhiteSpace(source?.Prefix) ?52 \$\"{source?.Prefix}:{configurationItem.Key}\" :53 configurationItem.Key;54 Data\[key\] = configurationItem.Value;55 }56 }57 }58 }5960 private Dictionary\<string, string\> ParseProperties(JObject? result)61 {62 Dictionary\<string, string\> properties = new();63 if (result is null)64 {65 return properties;66 }6768 foreach (var prop in result.Properties())69 {70 if (prop.Name.StartsWith(\"\_\") \|\| prop.Name.ToLowerInvariant() == \"id\")71 {72 continue;73 }7475 string key = prop.Name;7677 if (prop.Value.Type == JTokenType.Object)78 {79 var innerKeys = ParseProperties(prop.Value as JObject);80 foreach (var innerKey in innerKeys)81 {82 properties.Add(\$\"{key}:{innerKey.Key}\", innerKey.Value);83 }84 }85 else86 {87 properties.Add(key, prop.Value.ToString());88 }89 }9091 return properties;92 }9394 private async Task\<ChangeFeedProcessor\> StartChangeFeedProcessorAsync(string databaseName,95 string leaseContainerName, string sourceContainerName)96 {97 Container leaseContainer = cosmosClient.GetContainer(databaseName, leaseContainerName);9899 ChangeFeedProcessor changeFeedProcessor = cosmosClient.GetContainer(databaseName, sourceContainerName)100 .GetChangeFeedProcessorBuilder\<JObject\>(processorName: processorName, onChangesDelegate: HandleChangesAsync)101 .WithInstanceName(instanceName)102 .WithLeaseContainer(leaseContainer)103 .Build();104105 await changeFeedProcessor.StartAsync();106107 return changeFeedProcessor;108 }109110 private async Task HandleChangesAsync(ChangeFeedProcessorContext context,111 IReadOnlyCollection\<JObject\> changes, CancellationToken cancellationToken)112 {113 await Task.Run(() =\> this.Load(), cancellationToken);114 }115}116
En la implementación del método Load() lo que se hace simplemente es conectar a CosmosDB, ejecutar la consulta para recuperar el documento que se haya en el contenedor especificado y luego "parsear" las propiedades de ese documento a un diccionario con nuestros pares clave/valor.
Vemos en el constructor que se define el "change feed processor" que ejecutará el método Load() cada vez que haya un cambio en el contenedor de Azure CosmosDB.
Implementando métodos de extensión
Como recomendé en el artículo previo, implementamos métodos de extensión para hacer más sencillo el manejo de nuestro builder.
1public static class CosmosDbConfigurationBuilderExtensions2{3 public static IConfigurationBuilder AddCosmosDb(this IConfigurationBuilder builder, CosmosDbConfig cosmosDbConfig)4 {5 if (cosmosDbConfig == null)6 {7 throw new ArgumentNullException(nameof(cosmosDbConfig));8 }9 return builder.AddCosmosDb(cosmosDbBuilder =\>10 {11 if (!string.IsNullOrWhiteSpace(cosmosDbConfig.ConnectionString))12 {13 cosmosDbBuilder.UseConnectionString(cosmosDbConfig.ConnectionString);14 }15 if (!string.IsNullOrWhiteSpace(cosmosDbConfig.Endpoint))16 {17 cosmosDbBuilder.UseEndpoint(cosmosDbConfig.Endpoint);18 }19 if (!string.IsNullOrWhiteSpace(cosmosDbConfig.AuthKey))20 {21 cosmosDbBuilder.UseAuthKey(cosmosDbConfig.AuthKey);22 }23 if (!string.IsNullOrWhiteSpace(cosmosDbConfig.DatabaseName))24 {25 cosmosDbBuilder.UseDatabase(cosmosDbConfig.DatabaseName);26 }27 if (!string.IsNullOrWhiteSpace(cosmosDbConfig.ContainerName))28 {29 cosmosDbBuilder.UseContainer(cosmosDbConfig.ContainerName);30 }31 if (!string.IsNullOrWhiteSpace(cosmosDbConfig.Prefix))32 {33 cosmosDbBuilder.WithPrefix(cosmosDbConfig.Prefix);34 }35 if (cosmosDbConfig?.ChangeFeed == true)36 {37 cosmosDbBuilder.EnableChangeFeed();38 }39 });40 }414243 public static IConfigurationBuilder AddCosmosDb(this IConfigurationBuilder builder,44 CosmosDbConfig cosmosDbConfig, bool? enableChangeFeed = null) =\>45 builder.Add(new CosmosDbConfigurationSource46 {47 ConnectionString = cosmosDbConfig.ConnectionString,48 AuthKey = cosmosDbConfig.AuthKey,49 Endpoint = cosmosDbConfig.Endpoint,50 DatabaseName = cosmosDbConfig.DatabaseName,51 ContainerName = cosmosDbConfig.ContainerName,52 Prefix = cosmosDbConfig.Prefix,53 ChangeFeed = cosmosDbConfig.ChangeFeed54 });5556 public static IConfigurationBuilder AddCosmosDb(this IConfigurationBuilder builder,57 Action\<ICosmosDbConfigurationSourceBuilder\> cosmosDbBuilderAction)58 {59 var cosmosDbBuilder = new CosmosDbConfigurationSourceBuilder();60 cosmosDbBuilderAction(cosmosDbBuilder);61 var source = cosmosDbBuilder.Build();62 return builder.Add(source);63 }64}65
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 ICosmosDbConfigurationSourceBuilder2{3 ICosmosDbConfigurationSourceBuilder UseConnectionString(string connectionString);4 ICosmosDbConfigurationSourceBuilder UseEndpoint(string endpoint);5 ICosmosDbConfigurationSourceBuilder UseAuthKey(string authKey);6 ICosmosDbConfigurationSourceBuilder UseContainer(string container);7 ICosmosDbConfigurationSourceBuilder UseDatabase(string database);8 ICosmosDbConfigurationSourceBuilder WithPrefix(string prefix);9 ICosmosDbConfigurationSourceBuilder EnableChangeFeed();10 CosmosDbConfigurationSource Build();11}1213public class CosmosDbConfigurationSourceBuilder : ICosmosDbConfigurationSourceBuilder14{15 public string? ConnectionString { get; private set; }16 public string? Endpoint { get; private set; }17 public string? AuthKey { get; private set; }18 public string? ContainerName { get; private set; }19 public string? DatabaseName { get; private set; }20 public string? Prefix { get; private set; }21 public bool? ChangeFeed { get; private set; }22 public ICosmosDbConfigurationSourceBuilder UseConnectionString(string connectionString)23 {24 if (string.IsNullOrWhiteSpace(connectionString))25 {26 throw new ArgumentNullException(connectionString, \$\"Connection string could not be null or empty!\");27 }28 DbConnectionStringBuilder builder = new()29 {30 ConnectionString = connectionString31 };32 if (builder.TryGetValue(\"AccountKey\", out var key))33 {34 AuthKey = key.ToString();35 }36 if (builder.TryGetValue(\"AccountEndpoint\", out var uri))37 {38 Endpoint = uri.ToString();39 }40 ConnectionString = connectionString;41 return this;42 }4344 public ICosmosDbConfigurationSourceBuilder UseEndpoint(string endpoint)45 {46 if (string.IsNullOrWhiteSpace(endpoint))47 {48 throw new ArgumentNullException(endpoint, \$\"Endpoint string could not be null or empty!\");49 }5051 Endpoint = endpoint;5253 return this;54 }5556 public ICosmosDbConfigurationSourceBuilder UseContainer(string container)57 {58 if (string.IsNullOrWhiteSpace(container))59 {60 throw new ArgumentNullException(container, \$\"Container string could not be null or empty!\");61 }6263 ContainerName = container;6465 return this;66 }6768 public ICosmosDbConfigurationSourceBuilder UseDatabase(string database)69 {70 if (string.IsNullOrWhiteSpace(database))71 {72 throw new ArgumentNullException(database, \$\"Database string could not be null or empty!\");73 }7475 DatabaseName = database;7677 return this;78 }7980 public ICosmosDbConfigurationSourceBuilder WithPrefix(string prefix)81 {82 if (string.IsNullOrWhiteSpace(prefix))83 {84 throw new ArgumentNullException(prefix, \$\"Prefix could not be null or empty!\");85 }8687 Prefix = prefix;8889 return this;90 }9192 public ICosmosDbConfigurationSourceBuilder EnableChangeFeed()93 {94 ChangeFeed = true;95 return this;96 }9798 public ICosmosDbConfigurationSourceBuilder UseAuthKey(string authKey)99 {100 if (string.IsNullOrWhiteSpace(authKey))101 {102 throw new ArgumentNullException(authKey, \$\"AuthKey string could not be null or empty!\");103 }104105 AuthKey = authKey;106107 return this;108 }109110 public CosmosDbConfigurationSource Build()111 {112 var instance = new CosmosDbConfigurationSource();113114 if (ConnectionString != null)115 {116 instance.ConnectionString = ConnectionString;117 }118 if (Endpoint != null)119 {120 instance.Endpoint = Endpoint;121 }122 if (AuthKey != null)123 {124 instance.AuthKey = AuthKey;125 }126 if (ContainerName != null)127 {128 instance.ContainerName = ContainerName;129 }130 if (DatabaseName != null)131 {132 instance.DatabaseName = DatabaseName;133 }134 if (Prefix != null)135 {136 instance.Prefix = Prefix;137 }138 if (ChangeFeed != null)139 {140 instance.ChangeFeed = ChangeFeed;141 }142 return instance;143 }144}145
Ahora toca explicar cómo usar este proveedor. Se define una clase CosmosDbConfig que nos sirve para, desde configuración de la aplicación, indicar cómo nos conectaremos a nuestro Azure CosmosDB.
1public class CosmosDbConfig2{3 public string? ConnectionString { get; set; }4 public string? Endpoint { get; set; }5 public string? AuthKey { get; set; }6 public string? DatabaseName { get; set; }7 public string? ContainerName { get; set; }8 public string? Prefix { get; set; }9 public bool? ChangeFeed { get; set; }10}11
En tu Program.cs puedes hacer algo parecido a esto
1// get cosmos db configuration provider data from appSettings23var cosmosDbConfig = builder.Configuration.GetSection(\"CosmosDbConfig\")4 .Get\<CustomConfigurationProviders.CosmosDb.CosmosDbConfig\>();56if (cosmosDbConfig is not null)7{8 builder.Configuration.AddCosmosDb(cosmosDbConfig);9}10
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 para que consuma nuestra base de datos NoSQL favorita Azure CosmosDB. La verdad es que el uso del ChangeFeedProcessor le confiere una potencia brutal. Me mola mucho.
Happy coding!
Sergio Parra Guerra
Software & Cloud Architect at Encamina
https://twitter.com/sparraguerra