Dependency Injection in Azure Functions
Dependency Injection in Azure Functions
A little while ago Microsoft finally added support for dependency injection in their Azure Function product. Now, there are some subtle but important differences, but for the most part the experience is similar to what you're used to in ASPNET Core.
Microsoft's official documentation, found here, is as usual lacking in real-world examples. Thus, the purpose of this article is to expose some real world use cases to the wider public. We're going to build an Azure Function which listens to a Service Bus and writes to an Azure Search index.
Before we started, a brief introduction.
Azure Function Primer
Azure Functions are supposed to be single-purpose pieces of code which are trigger when an action happens. These actions are in fact called triggers. Once your code is trigger, it can do anything you want it to, but most often you'll want to do some kind of processing on the input from the trigger and spit out the result into a 'binding'. Bindings are essentially output buckets you can deposit the result of your function into. The key is to understand that triggers are mandatory but bindings are optional.
Microsoft's own documentation on triggers and bindings is pretty good, so if you're new to Azure Functions, I recommend giving that a read.
In our case, we'll be using a Service Bus trigger, but since there is no Azure Search index binding, we'll have to write to the index manually.
Not having a binding you need is a pretty common scenario, because Microsoft doesn't really support anything outside of their own ecosystem and even within the ecosystem getting them to build bindings you need is a tall order. How many years have we been waiting for Azure File Storage bindings?
Right, so now that we know what we're building, let's dive into the code.
Creating the Function
Go ahead and spin up a new Azure Function in Visual Studio. If you want, you can choose the Service Bus trigger while scaffolding -- that will make the process a tad bit faster. Choose whatever default storage account you use to store your functions (or Storage Emulator), make sure you've selected at least version 2 of Azure Functions (though as of this writing, 3 is the latest one so I'll be using that).
You already have a Function1.cs
file which contains the default function code. There are also host.json
, local.settings.json
and possibly a .gitignore
. Let's add a couple more files:
- Startup.cs
- ConfigBuilder.cs
Startup is going to contain our dependency injection code and ConfigBuilder will pull config files from the filesystem.
We're also going to need to add a Nuget package Microsoft.Azure.Functions.Extensions
.
Inside Startup.cs
class, add the following code:
[assembly: FunctionsStartup(typeof(FancyFunction.Startup))]
namespace FancyFunction
{
public class Startup : FunctionsStartup
{
public Startup() { }
public override void Configure(IFunctionsHostBuilder builder)
{
// 1. Build Config
// 2. Add Config as a Singleton
// 3. Add logging
// 4. Add the search service
}
}
}
First, we have to add an assembly reference attribute at the top of the namespace and specify the Startup class as the entrypoint. Then, we need to inherit from the FunctionsStartup
base class and add an empty constructor. Lastly, implement the required Configure
override.
We have our steps defined in the comments there, so let's start with building the config. We can add appsettings.json
via Microsoft's IConfiguration interface. Inside ConfigBuilder.cs
add the following code:
namespace FancyFunction
{
public static class ConfigBuilder
{
public static IConfiguration BuildConfiguration(string rootDir = null)
{
// We're allowing specifying a custom root directory
if (string.IsNullOrEmpty(rootDir))
{
// Theoretically this should exist in Azure Function apps
var localRoot = Environment.GetEnvironmentVariable("AzureWebJobsScriptRoot");
// But if it doesnt, then this will
var azureRoot = $"{Environment.GetEnvironmentVariable("HOME")}/site/wwwroot";
rootDir = localRoot ?? azureRoot;
}
// Grab the environment setting to use below
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development";
// Create and retun the config
return new ConfigurationBuilder()
.SetBasePath(rootDir)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile($"appsettings.{environment}.json", optional: true)
// Add any more sources you need here
.Build();
}
}
}
Resolve any using
s missing. The code is explained in the comments.
Inside Startup.cs, let's do steps 1 and 2:
// 1. Build Config
var config = ConfigBuilder.BuildConfiguration();
// 2. Add Config as a Singleton
builder.Services.AddSingleton(config);
Next, logging. Simple:
// 3. Add logging
builder.Services.AddLogging();
This will add the default loggers, one of which is Console. This step is not strictly necessary if you intend on using just the Console, since functions are injected ILogger out of the box, but if you plan on using any 3rd party or maybe your own logger, you can do so here by passing in a parameter:
builder.Services.AddLogging(cfg => {
// your code here
})
We will inject this and other services via Dependency Injection into the function soon.
Lastly, let's add the Azure Search Service. We need to create the search service first, then add it as a Singleton. Go ahead and create a new class: AzureSearchService.cs
. Inside, create an interface and the class which inherits from it:
public interface IAzureSearchService {
Task<bool> MergeDocument<T>(T entity, string index);
}
public class AzureSearchService : IAzureSearchService {
public SearchServiceClient Client;
public AzureSearchService(SearchServiceClient adminClient) {
Client = adminClient;
}
public Task<bool> MergeDocument<T>(T entity, string index) {
// ... code to merge a message from SB into Search Index
}
}
We're not going to build out the search client in this post, since the focus here is on Azure Functions. You can think of this service as a standard DI service you'd write in a typical .NET Core application. The interface is there to make it testable.
You may have to install Microsoft's latest Azure Search Nuget package. As of this writing, it's Microsoft.Azure.Search
version 10.1.0, but knowing Microsoft, by the time you read this, it'll be deprecated...
Now that you have the Azure Search Service class, you can add it to Startup.cs:
// 4. Add the search service
builder.Services.AddSingleton<IAzureSearchService>((provider) =>
{
// Create the client first using config values from appsettings.json (or alternative source)
var adminClient = new SearchServiceClient(config["SearchServiceName"], new SearchCredentials(config["SearchServiceAdminKey"]));
// Return the implementation of the class with this adminClient.
return new AzureSearchService(adminClient);
});
We're creating a singleton here, because the Search Client is reusable and does not need to instantiate every time a request is made.
Using DI in the Function
Finally, we can get to writing our function! Inside Function1.cs
(or, if you renamed it, whatever the new file name is) make the following changes:
- Remove
static
from the class definition.public class Function1
Add a constructor
public class Function1 { private IAzureSearchService _searchService; private IConfiguration _config; private ILogger<Function1> _logger; public Function1(IAzureSearchService azureSearchService, IConfiguration configuration, ILogger<Function1> logger) { _searchService = azureSearchService; _config = configuration; _logger = logger; } // ... Your function code here }
Write out the function just as you would a typical controller endpoint. You'll have access to anything you injected in the constructor and reassigned out of its scope. Here's an example:
[FunctionName("Function1")] public async Task Run([ServiceBusTrigger("fancy-topic", "fancy-subscription", Connection = "ConnectionStringDefinedInEnvironment")]Message message) { // Get the body of the message var body = Encoding.UTF8.GetString(message.Body); // Get user defined properties (UserProperties) is just a dictionary message.UserProperties.TryGetValue("customProperty", out object customProperty); // Deserialize the body var msgObject = JsonSerializer.Deserialize<FancyMessage>(body); // Use the search service await _searchService.MergeDocument<FancySearchIndexModel>(new FancySearchIndexModel { /* Reassign properties from message to search model here */ }, "FancyIndex"); }
This is it!
The entire process basically goes as follows:
- Create Startup.cs
- Add some attributes to specify that we're using a Functions-specific Startup class
- Add services to the DI container
- Inject services into the constructor of the function
Thanks for reading!