Azure, Azure Functions

Migrate Azure Functions from .NET Core 3.1 to .NET 5

Migrating Azure Functions v3 from .NET Core 3.1 to .NET 5 (isolated) requires a number of changes. In this post I am going to walk through steps I went through to upgrade an Azure Function.

The Microsoft Docs and the examples on Microsoft’s GitHub are well worth looking at as they give more details about what has changed.

The function that I am going to convert is a very simple one, it uses a Timer Trigger to check for messages every hour, a Blob to track when messages were last processed and add the messages to an Event Grid.

Here is the function code:

using Microsoft.Azure.EventGrid.Models;
using Microsoft.Azure.Storage.Blob;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.EventGrid;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using JsonSerializer = System.Text.Json.JsonSerializer;

namespace MessagePoller
{
    public class MessageFunction
    {
        private readonly IMessageProcessor _messageProcessor;

        public MessageFunction(IMessageProcessor messageProcessor)
        {
            _messageProcessor = messageProcessor;
        }

        [FunctionName("MessageFunction")]
        public async Task RunAsync([TimerTrigger("0 0 * * * *")] TimerInfo timerInfo,
            [Blob("tracking/trackingdate.json", FileAccess.ReadWrite, Connection = "StorageConnection")] ICloudBlob trackingBlob,
            [EventGrid(TopicEndpointUri = "MessageEventGridEndpoint", TopicKeySetting = "MessageEventGridTopicKey")] IAsyncCollector<EventGridEvent> outputEvents,
            ILogger log)
        {
            log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}");

            await using var stream = await trackingBlob.OpenReadAsync().ConfigureAwait(false);
            var tracking = await JsonSerializer.DeserializeAsync<TrackingObject>(stream, new JsonSerializerOptions
            {
                PropertyNameCaseInsensitive = true
            }).ConfigureAwait(false);

            var messages = await _messageProcessor.GetMessagesAsync(tracking).ConfigureAwait(false);
            await foreach (var message in messages.ToAsyncEnumerable())
            {
                var currentEvent = new EventGridEvent
                {
                    Id = Guid.NewGuid().ToString("D"),
                    Subject = "Message event",
                    EventTime = DateTime.UtcNow,
                    EventType = "Message",
                    Data = message,
                    DataVersion = "1"
                };

                await outputEvents.AddAsync(currentEvent).ConfigureAwait(false);
            }

            var bytes = JsonSerializer.SerializeToUtf8Bytes(new TrackingObject { ModificationDate = DateTime.UtcNow }, new JsonSerializerOptions
            {
                WriteIndented = true,
                PropertyNamingPolicy = JsonNamingPolicy.CamelCase
            });

            await trackingBlob.UploadFromByteArrayAsync(bytes, 0, bytes.Length).ConfigureAwait(false);
        }
    }
}

Step 1 – Update Project Files

The current .NET Core 3.1 project file looks like this:

<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
		<TargetFramework>netcoreapp3.1</TargetFramework>
		<AzureFunctionsVersion>v3</AzureFunctionsVersion>
	</PropertyGroup>
	<ItemGroup>
		<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.16" />
		<PackageReference Include="Microsoft.Azure.Functions.Extensions" Version="1.1.0" />
		<PackageReference Include="Microsoft.NET.Sdk.Functions" Version="3.0.13" />
		<PackageReference Include="Microsoft.Azure.EventGrid" Version="3.2.0" />
		<PackageReference Include="Microsoft.Azure.WebJobs.Extensions.EventGrid" Version="2.1.0" />
		<PackageReference Include="Microsoft.Azure.WebJobs.Extensions.Storage" Version="4.0.4" />
        <PackageReference Include="System.Linq.Async" Version="5.0.0" />
	</ItemGroup>
	<ItemGroup>
		<None Update="host.json">
			<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
		</None>
		<None Update="local.settings.json">
			<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
			<CopyToPublishDirectory>Never</CopyToPublishDirectory>
		</None>
	</ItemGroup>
</Project>

