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.
public class CosmosDbConfigurationSource : IConfigurationSource
{
public string? ConnectionString { get; set; }
public string? Endpoint { get; set; }
public string? AuthKey { get; set; }
public string? ContainerName { get; set; } = \"Settings\";
public string? DatabaseName { get; set; } = \"settings\";
public string? Prefix { get; set; }
public bool? ChangeFeed { get; set; }
public IConfigurationProvider Build(IConfigurationBuilder builder) =\> new CosmosDbConfigurationProvider(this);
}
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.
public class CosmosDbConfigurationProvider : ConfigurationProvider
{
private readonly CosmosClient cosmosClient;
private const string instanceName = \"host\";
private const string processorName = \"changeFeedSample\";
private const string leaseContainerName = \"leases\";
private readonly ChangeFeedProcessor? processor;
private readonly CosmosDbConfigurationSource? source;
public CosmosDbConfigurationProvider(CosmosDbConfigurationSource source)
{
this.source = source ?? throw new ArgumentNullException(nameof(source));
if (string.IsNullOrWhiteSpace(source.DatabaseName))
{
throw new ArgumentException(\"DatabaseName\");
}
if (string.IsNullOrWhiteSpace(source.ContainerName))
{
throw new ArgumentException(\"ContainerName\");
}
cosmosClient = !string.IsNullOrWhiteSpace(source?.ConnectionString) ?
new CosmosClient(source?.ConnectionString) :
new CosmosClient(source?.Endpoint, source?.AuthKey);
if (source?.ChangeFeed == true)
{
processor = StartChangeFeedProcessorAsync(source.DatabaseName, leaseContainerName,
source.ContainerName).GetAwaiter().GetResult(); ;
}
}
public override void Load()
{
var container = cosmosClient.GetContainer(source?.DatabaseName, source?.ContainerName);
var queryOptions = new QueryRequestOptions { MaxItemCount = -1 };
QueryDefinition query = new($"SELECT * FROM {source?.ContainerName} c");
using var resultSetIterator =
container.GetItemQueryIterator<JObject>(query, requestOptions: new QueryRequestOptions { MaxConcurrency = 1 });
while (resultSetIterator.HasMoreResults)
{
var response = Task.Run(async () => await resultSetIterator.ReadNextAsync()).Result;
foreach (var result in response)
{
var allConfiguration = ParseProperties(result);
foreach (var configurationItem in allConfiguration)
{
var key = !string.IsNullOrWhiteSpace(source?.Prefix) ?
$"{source?.Prefix}:{configurationItem.Key}" :
configurationItem.Key;
Data[key] = configurationItem.Value;
}
}
}
}
private Dictionary<string, string> ParseProperties(JObject? result)
{
Dictionary<string, string> properties = new();
if (result is null)
{
return properties;
}
foreach (var prop in result.Properties())
{
if (prop.Name.StartsWith("_") || prop.Name.ToLowerInvariant() == "id")
{
continue;
}
string key = prop.Name;
if (prop.Value.Type == JTokenType.Object)
{
var innerKeys = ParseProperties(prop.Value as JObject);
foreach (var innerKey in innerKeys)
{
properties.Add($"{key}:{innerKey.Key}", innerKey.Value);
}
}
else
{
properties.Add(key, prop.Value.ToString());
}
}
return properties;
}
private async Task<ChangeFeedProcessor> StartChangeFeedProcessorAsync(string databaseName,
string leaseContainerName, string sourceContainerName)
{
Container leaseContainer = cosmosClient.GetContainer(databaseName, leaseContainerName);
ChangeFeedProcessor changeFeedProcessor = cosmosClient.GetContainer(databaseName, sourceContainerName)
.GetChangeFeedProcessorBuilder<JObject>(processorName: processorName, onChangesDelegate: HandleChangesAsync)
.WithInstanceName(instanceName)
.WithLeaseContainer(leaseContainer)
.Build();
await changeFeedProcessor.StartAsync();
return changeFeedProcessor;
}
private async Task HandleChangesAsync(ChangeFeedProcessorContext context,
IReadOnlyCollection<JObject> changes, CancellationToken cancellationToken)
{
await Task.Run(() => this.Load(), cancellationToken);
}
}
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.
public static class CosmosDbConfigurationBuilderExtensions
{
public static IConfigurationBuilder AddCosmosDb(this IConfigurationBuilder builder, CosmosDbConfig cosmosDbConfig)
{
if (cosmosDbConfig == null)
{
throw new ArgumentNullException(nameof(cosmosDbConfig));
}
return builder.AddCosmosDb(cosmosDbBuilder =\>
{
if (!string.IsNullOrWhiteSpace(cosmosDbConfig.ConnectionString))
{
cosmosDbBuilder.UseConnectionString(cosmosDbConfig.ConnectionString);
}
if (!string.IsNullOrWhiteSpace(cosmosDbConfig.Endpoint))
{
cosmosDbBuilder.UseEndpoint(cosmosDbConfig.Endpoint);
}
if (!string.IsNullOrWhiteSpace(cosmosDbConfig.AuthKey))
{
cosmosDbBuilder.UseAuthKey(cosmosDbConfig.AuthKey);
}
if (!string.IsNullOrWhiteSpace(cosmosDbConfig.DatabaseName))
{
cosmosDbBuilder.UseDatabase(cosmosDbConfig.DatabaseName);
}
if (!string.IsNullOrWhiteSpace(cosmosDbConfig.ContainerName))
{
cosmosDbBuilder.UseContainer(cosmosDbConfig.ContainerName);
}
if (!string.IsNullOrWhiteSpace(cosmosDbConfig.Prefix))
{
cosmosDbBuilder.WithPrefix(cosmosDbConfig.Prefix);
}
if (cosmosDbConfig?.ChangeFeed == true)
{
cosmosDbBuilder.EnableChangeFeed();
}
});
}
public static IConfigurationBuilder AddCosmosDb(this IConfigurationBuilder builder,
CosmosDbConfig cosmosDbConfig, bool? enableChangeFeed = null) =>
builder.Add(new CosmosDbConfigurationSource
{
ConnectionString = cosmosDbConfig.ConnectionString,
AuthKey = cosmosDbConfig.AuthKey,
Endpoint = cosmosDbConfig.Endpoint,
DatabaseName = cosmosDbConfig.DatabaseName,
ContainerName = cosmosDbConfig.ContainerName,
Prefix = cosmosDbConfig.Prefix,
ChangeFeed = cosmosDbConfig.ChangeFeed
});
public static IConfigurationBuilder AddCosmosDb(this IConfigurationBuilder builder,
Action<ICosmosDbConfigurationSourceBuilder> cosmosDbBuilderAction)
{
var cosmosDbBuilder = new CosmosDbConfigurationSourceBuilder();
cosmosDbBuilderAction(cosmosDbBuilder);
var source = cosmosDbBuilder.Build();
return builder.Add(source);
}
}
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í:
public interface ICosmosDbConfigurationSourceBuilder
{
ICosmosDbConfigurationSourceBuilder UseConnectionString(string connectionString);
ICosmosDbConfigurationSourceBuilder UseEndpoint(string endpoint);
ICosmosDbConfigurationSourceBuilder UseAuthKey(string authKey);
ICosmosDbConfigurationSourceBuilder UseContainer(string container);
ICosmosDbConfigurationSourceBuilder UseDatabase(string database);
ICosmosDbConfigurationSourceBuilder WithPrefix(string prefix);
ICosmosDbConfigurationSourceBuilder EnableChangeFeed();
CosmosDbConfigurationSource Build();
}
public class CosmosDbConfigurationSourceBuilder : ICosmosDbConfigurationSourceBuilder
{
public string? ConnectionString { get; private set; }
public string? Endpoint { get; private set; }
public string? AuthKey { get; private set; }
public string? ContainerName { get; private set; }
public string? DatabaseName { get; private set; }
public string? Prefix { get; private set; }
public bool? ChangeFeed { get; private set; }
public ICosmosDbConfigurationSourceBuilder UseConnectionString(string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentNullException(connectionString, $"Connection string could not be null or empty!");
}
DbConnectionStringBuilder builder = new()
{
ConnectionString = connectionString
};
if (builder.TryGetValue("AccountKey", out var key))
{
AuthKey = key.ToString();
}
if (builder.TryGetValue("AccountEndpoint", out var uri))
{
Endpoint = uri.ToString();
}
ConnectionString = connectionString;
return this;
}
public ICosmosDbConfigurationSourceBuilder UseEndpoint(string endpoint)
{
if (string.IsNullOrWhiteSpace(endpoint))
{
throw new ArgumentNullException(endpoint, $"Endpoint string could not be null or empty!");
}
Endpoint = endpoint;
return this;
}
public ICosmosDbConfigurationSourceBuilder UseContainer(string container)
{
if (string.IsNullOrWhiteSpace(container))
{
throw new ArgumentNullException(container, $"Container string could not be null or empty!");
}
ContainerName = container;
return this;
}
public ICosmosDbConfigurationSourceBuilder UseDatabase(string database)
{
if (string.IsNullOrWhiteSpace(database))
{
throw new ArgumentNullException(database, $"Database string could not be null or empty!");
}
DatabaseName = database;
return this;
}
public ICosmosDbConfigurationSourceBuilder WithPrefix(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
{
throw new ArgumentNullException(prefix, $"Prefix could not be null or empty!");
}
Prefix = prefix;
return this;
}
public ICosmosDbConfigurationSourceBuilder EnableChangeFeed()
{
ChangeFeed = true;
return this;
}
public ICosmosDbConfigurationSourceBuilder UseAuthKey(string authKey)
{
if (string.IsNullOrWhiteSpace(authKey))
{
throw new ArgumentNullException(authKey, $"AuthKey string could not be null or empty!");
}
AuthKey = authKey;
return this;
}
public CosmosDbConfigurationSource Build()
{
var instance = new CosmosDbConfigurationSource();
if (ConnectionString != null)
{
instance.ConnectionString = ConnectionString;
}
if (Endpoint != null)
{
instance.Endpoint = Endpoint;
}
if (AuthKey != null)
{
instance.AuthKey = AuthKey;
}
if (ContainerName != null)
{
instance.ContainerName = ContainerName;
}
if (DatabaseName != null)
{
instance.DatabaseName = DatabaseName;
}
if (Prefix != null)
{
instance.Prefix = Prefix;
}
if (ChangeFeed != null)
{
instance.ChangeFeed = ChangeFeed;
}
return instance;
}
}
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.
public class CosmosDbConfig
{
public string? ConnectionString { get; set; }
public string? Endpoint { get; set; }
public string? AuthKey { get; set; }
public string? DatabaseName { get; set; }
public string? ContainerName { get; set; }
public string? Prefix { get; set; }
public bool? ChangeFeed { get; set; }
}
En tu Program.cs puedes hacer algo parecido a esto
// get cosmos db configuration provider data from appSettings
var cosmosDbConfig = builder.Configuration.GetSection(\"CosmosDbConfig\")
.Get\<CustomConfigurationProviders.CosmosDb.CosmosDbConfig\>();
if (cosmosDbConfig is not null)
{
builder.Configuration.AddCosmosDb(cosmosDbConfig);
}
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
De la revista Número 54 - Diciembre 2022
Edición número 54 de la revista CompartiMOSS.
Ver más artículos de esta revista →