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 PackageReferences have been added:

(Here is a completed example)

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/* &amp;&amp; mkdir -p $(OutDir)bin/runtimes &amp;&amp; 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."

0. 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
  1. On the command line, navigate to the folder containing your Azure Functions CSPROJ
  2. 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

0. 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
  1. On the command line, navigate to the folder containing your Azure Functions CSPROJ
  2. On the command line, enter the following command: dotnet publish -c Release
  3. On the command line, navigate to the publish artifacts by entering the following command: cd ./bin/Release/net5.0/publish
  4. 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