In order to update it to use .NET 5 we need to:

  • Update the target framework to net5.0
  • Add the OutputType of Exe
  • Upgrade Microsoft.Extensions.DependencyInjection to 5.x
  • Remove Packages
    • Microsoft.Azure.Functions.Extensions
    • Microsoft.NET.Sdk.Functions
    • Microsoft.Azure.WebJobs.Extensions.EventGrid
    • Microsoft.Azure.WebJobs.Extensions.Storage
  • Add Packages
    • Microsoft.Azure.Functions.Worker
    • Microsoft.Azure.Functions.Worker.Sdk
    • Microsoft.Azure.Functions.Worker.Extensions.EventGrid
    • Microsoft.Azure.Functions.Worker.Extensions.Storage
    • Microsoft.Azure.Functions.Worker.Extensions.Timer

After changing those, the csproj file now looks like this:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <AzureFunctionsVersion>v3</AzureFunctionsVersion>
    <OutputType>Exe</OutputType>
  </PropertyGroup>
  <ItemGroup>
  <PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.3.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.0.3" />
    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
    <PackageReference Include="Microsoft.Azure.EventGrid" Version="3.2.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.EventGrid" Version="2.1.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage" Version="4.0.4" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.1.0" />
    <PackageReference Include="System.Linq.Async" Version="5.0.0" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>
</Project>

NOTE: Microsoft.Azure.Functions.Worker.Extensions.Timer has been added as it is now a separate package

Step 2 – Create Entry point

Firstly for .NET 5 (isolated) we need a Program.cs file to work as the entry point

using Microsoft.Extensions.Hosting;

namespace MessagePoller
{
    public class Program
    {
        public static void Main()
        {
            var host = new HostBuilder()
                .ConfigureFunctionsWorkerDefaults()
                .Build();

            host.Run();
        }
    }
}

The function uses Dependency Injection and the dependencies are currently in Startup.cs and look like this:

using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;

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

namespace MessagePoller
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
           builder.Services.AddTransient<IMessageProcessor, MessageProcessor>();
        }
    }
}

This configuration needs moving to Program.cs in ConfigureServices:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace MessagePoller
{
    public class Program
    {
        public static void Main()
        {
            var host = new HostBuilder()
                .ConfigureFunctionsWorkerDefaults()
                .ConfigureServices(s =>
                {
                    s.AddTransient<IMessageProcessor, MessageProcessor>();
                })
                .Build();

            host.Run();
        }
    }
}

Now Startup.cs can be removed as it is no longer required.

Step 3 – Update Local Settings

The local.settings.json needs updating to change the Functions Runtime to dotnet-isolated

“FUNCTIONS_WORKER_RUNTIME”: “dotnet”

becomes

“FUNCTIONS_WORKER_RUNTIME”: “dotnet-isolated”

Step 4 – Code Changes

The first thing to change is “FunctionName” to “Function”

e.g.

[FunctionName("MessageFunction")]

becomes

[Function("MessageFunction")]

Next thing to change is ILogger, ILogger is no longer passed to the function method FunctionContext is and therefore we need to get the ILogger instance from the context.

e.g.

[Function("MessageFunction")]
public async Task<MessageOutputType> RunAsync([TimerTrigger("0 0 * * * *")] TimerInfo timerInfo,
       ...,
       FunctionContext executionContext)
{
    // Call to get the logger
    var log = executionContext.GetLogger("MessageFunction");
    ...
}

Now on to the next the function itself. The code uses ICloudBlob and IAsyncCollector<EventGridEvent> which are not supported.

Note from the Microsoft Docs on limitations of binding classes

The code also uses Blob as an Input and Output Binding which would need to now be split to use BlobInput and BlobOutput.

So if we change the current function declaration

[FunctionName("MessageFunction")]
public async Task RunAsync([TimerTrigger("0 0 * * * *")] TimerInfo timerInfo,
       [Blob("tracking/trackingdate.json", FileAccess.ReadWrite, Connection = "StorageConnection")] ICloudBlob trackingBlob,
       [EventGrid(TopicEndpointUri = "MessageEventGridEndpoint", TopicKeySetting = "MessageEventGridTopicKey")] IAsyncCollector<EventGridEvent> outputEvents,
       ILogger log)
{
    ...
}

