Migrating to isolated worker Azure Functions (2024)

Mylocloud Limited | Ryan Barlow | Technical Architect services - specialising in .NET, Azure, JS (Vanilla, TypeScript, React, Express)
East Midlands UK, Remote, Hybrid

Find us on LinkedIn at;
www.linkedin.com/in/ryan-barlow-uk and www.linkedin.com/company/mylocloud

Azure Functions offer two distinct hosting models;

Out of these, in-process is the default model that you will see used the majority of the time. In most use cases, this model is 'good enough', and does everything a developer needs it to do.

However, a couple of major downsides with the in in-process model are that;

You may want to upgrade to reduce any potential technical debt by always being on the newest stable .NET version, or you may be reliant on bug fixes, new language features (our use case was that a client needed a fix to large EF migrations that is available in .NET 8) or functionality provided by a certain middleware.

Migrating

Migrating an in-process Azure Function to use the isolated worker model has a rather steep initial learning curve. Because of this, and to help ease the process for anyone doing it in the future, we have written this guide with a few tips and tricks we learnt along the way of doing it.

NOTE - This guide is a quite HTTP function-centric, but feel free to pick and choose what you need. There is a brief note at the end about function bindings for none-HTTP functions.

Update language version

If you are migrating to the isolated worker model, with the purpose of updating the language the function will use, you need to;

Update packages

There are some Nuget package updates to be made. Some of the core functionality exists in different packages with the isolated model.

NOTE - While updating the named packages below, it is likely worthwhile to take the time to update your other referenced packages to the newest available, and to ones targeting the .NET version you are upgrading to.

HTTP trigger packages

Swagger / OpenAPI pacakges

Add Program.cs

The in-process model doesn't need a Program.cs file as it runs in the same process as the Functions host. However, isolated does as the entry point into your program.

Add a Program.cs file to your Azure Function project. The minimum skeleton you will likely need is;

public class Program
{
    static async Task Main(string[] args)
    {
        var host =
            new HostBuilder()
            .ConfigureFunctionsWorkerDefaults()
            .ConfigureOpenApi()
            .ConfigureServices(services =>
            {
                // Add DI here (likely move
                // from Startup.cs)
            })
            .Build();

            await host.RunAsync();
    }
}

Remove Startup.cs

Counter to what was talked about with the Program.cs file, isolated worker model Azure Functions do not use a Startup.cs file.

Instead, the dependancy injection work that would previously have been done in there should move into the ConfigureServices method of Program.cs and the Startup.cs should then be deleted

Tip! Entity framework

Configuring EF has changed. Swap from;

services.AddDbContext<DataContext>(
    options => options.UseSqlServer(
        connectionString));

To the simpler;

services.AddSqlServer<DataContext>(
        connectionString)

Update using statements / reference correct namespaces

As a rule of thumb, we should remove the following

using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;

Add;

using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions
    .OpenApi.Extensions;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Hosting;

Obviously remove any of the above that are not being used (VS/Rider tooling will show the unused usings), and add others as and where needed (again, lean on the tooling to help).

Set project output type

We need to set out project output type to be an executable (when in process it piggybacks of the host function as mentioned before, so this wasn't needed).

In the main .csproj file, add;

<PropertyGroup>
    <OutputType>Exe</OutputType>
</PropertyGroup>

Add important config setting

In your local.settings.json file, or wherever you are keeping your appsettings, set the following;

FUNCTIONS_WORKER_RUNTIME dotnet-isolated

Attribute changes

FunctionName to Function on functions

(All the following to be added to the method rather then to any other property as they may have been previously)

Please see https://www.ais.com/creating-self-documenting-azure-functions-with-c-and-openapi-update/ for a comprehensive guide to setting these properties

UPDATE 2024-03-18 - The information in the following sections with regards to Request and Response changes, are correct when using the isolated worker built-in HTTP model.

However reddit user u/RiverRoll has steered me to the documention showing that you can actually continue using the 'HttpRequest', 'HttpResponse' and IActionResult methods, by doing the following to references;

Additionally swap to ConfigureFunctionsWebApplication from ConfigureFunctionsWorkerDefaults in Program.cs.

With these changes made you can swap the following two sections.

Request changes

With the change to the isolated worked model, the way we recieve request data has changed.

Change references of HttpRequest and HttpRequestMessage to HttpRequestData

The HttpRequest class is no longer used to recieve request data, instead we use the similar HttpRequestData. Update your function definitions to swap from one to the other.

Changes to query object

Request.Query is now a NameValueCollection rather then a IQueryCollection. You interact with it in the same fashion however.

By the way, the namespace for NameValueCollection is System.Collection.Specialized rather then Microsoft.AspNetCore.Http for IQueryCollection

Retrieving JSON

If you want to retrieve the body straight into JSON, previously you may have done the following;

request.ReadFromJsonAsync<T>()

This method no longer works, so instead switch to;

JsonSerializer.DeserializeAsync<T>(request.Body)

Response changes

Likewise, with the change to the isolated worked model, the way we send response data has changed.

Change references of HttpResponse to HttpResponseData

Anywhere you are interacting with an HttpResponse class, the equivilent is now HttpResponseData.

Leave IActionResult behind

With the isolated worker model, you can't return an IActionResult (or more technically - you can but the response will not be what you expected).

Instead you will need to do, something along the lines of the following;

var response = request.CreateResponse(
    HttpStatusCode.OK);
response.WriteString(JsonSerializer.Serialize(
    data,
    new JsonSerializerOptions
    {
        WriteIndented = true,
        ReferenceHandler = 
            ReferenceHandler.IgnoreCycles
    }));
response.Headers.Add("Content-Type", 
    "application/json");

retun response;

We suggest encapsulating this in some type of helper method. I have one I have named 'OkResponse', so can change from old IActionResult types quickly.

Serialise your complex output

With IActionResult / OkObjectResult, complex objects are serialised automatically. However with HttpResponseData, it isn't.

Because of this you will need to do something along the lines of the following to build up a string;

JsonSerializer.Serialize(
    data,
    new JsonSerializerOptions
    {
        WriteIndented = true,
        ReferenceHandler = 
            ReferenceHandler.IgnoreCycles
    }));

