How to inject or use IConfiguration in Azure Function V3 with Dependency Injection when configuring a service
Asked Answered
M

5

56

Normally in a .NET Core project I would create a 'boostrap' class to configure my service along with the DI registration commands. This is usually an extension method of IServiceCollection where I can call a method like .AddCosmosDbService and everything necessary is 'self-contained' in the static class containing that method. The key though is that the method gets an IConfiguration from the Startup class.

I've worked with DI in Azure Functions in past but haven't come across this specific requirement yet.

I'm using the IConfiguration to bind to a concrete class with properties matching settings from both my local.settings.json as well as the dev/production application settings when the Function is deployed in Azure.

CosmosDbClientSettings.cs

/// <summary>
/// Holds configuration settings from local.settings.json or application configuration
/// </summary>    
public class CosmosDbClientSettings
{
    public string CosmosDbDatabaseName { get; set; }
    public string CosmosDbCollectionName { get; set; }
    public string CosmosDbAccount { get; set; }
    public string CosmosDbKey { get; set; }
}

BootstrapCosmosDbClient.cs

public static class BootstrapCosmosDbClient
{
    /// <summary>
    /// Adds a singleton reference for the CosmosDbService with settings obtained by injecting IConfiguration
    /// </summary>
    /// <param name="services"></param>
    /// <param name="configuration"></param>
    /// <returns></returns>
    public static async Task<CosmosDbService> AddCosmosDbServiceAsync(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        CosmosDbClientSettings cosmosDbClientSettings = new CosmosDbClientSettings();
        configuration.Bind(nameof(CosmosDbClientSettings), cosmosDbClientSettings);

        CosmosClientBuilder clientBuilder = new CosmosClientBuilder(cosmosDbClientSettings.CosmosDbAccount, cosmosDbClientSettings.CosmosDbKey);
        CosmosClient client = clientBuilder.WithConnectionModeDirect().Build();
        CosmosDbService cosmosDbService = new CosmosDbService(client, cosmosDbClientSettings.CosmosDbDatabaseName, cosmosDbClientSettings.CosmosDbCollectionName);
        DatabaseResponse database = await client.CreateDatabaseIfNotExistsAsync(cosmosDbClientSettings.CosmosDbDatabaseName);
        await database.Database.CreateContainerIfNotExistsAsync(cosmosDbClientSettings.CosmosDbCollectionName, "/id");

        services.AddSingleton<ICosmosDbService>(cosmosDbService);

        return cosmosDbService;
    }
}

Startup.cs

public class Startup : FunctionsStartup
{

    public override async void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddHttpClient();
        await builder.Services.AddCosmosDbServiceAsync(**need IConfiguration reference**); <--where do I get IConfiguration?
    }
}

Obviously adding a private field for IConfiguration in Startup.cs won't work as it needs to be populated with something and I've also read that using DI for IConfiguration isn't a good idea.

I've also tried using the options pattern as described here and implemented as such:

builder.Services.AddOptions<CosmosDbClientSettings>()
    .Configure<IConfiguration>((settings, configuration) => configuration.Bind(settings));

While this would work to inject an IOptions<CosmosDbClientSettings> to a non-static class, I'm using a static class to hold my configuration work.

Any suggestions on how I can make this work or a possible workaround? I'd prefer to keep all the configuration in one place (bootstrap file).

Melaniemelanin answered 24/12, 2019 at 23:14 Comment(1)
Look at @Casper answer. Isn't it the most appropriate?Compress
U
74

As of version 1.1.0 of Microsoft.Azure.Functions.Extensions you can do the following:

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        var configuration = builder.GetContext().Configuration;
        builder.Services.AddCosmosDbService(configuration);
    }
}

Unfortunately it still does not support async configuration, so you will still have to block waiting for the task to finish or use the trick described by @Nkosi's answer.