to add the outputs, which would be Blob and EventGrid and then change ICloudBlob to something supported like the class object to deserialize to, we would end up with this:

[Function("MessageFunction")]
[EventGridOutput(TopicEndpointUri = "MessageEventGridEndpoint", TopicKeySetting = "MessageEventGridTopicKey")]
[BlobOutput("tracking/trackingdate.json")]
public async Task RunAsync([TimerTrigger("0 0 * * * *")] TimerInfo timerInfo,
       [BlobInput("tracking/trackingdate.json", Connection = "StorageConnection")] TrackingObject tracking,
       FunctionContext executionContext)
{
    ...
}

This of course will not work as you can only have one output binding and we now need a return type.

To solve this we need to create a custom return type which contains both the output bindings and then use this as the return type.

public class MessageOutputType
{
    [EventGridOutput(TopicEndpointUri = "MessageEventGridEndpoint", TopicKeySetting = "MessageEventGridTopicKey")]
    public IList<EventGridEvent> Events { get; set; }

    [BlobOutput("tracking/trackingdate.json")]
    public byte[] Tracking { get; set; }
}
[Function("MessageFunction")]
public async Task<MessageOutputType> RunAsync([TimerTrigger("0 0 * * * *")] TimerInfo timerInfo,
       [BlobInput("tracking/trackingdate.json", Connection = "StorageConnection")] TrackingObject tracking,
       FunctionContext executionContext)
{
    ...
}

Now all the code has been converted over the final result looks like this:

using Microsoft.Azure.EventGrid.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace MessagePoller
{
    public class MessageFunction
    {
        private readonly IMessageProcessor _messageProcessor;

        public MessageFunction(IMessageProcessor messageProcessor)
        {
            _messageProcessor = messageProcessor;
        }

        [Function("MessageFunction")]
        public async Task<MessageOutputType> RunAsync([TimerTrigger("0 0 * * * *")] TimerInfo timerInfo,
            [BlobInput("tracking/trackingdate.json", Connection = "StorageConnection")] TrackingObject trackingBlob,
            FunctionContext executionContext)
        {
            var log = executionContext.GetLogger("MessageFunction");
            log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}");

            var messages = await _messageProcessor.GetMessagesAsync(trackingBlob).ConfigureAwait(false);

            var eventMessages = await messages.ToAsyncEnumerable()
                .Select(message => new EventGridEvent
                {
                    Id = Guid.NewGuid().ToString("D"),
                    Subject = "Message event",
                    EventTime = DateTime.UtcNow,
                    EventType = "Message",
                    Data = message,
                    DataVersion = "1"
                })
                .ToListAsync().ConfigureAwait(false);

            return new MessageOutputType
            {
                Events = eventMessages,
                Tracking = new TrackingObject { ModificationDate = DateTime.UtcNow }
            };
        }
    }

    public class MessageOutputType
    {
        [EventGridOutput(TopicEndpointUri = "MessageEventGridEndpoint", TopicKeySetting = "MessageEventGridTopicKey")]
        public IList<EventGridEvent> Events { get; set; }

        [BlobOutput("tracking/trackingdate.json")]
        public TrackingObject Tracking { get; set; }
    }
}

Conclusion

Upgrading this particular function was surprisingly easy despite needing multiple output bindings, the Microsoft Docs and GitHub examples were extremely helpful in understanding the changes for the particular binding.

I have to say I like the multiple return type as well as the binding attributes being explicit about if they are Input or Output.

I deployed my new function from Visual Studio 2019 and worked as expected, now time to update the IaC (Infrastructure as Code) and deploy using my pipeline, hopefully there will be no surprises there.

I hope functions with other bindings are as easy to convert and can’t wait to see what other changes are added to Azure Functions in the future with the Isolated process, .NET 6 and Functions v4.

Happy Azure Functions upgrading!!