Building Messaging Endpoints in Azure: Functions

Posts in this series:

In our last post, we looked at deploying message endpoints in containers, eventually out to Azure Container Instances. While fairly straightforward, this approach is fairly close to Infrastructure-as-a-Service. I can scale, but I can't auto-scale, and even if I used Kubernetes, I can't scale based on exceeding my lead time SLA (time from when a message enters the queue until when it is consumed).

But what about the serverless option of Azure, Azure Functions? Can this offer me a better experience for building a message endpoint?

So far, the answer is "not really", but it will highly depend on your workload or needs. The programming and hosting model is vastly different than containers or web jobs, so we first need to understand how our function will get triggered.

Choosing a trigger

Something needs to kick off our endpoint, and for this, we have a couple of choices. The most basic choice is the Azure Service Bus binding for Azure Functions. I mention "basic" - because it is. You're not really building an endpoint here, but a single function. It may seem like a small difference, but it's really not. When I'm building endpoints, the handler is just one piece of the puzzle - I have the host configuration, logging, tracing, error handling, and beyond that, complex messaging patterns.

The other option is a pre-release version of the NServiceBus support for Azure Functions, which dramatically alters the development model of functions itself and you're back to developing message handlers (instead of merely functions).

First, let's look at the out-of-the-box binding.

Azure Service Bus binding

With Azure Functions, with their own special SDK package, you're creating a "Functions" project and choosing the "Azure Service Bus" trigger. All this really does behind the scenes is create a project with the correct NuGet package references:

<ItemGroup>
  <PackageReference Include="Microsoft.Azure.WebJobs.Extensions.ServiceBus" Version="3.0.6" />
  <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="1.0.29" />
</ItemGroup>

Yes it's a little odd - you're adding a package for "WebJobs" but this is a Functions application. That's because the WebJobs triggers and Functions triggers share the same infrastructure, but with a different hosting/deployment model.

In any case, you can now create a function with Many Attributes. Here's one for a simple request/response:

public static class SayFunctionSomethingHandler
{
    [FunctionName("SayFunctionSomethingHandler")]
    [return: ServiceBus("NsbAzureHosting.Sender", Connection = "AzureServiceBus")]
    public static SayFunctionSomethingResponse Run(
        [ServiceBusTrigger("NsbAzureHosting.FunctionReceiver", Connection = "AzureServiceBus")]
        SayFunctionSomethingCommand command, 
        ILogger log)
    {
        log.LogInformation($"C# ServiceBus topic trigger function processed message: {command.Message}");

        return new SayFunctionSomethingResponse { Message = command.Message + " back at ya!"};
    }
}

We have to declare the function name, as an attribute, the trigger, as an attribute, and the return value also as an attribute. Service Bus client takes care of deserializing my message from JSON (assuming the content type of the message was application/json).

Functions (or the Azure Service Bus client) don't understand the concept of "request/response" or "pub/sub" for that matter, so it's up to you to build these concepts on top. If you want to subscribe to an event, you need to set up the topic and subscription inside of the broker.

There's no support for request/response, return addresses, or correlated replies. For us, if we want to "reply" back to the receiver, we'd need to create a client, pick off some reply address from the headers, and generate and send the message.

In the above example, I've hardcoded the reply queue, NsbAzureHosting.Sender, so it's not even following the correct message pattern. With request/reply, the receiver should be ignorant of the sender, just as modern email clients are.

So while all this works, you don't get the more advanced features of a full message endpoint and all the patterns of the Enterprise Integration Patterns book. You have to roll a lot yourself.

You also get only very primitive retry capabilities - retries are immediate and once exhausted the message goes to the dead-letter queue. With NServiceBus, we get immediate and delayed retries - very helpful when we're using some external resource whose downtime won't get resolved within milliseconds.

With all this in mind, let's look at the NServiceBus function support.

NServiceBus bindings

I won't rehash the entire sample, but there are some key differences in this setup than a "normal" functions setup. Azure Functions doesn't have a lot of the extensibility support that NServiceBus, so it won't give you things like Outbox, deferred messages, idempotent receivers, sagas, and so on. So NServiceBus gets around this by still hosting an "endpoint", and delegating the message handling to the endpoint from inside your function:

[FunctionName(EndpointName)]
public static async Task Run(
    [ServiceBusTrigger(queueName: EndpointName)]
    Message message,
    ILogger logger,
    ExecutionContext executionContext)
{
    await endpoint.Process(message, executionContext, logger);
}

The endpoint is what processes the message inside a full execution pipeline, so you can focus on building out full message handlers, instead of just functions:

public class TriggerMessageHandler : IHandleMessages<TriggerMessage>
{
    private static readonly ILog Log = LogManager.GetLogger<TriggerMessageHandler>();

    public Task Handle(TriggerMessage message, IMessageHandlerContext context)
    {
        Log.Warn($"Handling {nameof(TriggerMessage)} in {nameof(TriggerMessageHandler)}");
        return context.SendLocal(new FollowupMessage());
    }
}

Now inside of our message handler, we get the full IMessageHandlerContext, and not just the ExecutionContext of a function, which is fairly limited. Now we can reply, publish, defer, set timeouts, kick off sagas, all inside the full-featured NServiceBus message endpoint.

Neither of these options are great, but for very simple message handlers, an Azure Function can suffice. While an Azure Function isn't close to a "PaaS Message Endpoint", it is close to a "PaaS Message Handler", and that might be sufficient for your needs.

In the last post, I'll look ahead to see how our situation may improve with gulp Kubernetes.