Ulloa answered 30/9, 2020 at 20:20 Comment(2)
Is the GetContext() method an extension method that is defined in another project or assembly ?Whomsoever
No but it is introduced in version 1.1.0 of the package so you must upgrade if using an older version.Ulloa
R
19

The linked example is poorly designed (In My Opinion). It encourages tight coupling and the mixing of async-await and blocking calls.

IConfiguration is added to the service collection by default as part of the start up, so I would suggest changing up your design to take advantage of the deferred resolution of dependencies so that the IConfiguration can be resolved via the built IServiceProvider using a factory delegate.

public static class BootstrapCosmosDbClient {

    private static event EventHandler initializeDatabase = delegate { };

    public static IServiceCollection AddCosmosDbService(this IServiceCollection services) {

        Func<IServiceProvider, ICosmosDbService> factory = (sp) => {
            //resolve configuration
            IConfiguration configuration = sp.GetService<IConfiguration>();
            //and get the configured settings (Microsoft.Extensions.Configuration.Binder.dll)
            CosmosDbClientSettings cosmosDbClientSettings = configuration.Get<CosmosDbClientSettings>();
            string databaseName = cosmosDbClientSettings.CosmosDbDatabaseName;
            string containerName = cosmosDbClientSettings.CosmosDbCollectionName;
            string account = cosmosDbClientSettings.CosmosDbAccount;
            string key = cosmosDbClientSettings.CosmosDbKey;

            CosmosClientBuilder clientBuilder = new CosmosClientBuilder(account, key);
            CosmosClient client = clientBuilder.WithConnectionModeDirect().Build();
            CosmosDbService cosmosDbService = new CosmosDbService(client, databaseName, containerName);

            //async event handler
            EventHandler handler = null;
            handler = async (sender, args) => {
                initializeDatabase -= handler; //unsubscribe
                DatabaseResponse database = await client.CreateDatabaseIfNotExistsAsync(databaseName);
                await database.Database.CreateContainerIfNotExistsAsync(containerName, "/id");
            };
            initializeDatabase += handler; //subscribe
            initializeDatabase(null, EventArgs.Empty); //raise the event to initialize db

            return cosmosDbService;
        };
        services.AddSingleton<ICosmosDbService>(factory);
        return service;
    }
}

Note the approach taken to get around the having to use async void in a non-async event handler.

Reference Async/Await - Best Practices in Asynchronous Programming.

So now the Configure can be properly invoked.

public class Startup : FunctionsStartup {

    public override void Configure(IFunctionsHostBuilder builder) =>
        builder.Services
            .AddHttpClient()
            .AddCosmosDbService();
}
Radiolucent answered 25/12, 2019 at 2:2 Comment(4)
The factory idea is perfect! Now I don't need to build the ServiceProvider manually anymore! I use it in the AddDbContext((sp,options)=>{}) by the way.Derek
@Nkosi, in your response, you have a comment //and get the configured settings (Microsoft.Extensions.Configuration.Binder.dll) This is the bit that is really stumping me. @JasonShave refers to this as a problem also, since his bootstrap class is static. Please can you elaborate or provide some guidance or links resources where config is set up and can be used in this way? ThanksChilon
@Chilon read this section and pay attention to the part about using ConfigurationBinder.Get<T> extension.Radiolucent
Hey, thx @Nkosi. The problem was PEBCAC (Problem exists between computer and chair), i.e. with me... The config data keys didn't quite match my data class properties... So much time spent chasing ghosts... In the end I used sp.GetService<IOptions<MyConfigClass>>()?.Value; My startup Configure(IFunctionsHostBuilder builder), for anyone whose interested, was: var config = builder.GetContext().Configuration; followed by build.Services.AddOptions<MyConfigClass>().Bind(config.GetSection("MyConfig"));. Thx again.Chilon
G
14

Currently recommended way

Based on docs here https://learn.microsoft.com/en-us/azure/azure-functions/functions-dotnet-dependency-injection

Binding settings to custom class

You can bind the settings from Function settings in Azure as well as local.settings.json file for local development as follows:

Set the key in Portal (notice the : notation in the key name) enter image description here

And optionally in the local.settings.json file:

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "WebhookHandlerSettings:SecretKey": "AYBABTU"
  }
}

Have a custom class for the settings:

public class WebhookHandlerSettings 
    {
        public string SecretKey { get; set; }
}

Add a Startup class file with the following code:

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
            //bind the settings 
            builder.Services.AddOptions<WebhookHandlerSettings>()
            .Configure<IConfiguration>((settings, configuration) =>
            {
                configuration.GetSection(nameof(WebhookHandlerSettings)).Bind(settings);
            });
            //this is where we use the binded settings (by convention it's an extension method) 
            builder.Services.AddRequestValidation(); 
    }
}

The settings are bound to the class that you specify in the AddOptions<T> parameter. You need to specify the section of the settings, then : and then the settings key.
The framework will bind the keys to properties where the name matches.

Inject the settings to service classes

Conventionally I put the services registrations group code into extension methods, like below:

    public static class RequestValidatorRegistration
    {
        public static void AddRequestValidation(this IServiceCollection services)
        {
            services.AddScoped<IWebhookRequestValidator>((s) =>
            {
#if DEBUG
                return new AlwaysPassRequestValidator(s.GetService<ILogger<AlwaysPassRequestValidator>>());
#endif
   //you can pass the built in ILogger<T> (**must be generic**), as well as your IOptions<T>

    return new WebhookRequestValidator(s.GetService<ILogger<WebhookRequestValidator>>(), 
        s.GetService<IOptions<WebhookHandlerSettings>>());

            });
        }
    }

Extra tip - if you pass the built-in logger, you cannot pass just ILogger as service type. It needs to be ILogger<T>, otherwise it won't be resolved.

Finally, in your custom service you have dependencies injected to the constructor:

    public class WebhookRequestValidator : IWebhookRequestValidator
    {
        public WebhookRequestValidator(ILogger<WebhookRequestValidator> log, IOptions<WebhookHandlerSettings> settings)
        {
            this.log = log;
            this.settings = settings.Value;
        }
}

When you are passing the registered dependencies to your function classes, you don't need to register the injection into function class, as it will be resolved automatically.
Just remove the static keyword from the function class, and add a constructor with dependencies that you registered.

Greylag answered 3/3, 2021 at 11:55 Comment(4)
Why use a factory method for registering IWebhookValidator when you can simply do services.AddScoped<IWebhookRequestValidator, WebhookRequestValidator>() (or whatever the syntax is, I'm using SimpleInjector)?Ulloa
@Ulloa - great question - I chose to use the factory method, because my full code involves providing a different implementation of the validator for #DEBUG mode - see updated code :)Greylag
That too can be handled by wrapping the whole registration in the debug block using an else statement for the normal registration 😊Ulloa
@IanKemp, well, I disagree. My answer provides a fuller explanation of this pattern (albeit well-known). Bear in mind that Stackoverflow is not only in order to answer to OPs question (as he surely no longer cares), but also answer the question of any other Google user who looks for a 'how to do DI in Azure Functions' and finds this page. And in that I belive my answer is useful and correct. Regards:)Greylag
M
11

Here's an example that I was able to whip up; it establishes a connection to Azure App Configuration for centralized configuration and feature management. One should be able to use all DI features, such as IConfiguration and IOptions<T>, just as they would in an ASP.NET Core controller.

NuGet Dependencies

  • Install-Package Microsoft.Azure.Functions.Extensions
  • Install-Package Microsoft.Extensions.Configuration.AzureAppConfiguration
  • Install-Package Microsoft.Extensions.Configuration.UserSecrets

Startup.cs

[assembly: FunctionsStartup(typeof(SomeApp.Startup))]

namespace SomeApp
{
    public class Startup : FunctionsStartup
    {
        public IConfigurationRefresher ConfigurationRefresher { get; private set; }