Set content type

Nothing is set by default when using HttpResponseData. So you will need to set the content type explicitly, with code like below;

response.Headers.Add("Content-Type", 
    "application/json");

Logging

There are changes to how logging works. You can no longer recieve an additional ILogger parameter to your function.

Instead, an ILogger<T> will need to be injected into your function constructors.

Unit testing changes

To mock the http request and response data, the following classes should come in useful (thanks to Vincent Bitter from StackOverflow where this is adapted from).

public class MockHttpRequestData : HttpRequestData
{
    public MockHttpRequestData(
        FunctionContext functionContext,
        Uri url,
        Stream body = null)
        : base(functionContext)
    {
        Url = url;
        Body = body ?? new MemoryStream();
    }

    prviate Stream _Body = new MemoryStream();

    public override Stream Body
    {
        get
        {
            return _Body;
        }
    }

    public void SetBody(Stream data)
    {
        _Body = data;
    }

    public override HttpHeadersCollection
        Headers { get; } = new();

    public override IReadOnlyCollection
        Cookies { get; }

    public override Uri Url { get; }

    public override IEnumerable
        Identities { get; }

    public override string Method { get; }

    public NameValueCollection _Query = new();

    public override NameValueCollection Query
    {
        get
        {
            return _Query;
        }
    }

    public void SetQuery(NameValueCollection data)
    {
        _Query = data;
    }

    public override HttpResponseData
        CreateResponse()
    {
        return new MockHttpRequestData(
            FunctionContext);
    }
}

and;

public class MockHttpResponseData : HttpResponseData
{
    public MockHttpResponseData(
            FunctionContext functionContext)
        : base(functionContext)
    {
    }

    public override HttpStatusCode
        StatusCode { get; set; }
    public override HttpHeadersCollection
        Headers { get; set; }
        = new();
    public override Stream Body { get; set; } =
        new MemoryStream();
    public override HttpCookies Cookies { get; }
}

Change any DefaultHttpContext to use MockHttpRequestData.

Anywhere passing in httpContext.Request change to your httpRequestData instance.

From;

_httpContext = new DefaultHttpContext
{
    Request =
    {
        Body = stream,
        ContentLength = stream.Length,
        ContentType = "application/json"
    }
}

To;

var body = new MemoryStream(
    Encoding.ASCII.GetBytes("{}"));
var context = new Mock<FunctionContext>();
_httpRequestData = new MockHttpRequestData(
    context.Object,
    new Uri("https://example.org"),
    body
);

_httpRequestData.Headers.Add("content-length",
    stream.Length.ToString());
_httpRequestData.Headers.Add("content-type",
    "application/json");
_httpRequestData.SetBody(stream);

Additional notes

Swagger / OpenAPI

We now don't need any standalone Swagger methods. Its 'automagically' done for you if correct packages are included, and attributes added.

Difference in write permissions

If your program writes any data to the local file system, you will not be able to write to the current directory. Instead you can write to the a temporary directory.

Change any references of Environment.CurrentDirectory to Path.GetTempPath()

None-http function bindings

Microsoft have a good page showing how to change the other bindings for none-http methods at https://learn.microsoft.com/en-us/azure/azure-functions/migrate-dotnet-to-isolated-model?tabs=net8

References