Azure Functions recently released preview support for .NET 5. Let's take a look at how to upgrade our existing Azure Functions to use it!
Note: This is a preview experience for .NET 5 support in Azure Functions. The Azure Functions teams notes that the ".NET 5 experience will improve in the coming weeks".
Why is it more complicated than last time?
You might be wondering "Why can't I just change `netcoreapp3.1` to `net5.0`?"
Historically, Azure Functions has always been tightly coupled with .NET, specifically Long Term Support (LTS) .NET releases. This meant that we couldn't use a newer version of .NET until the Azure Functions team also updated their Azure Functions .NET Runtime.
This is the first release that moves .NET to an "out-of-process model", allowing us to run our Azure Functions using any version of .NET!
Walkthrough
In this walkthrough, I'll be providing snippets from the Azure Functions I use for my app GitTrends. GitTrends is an open-source app available in the iOS and Android App Stores, built in C# using Xamarin, that uses Azure Functions for its backend.
You can find the completed solution in the `Move-Azure-Functions-to-net5.0` branch on the GitTrends repository, here: https://github.com/brminnick/GitTrends/tree/Move-Azure-Functions-to-net5.0/GitTrends.Functions
1. Update .NET
Let's update to .NET 5!
First, download the .NET 5 SDK and install it on your development machine.
Then, in your Functions' CSPROJ, set the following values for `TargetFramework`, `LangVersion`, `AzureFunctionsVersion`,` OutputType` and `_FunctionsSkipCleanOutput`:
(Here is a completed working example)
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<LangVersion>preview</LangVersion>
<AzureFunctionsVersion>v3</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<_FunctionsSkipCleanOutput>true</_FunctionsSkipCleanOutput>
</PropertyGroup>
2. Update NuGet Packages
Now let's add the necessary NuGet Packages.
In your Functions' CSPROJ, ensure the following `PackageReference`s have been added:
Note: For `Microsoft.Azure.Functions.Worker.Sdk`, add `OutputItemType="Analyzer"`
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.0.0-preview3" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.0.0-preview3" OutputItemType="Analyzer" />
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions" Version="4.0.1" />
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions.Http" Version="3.0.12" />
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions.Storage" Version="4.0.3" />
<PackageReference Include="Microsoft.Azure.WebJobs.Script.ExtensionsMetadataGenerator" Version="1.2.1" />
<PackageReference Include="System.Net.NameResolution" Version="4.3.0" />
</ItemGroup>
3. Add Non-Windows Workaround
We need to include a workaround to ensure this new out-of-process worker works properly on non-Windows machines.
In your Functions CSPROJ, add the following `Target`:
(Here is a completed working example)
<Target Name="CopyRuntimes" AfterTargets="AfterBuild" Condition=" '$(OS)' == 'UNIX' ">
<!-- To workaround a bug where the files aren't copied correctly for non-Windows platforms -->
<Exec Command="rm -rf $(OutDir)bin/runtimes/* && mkdir -p $(OutDir)bin/runtimes && cp -R $(OutDir)runtimes/* $(OutDir)bin/runtimes/" />
</Target>
4. Update local.settings.json
To run our Functions locally, we'll need to tell the Azure Functions Host to use the isolated dotnet runtime in `local.settings.json` by by setting `FUNCTIONS_WORKER_RUNTIME` to `dotnet-isolated`, like so:
(Here is a working completed example)
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"AzureWebJobsDashboard": "UseDevelopmentStorage=true"
}
}
Then, in the Functions' CSPROJ, ensure it is being copied to the output directory using `CopyToOutputDirectory` like so:
(Here is a working completed example)
<ItemGroup>
<None Update="local.settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
5. Update Initialization & Dependency Injection
The way we initialize Azure Functions, including Dependency Injection, for .NET 5 has improved.
Old Initialization & Dependency Injection (pre .NET 5.0)
The old way to use Dependency Injection with Azure Functions was to add the `[assembly: FunctionsStartup]` attribute and inherit from `FunctionsStartup`.
Here is an example of how we used to initialize Dependency Injection in Azure Functions:
(Here is a completed working example)
//Note: This is the old (pre-.NET 5) way of using Dependency Injection with Azure Functions
[assembly: FunctionsStartup(typeof(Startup))]
namespace GitTrends.Functions
{
public class Startup : FunctionsStartup
{
readonly static string _storageConnectionString = Environment.GetEnvironmentVariable("AzureWebJobsStorage") ?? string.Empty;
public override void Configure(IFunctionsHostBuilder builder)
{
builder.Services.AddHttpClient();();
builder.Services.AddSingleton<BlobStorageService>();
builder.Services.AddSingleton<CloudBlobClient>(CloudStorageAccount.Parse(_storageConnectionString).CreateCloudBlobClient());
}
}
}
//Note: This is the old (pre-.NET 5) way of using Dependency Injection with Azure Functions
New Initialization & Dependency Injection
The new way is to initialize Azure Functions in .NET 5 is more similar to ASP.NET. It uses to `Microsoft.Extensions.Hosting.HostBuilder`, like so:
(Here is a competed working example)
namespace GitTrends.Functions
{
class Program
{
readonly static string _storageConnectionString = Environment.GetEnvironmentVariable("AzureWebJobsStorage") ?? string.Empty;
static Task Main(string[] args)
{
var host = new HostBuilder()
.ConfigureAppConfiguration(configurationBuilder =>
{
configurationBuilder.AddCommandLine(args);
})
.ConfigureFunctionsWorker((hostBuilderContext, workerApplicationBuilder) =>
{
workerApplicationBuilder.UseFunctionExecutionMiddleware();
})
.ConfigureServices(services =>
{
services.AddHttpClient();
services.AddSingleton<BlobStorageService>();
services.AddSingleton<CloudBlobClient>(CloudStorageAccount.Parse(_storageConnectionString).CreateCloudBlobClient());
})
.Build();
return host.RunAsync();
}
}
6. Update HttpTrigger Functions
To update an existing HttpTrigger Function, we replace the following method parameters:
- `HttpRequest` -> `HttpRequestData`
- `ILogger` -> `FunctionExecutionContext`
Note: `ILogger` can now be found in `FunctionExecutionContext.Logger`
Old HttpTrigger (pre .NET 5.0)
Here is an example of the old (pre .NET 5) way of creating an `HttpTrigger`:
(Here is a completed working example)
//Note: This is the old (pre-.NET 5) way of creating an HttpTrigger with Azure Functions
public static class GetGitHubClientId
{
readonly static string _clientId = Environment.GetEnvironmentVariable("GitTrendsClientId") ?? string.Empty;
[FunctionName(nameof(GetGitHubClientId))]
public static IActionResult Run([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest request, ILogger log)
{
log.LogInformation("Retrieving Client Id");
if (string.IsNullOrWhiteSpace(_clientId))
return new NotFoundObjectResult("Client ID Not Found");
return new OkObjectResult(new GetGitHubClientIdDTO(_clientId));
}
}
//Note: This is the old (pre-.NET 5) way of creating an HttpTrigger with Azure Functions
New HttpTrigger
The new `HttpTrigger` syntax is nearly identical; only `HttpRequestData` and `FunctionExecutionContext` are now being used as its method parameters:
(Here is a completed working example)
public static class GetGitHubClientId
{
readonly static string _clientId = Environment.GetEnvironmentVariable("GitTrendsClientId") ?? string.Empty;
[FunctionName(nameof(GetGitHubClientId))]
public static IActionResult Run([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req, FunctionExecutionContext executionContext)
{
var logger = executionContext.Logger;
logger.LogInformation("Retrieving Client Id");
if (string.IsNullOrWhiteSpace(_clientId))
return new NotFoundObjectResult("Client ID Not Found");
return new OkObjectResult(new GetGitHubClientIdDTO(_clientId));
}
}
7. Update TimerTrigger Functions
To update an existing TimerTrigger Function, we must do the following:
- Create `TimerInfo.cs`
- `ILogger` -> `FunctionExecutionContext`
Create TimerInfo.cs
The out-of-process worker doesn't yet include the `TimerInfo` class, but we can create it ourselves with the same properties and its values will injected at runtime:
(Here is a completed working example)
using System;
namespace GitTrends.Functions
{
public class TimerInfo
{
public ScheduleStatus? ScheduleStatus { get; set; }
/// <summary>
/// Gets a value indicating whether this timer invocation
/// is due to a missed schedule occurrence.
/// </summary>
public bool IsPastDue { get; set; }
}
public class ScheduleStatus
{
/// <summary>
/// Gets or sets the last recorded schedule occurrence.
/// </summary>
public DateTime Last { get; set; }
/// <summary>
/// Gets or sets the expected next schedule occurrence.
/// </summary>
public DateTime Next { get; set; }
/// <summary>
/// Gets or sets the last time this record was updated. This is used to re-calculate Next
/// with the current Schedule after a host restart.
/// </summary>
public DateTime LastUpdated { get; set; }
}
}
Old TimerTrigger (pre .NET 5.0)
Here is an example of a TimerTrigger Function before updating it to .NET 5.0:
(Here is a working completed example)
//Note: This is the old (pre-.NET 5) way of creating an TimerTrigger with Azure Functions
public class SendSilentPushNotification
{
const string _runEveryHourCron = "0 0 * * * *";
readonly static string _notificationHubFullConnectionString = Environment.GetEnvironmentVariable("NotificationHubFullConnectionString") ?? string.Empty;
readonly static Lazy<NotificationHubClient> _clientHolder = new(NotificationHubClient.CreateClientFromConnectionString(_notificationHubFullConnectionString, GetNotificationHubInformation.NotificationHubName));
static NotificationHubClient Client => _clientHolder.Value;
[FunctionName(nameof(SendSilentPushNotification))]
public static Task Run([TimerTrigger(_runEveryHourCron)] TimerInfo myTimer, ILogger log) => Task.WhenAll(TrySendAppleSilentNotification(Client, log), TrySendFcmSilentNotification(Client, log));
}
//Note: This is the old (pre-.NET 5) way of creating an TimerTrigger with Azure Functions
New TimerTrigger
In the new TimerTrigger, in the its method parameters, we remove `ILogger`, replacing it with `FunctionExecutionContext`:
(Here is a working completed example)
public class SendSilentPushNotification
{
const string _runEveryHourCron = "0 0 * * * *";
readonly static string _notificationHubFullConnectionString = Environment.GetEnvironmentVariable("NotificationHubFullConnectionString") ?? string.Empty;
readonly static Lazy<NotificationHubClient> _clientHolder = new(NotificationHubClient.CreateClientFromConnectionString(_notificationHubFullConnectionString, GetNotificationHubInformation.NotificationHubName));
static NotificationHubClient Client => _clientHolder.Value;
[FunctionName(nameof(SendSilentPushNotification))]
public static Task Run([TimerTrigger(_runEveryHourCron)] TimerInfo myTimer, FunctionExecutionContext executionContext)
{
var logger = executionContext.Logger;
return Task.WhenAll(TrySendAppleSilentNotification(Client, logger), TrySendFcmSilentNotification(Client, logger));
}
}
8. Run .NET 5 Azure Functions Locally
Currently, the only way to run our .NET 5 Azure Functions locally is to use the command line.
Note: Visual Studio and Visual Studio for Mac have not yet been updated to run .NET 5 Azure Functions. If you try to run this code using Visual Studio, it will throw a `System.UriFormatException`: "Invalid URI: The hostname could not be parsed."
1. Install Azure Functions Core Tools v3.0.3160
- On macOS: Open the Terminal and run the following command:
- `brew tap azure/functions; brew install azure-functions-core-tools@3`
- On Windows: Open the Command Prompt and run the following command:
- `npm i -g azure-functions-core-tools@3 --unsafe-perm true`
2. On the command line, navigate to the folder containing your Azure Functions CSPROJ
3. On the command line, enter the following command:
- `func host start --verbose`
Note: This command is slightly different from the command you may already be familiar with, `func start`
9. Publish .NET 5 Azure Functions to Azure
Currently, the only way to publish our .NET 5 Azure Functions to Azure is to use the command line.
Note: Deployment to Azure is currently limited to Windows plans. Note that some optimizations are not in place in the consumption plan and you may experience longer cold starts
1. Install Azure Functions Core Tools v3.0.3160
- On macOS: Open the Terminal and run the following command:
- `brew tap azure/functions; brew install azure-functions-core-tools@3`
- On Windows: Open the Command Prompt and run the following command:
- `npm i -g azure-functions-core-tools@3 --unsafe-perm true`
2. On the command line, navigate to the folder containing your Azure Functions CSPROJ
3. On the command line, enter the following command:
- `dotnet publish -c Release`
4. On the command line, navigate to the publish artifacts by entering the following command:
- `cd ./bin/Release/net5.0/publish`
5. On the command line, publish the Function App to Azure using the following command:
- `func azure functionapp publish <APP_NAME>`
Conclusion
The Azure Functions team is doing a ton of work to create out-of-process workers that allow us to use .NET 5.0 in Azure Functions.
Their work is still on going, and I highly recommend Watching & Staring the azure-functions-core-tools GitHub Repo: https://github.com/Azure/azure-functions-core-tools