        public override void Configure(IFunctionsHostBuilder hostBuilder) {
            if (ConfigurationRefresher is not null) {
                hostBuilder.Services.AddSingleton(ConfigurationRefresher);
            }
        }
        public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder configurationBuilder) {
            var hostBuilderContext = configurationBuilder.GetContext();
            var isDevelopment = ("Development" == hostBuilderContext.EnvironmentName);

            if (isDevelopment) {
                configurationBuilder.ConfigurationBuilder
                    .AddJsonFile(Path.Combine(hostBuilderContext.ApplicationRootPath, $"appsettings.{hostBuilderContext.EnvironmentName}.json"), optional: true, reloadOnChange: false)
                    .AddUserSecrets<Startup>(optional: true, reloadOnChange: false);
            }

            var configuration = configurationBuilder.ConfigurationBuilder.Build();
            var applicationConfigurationEndpoint = configuration["APPLICATIONCONFIGURATION_ENDPOINT"];

            if (!string.IsNullOrEmpty(applicationConfigurationEndpoint)) {
                configurationBuilder.ConfigurationBuilder.AddAzureAppConfiguration(appConfigOptions => {
                    var azureCredential = new DefaultAzureCredential(includeInteractiveCredentials: false);

                    appConfigOptions
                        .Connect(new Uri(applicationConfigurationEndpoint), azureCredential)
                        .ConfigureKeyVault(keyVaultOptions => {
                            keyVaultOptions.SetCredential(azureCredential);
                        })
                        .ConfigureRefresh(refreshOptions => {
                            refreshOptions.Register(key: "Application:ConfigurationVersion", label: LabelFilter.Null, refreshAll: true);
                            refreshOptions.SetCacheExpiration(TimeSpan.FromMinutes(3));
                        });

                    ConfigurationRefresher = appConfigOptions.GetRefresher();
                });
            }
        }
    }
}
Millenarian answered 22/2, 2020 at 5:59 Comment(5)
With this approach, I have an issue that host.json parameters are not used, in particular, routePrefixElectrometallurgy
@Andrii Interesting, I'll have to do some research and will edit my post if a solution is found; thanks a ton for the heads up!Millenarian
@Andrii Try the latest edit out and lemme know if your issue persists; sorry it took so long.Millenarian
Everything compiles and the first load from App Configuration works...but I don't get new values when I change the key Application:ConfigurationVersion. I'm injecting the Refresher on my function and calling _configurationRefresher.TryRefreshAsync() but still nothing...Clytemnestra
@Millenarian I'm wondering if my issues are related to the libraries versions...would you mind sharing the version number you are using?Clytemnestra
L
8

I´m using .net core 3.1

[assembly: FunctionsStartup(typeof(Startup))]
namespace xxxxx.Functions.Base
{
    [ExcludeFromCodeCoverage]
    public class Startup : FunctionsStartup
    {
        private static IConfiguration _configuration = null;

        public override void Configure(IFunctionsHostBuilder builder)
        {
            var serviceProvider = builder.Services.BuildServiceProvider();
            _configuration = serviceProvider.GetRequiredService<IConfiguration>();

            *** Now you can use _configuration["KEY"] in Startup.cs ***
        }
Lieselotteliestal answered 11/12, 2020 at 4:41 Comment(6)
This is not the recommended way of doing it anymore...Ulloa
That's fine. I just wanted to make sure that developers coming here to find an answer to the problem knows that this answer is not the recommended one.Ulloa
@Ulloa so what's the recommended one?Astrodome
@PawelCioch See my answer above.Ulloa
@Ulloa I see, I have same error about GetContext() as F. mentioned, will try to update the package, let's hope won't f..up anything after deploying to Azure :)Astrodome
NO. DO NOT EVER manually build service providers in your startup classes. Things WILL break and you WON'T understand why. Use @Casper's answer.Koala

© 2022 - 2024 — McMap. All rights reserved.