ASP.­NET Core - Implementando un proveedor de configuración personalizado II (para Azure CosmosDB)

Escrito por  Sergio Parra

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 : IConfigurationSource
2{
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 : ConfigurationProvider
2{
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        }
20
21        cosmosClient = !string.IsNullOrWhiteSpace(source?.ConnectionString) ?
22                                new CosmosClient(source?.ConnectionString) :
23                                new CosmosClient(source?.Endpoint, source?.AuthKey);
24
25        if (source?.ChangeFeed == true)
26        {
27            processor = StartChangeFeedProcessorAsync(source.DatabaseName, leaseContainerName,
28 source.ContainerName).GetAwaiter().GetResult(); ;
29        }
30    }
31
32    public override void Load()
33    {
34        var container = cosmosClient.GetContainer(source?.DatabaseName, source?.ContainerName);
35        var queryOptions = new QueryRequestOptions { MaxItemCount = -1 };
36
37        QueryDefinition query = new(\$\"SELECT \* FROM {source?.ContainerName} c\");
38
39        using var resultSetIterator =
40                    container.GetItemQueryIterator\<JObject\>(query, requestOptions: new QueryRequestOptions { MaxConcurrency = 1 });
41
42        while (resultSetIterator.HasMoreResults)
43        {
44            var response = Task.Run(async () =\> await resultSetIterator.ReadNextAsync()).Result;
45
46            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    }
59
60    private Dictionary\<string, string\> ParseProperties(JObject? result)
61    {
62        Dictionary\<string, string\> properties = new();
63        if (result is null)
64        {
65            return properties;
66        }
67
68        foreach (var prop in result.Properties())
69        {
70            if (prop.Name.StartsWith(\"\_\") \|\| prop.Name.ToLowerInvariant() == \"id\")
71            {
72                continue;
73            }
74
75            string key = prop.Name;
76
77            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            else
86            {
87                properties.Add(key, prop.Value.ToString());
88            }
89        }
90
91        return properties;
92    }
93
94    private async Task\<ChangeFeedProcessor\> StartChangeFeedProcessorAsync(string databaseName,
95 string leaseContainerName, string sourceContainerName)
96    {
97        Container leaseContainer = cosmosClient.GetContainer(databaseName, leaseContainerName);
98
99        ChangeFeedProcessor changeFeedProcessor = cosmosClient.GetContainer(databaseName, sourceContainerName)
100            .GetChangeFeedProcessorBuilder\<JObject\>(processorName: processorName, onChangesDelegate: HandleChangesAsync)
101            .WithInstanceName(instanceName)
102            .WithLeaseContainer(leaseContainer)
103            .Build();
104
105        await changeFeedProcessor.StartAsync();
106
107        return changeFeedProcessor;
108    }
109
110    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 CosmosDbConfigurationBuilderExtensions
2{
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    }
41                                           
42
43    public static IConfigurationBuilder AddCosmosDb(this IConfigurationBuilder builder,
44 CosmosDbConfig cosmosDbConfig, bool? enableChangeFeed = null) =\>
45       builder.Add(new CosmosDbConfigurationSource
46       {
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.ChangeFeed
54       });
55
56    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 ICosmosDbConfigurationSourceBuilder
2{
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}
12
13public class CosmosDbConfigurationSourceBuilder : ICosmosDbConfigurationSourceBuilder
14{
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 = connectionString
31        };
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    }
43
44    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        }
50
51        Endpoint = endpoint;
52
53        return this;
54    }
55
56    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        }
62
63        ContainerName = container;
64
65        return this;
66    }
67
68    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        }
74
75        DatabaseName = database;
76
77        return this;
78    }
79
80    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        }
86
87        Prefix = prefix;
88
89        return this;
90    }
91
92    public ICosmosDbConfigurationSourceBuilder EnableChangeFeed()
93    {
94        ChangeFeed = true;
95        return this;
96    }
97
98    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        }
104
105        AuthKey = authKey;
106
107        return this;
108    }
109
110    public CosmosDbConfigurationSource Build()
111    {
112        var instance = new CosmosDbConfigurationSource();
113
114        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 CosmosDbConfig
2{  
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 appSettings
2
3var cosmosDbConfig = builder.Configuration.GetSection(\"CosmosDbConfig\")
4 .Get\<CustomConfigurationProviders.CosmosDb.CosmosDbConfig\>();
5
6if (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

Siguemos en LinkedInSiguemos en Twitter
Powered by  ENCAMINA