Azure Durable Functions, deel 3: de eerste Orchestrator en Activity

In het eerste deel van deze serie heb ik uitgelegd waarom Durable Functions waarde kunnen toevoegen: we hoeven minder plumbing te schrijven. In het tweede deel zijn we begonnen met de code (voornamelijk plumbing, helaas. Nu wordt het tijd om iets Durable te gaan maken!

Ik wil eerst even stilstaan bij wat de term Durable Functions nu precies betekent. In het eerste deel heb ik je al behoorlijk veel theorie gegeven dus ik wilde dit even overslaan. Maar nu is het moment toch daar om uit te leggen waar we het nu precies over hebben.

Azure Functions zijn stateless. Je roept ze aan met data, de runtime maakt een instance van de functie aan (en dus de class waar deze in zit, en dus een app-domain, en eventueel wordt er zelfs een VM opgestart om die app-domain op te draaien), de functie gaat aan het werk, stuurt het resultaat terug en… komt te overlijden. De functie is weg, alle data die erbij hoort is weg. De app-domain kan ook weg en zelfs de VM zou kunnen verdwijnen.

Dit zorgt er natuurlijk voor dat we heel makkelijk een schaalbaar systeem kunnen maken. Als we ineens heel veel requests krijgen, kunnen we makkelijke tientallen, honderden of duizenden instances van onze functies de lucht in krijgen. Ze hebben toch niets met elkaar of met andere systemen te maken dus ze zitten elkaar niet in de weg.

Maar: als je toch state wilt bewaren zul je dat ergens op moeten slaan. SQL Server is een optie, blog storage of table storage zou ook kunnen, het maakt eigenlijk niet uit. Als je er maar voor zorgt dat alle data die je wilt bewaren ergens opgeslagen wordt want de functie verdwijnt uit het geheugen als deze klaar is.

Uiteraard hebben we daar al heel lang oplossingen voor. En met Durable Functions hebben we daar een oplossing bijgekregen. Durable Functions doen alsof ze state hebben. Dat hebben ze niet: de data wordt steeds opgeslagen tussen de calls in zodat deze bewaard blijft, maar het gebeurt achter de schermen. We krijgen dus de illusie van een stateful, oftewel durable, function.

Een van de dingen die je daar mee kunt doen is er een state machine van maken, of een workflow. Als je een workflow heb, moet je bijhouden waar je bent in de flow en de resultaten van alle stappen opslaan en delen met de andere stappen. Dat klinkt als stateful programmeren en dat is dus ook precies waar Durable Functions je mee helpen.

De workflow bestaat uit 3 onderdelen:

  1. Een starter function, die de workflow aftrapt
  2. Een orchestrator function, die de juiste functies aanroept en de data deelt
  3. De activity functions, die het echte werk doen

We hebben een begin gemaakt met de starter functie. Laten we daar eens een echte starter van maken. Daarna zullen we de orchestrator bekijken, gevolgd door de activity.

Komt ‘ie!

De starter function

We nemen onze code uit de vorige post. De functie “StartRegistration” zal de workflow gaan starten. Deze heeft nu immers alle data die we nodig hebben (name, email en wantsTweet).

Om Durable Functions te kunnen gebruiken moeten we wel een Nuget package installeren. Dat is Microsoft.Azure.WebJobs.Extensions.DurableTask

Let op: op dit moment is de laatste stabiele versie v1.8.3. Er is een preview van v2 uit maar die werkt totaal anders. Dus gebruik deze nog even niet. Als deze versie uit preview is, zal ik er uitgebreid over schrijven!

Nuget pacakge voor de Durable Functions

Als dit gebeurt is, gaan we terug naar onze starter function. We voegen een parameter toe aan de method:

[FunctionName("StartRegistration")]
public static async Task<HttpResponseMessage> StartRegistration(
    [HttpTrigger(
        AuthorizationLevel.Function, 
        "get", 
        Route = "dotNed/register/{name}/{email}/{wantsTweet}")] 
    HttpRequestMessage req,
    [OrchestrationClient] DurableOrchestrationClient client,
    string name, 
    string email,
    bool wantsTweet,
    ILogger log)
{
    log.LogWarning($"Received: {name} {email} {wantsTweet}.");

    return req.CreateResponse(System.Net.HttpStatusCode.OK);

}
}

In regel 8 zie je dat ik een DurableOrchestrationClient (met de naam client) heb toegevoegd. Deze heeft als attribuut OrchestrationClient zodat de runtime deze kan injecteren. De client kunnen we gebruiken om de workflow mee te starten.

We daan de body van de method aanpassen, maar voor we dat gaan doen moeten we iets hebben wat de data vast kan houden die we moeten doorgeven. We krijgen deze data mee van de runtime (name, email en wantsTweet) en die wil ik in een class hebben. Dus:

namespace MeetingRegistration
{
    public class Attendee
    {
        public string Name { get; set; }
        public string Email { get; set; }
        public bool WantsTweet { get; set; }
    }
}

Dit is een gewone DTO. We zullen in deze reeks nog veel meer van dit soort classes maken, ieder met precies genoeg informatie voor de function die ze zal krijgen. Want: micro-services zijn zo onafhankelijk mogelijk.

In de body van onze Function kunnen we nu een instance aanmaken van deze Attendee class. Deze kunnen we dan doorgeven aan de orchestrator die deze data zal gebruiken om alle functies uit te voeren.

Ok. Komt ‘ie:

        [FunctionName("StartRegistration")]
        public static async Task<HttpResponseMessage> StartRegistration(
            [HttpTrigger(
                AuthorizationLevel.Function, 
                "get", 
                Route = "dotNed/register/{name}/{email}/{wantsTweet}")] 
            HttpRequestMessage req,
            [OrchestrationClient] DurableOrchestrationClient client,
            string name, 
            string email,
            bool wantsTweet,
            ILogger log)
        {
            log.LogWarning($"Received: {name} {email} {wantsTweet}.");
            var attendee = new Attendee 
            { 
                Name = name, 
                Email = email, 
                WantsTweet = wantsTweet 
            };

            var activityId = await client.StartNewAsync("O_PerformRegistration",attendee);
            return client.CreateCheckStatusResponse(req, activityId);
        }

Ik heb hier nog even de hele method neergezet. Vanaf nu zal ik dat alleen doen als dat nodig is. Maar: dit is alles wat we nodig hebben voor onze starter functie. Hij is af!

Wat doen we nu?

Als eerste maken we die Attendee instance aan.

Daarna roepen we StartNewAsync aan op de client (je weet wel: die geinjecteerde DurableOrchestrationClient). Deze krijgt een paar parameters: de naam van de orchestrator function en de data die we door willen geven.

De naam is een string. Zoals ik al eerder zei, kijkt Azure Functions alleen naar het attribuut FunctionName om te bepalen wat je aan wilt roepen. De C# method name is niet relevant. Ik heb als naming convention overigens de volgende regel:

  1. Normale, publieke functions krijgen dezelfde naam als de C# method
  2. Orchestration functions krijgen de naam als de method maar met de prefix O_
  3. Activity functions krijgen de naam als de method maar met de prefix A_

De reden hiervoor is dat we later, in Azure, bij de Function App een hele lijst krijgen met alle functies die we hebben. We krijgen uiteindelijke meerdere publieke functies, meerdere orchestrators en meerdere activities. Dan is het wel fijn om in die lijst meteen te zien wat nu precies wat is.

De StartNewAsync geeft een string terug. Deze string is de unieke identifier van deze run van de workflow. Dus voor iedere attendee die zich aanmeldt wordt de workflow gestart en krijgen we een unieke ID.

Als laatste heb ik de return aangepast. Eerst stond daar dat we 202 OK teruggeven, maar nu laat ik de client wat debug informatie genereren en die op het scherm tonen. Doe dit nu niet in productie: alle gevoelige informatie die je niet wilt delen komt hier in te staan. We zullen zo zien waar dat uit bestaat.

De orchestrator basis

Voordat we dit kunnen runnen om te testen, moeten we een kale orchestrator hebben. Ook dit is een Azure Function. Ik zet deze even in een aparte class, waar alle orchestrators in zullen komen. Op die manier hou ik een mooie scheiding:

Dit is hem:

using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;

namespace MeetingRegistration
{
    public static class OrchestratorFunctions
    {
        [FunctionName("O_PerformRegistration")]
        public static void PerformRegistration(
            [OrchestrationTrigger] DurableOrchestrationContext ctx,
            ILogger log)
        {
            log.LogWarning("We are in the orchestrator! Yeah!");
        }
    }
}

De class heb ik OrchestratorFunctions genoemd. Net als bij de starter functions is de naam niet relevant, maar C# verreist nu eenmaal dat alle code in classes zit.

De method zelf heet PeformRegistration. Ook hier is de naam niet belangrijk; wel belangrijk is de FunctionName attribuut met daarin de naam “O_PerformOrchestration”. Dit is de naam die we gebruikt hebben in de starter method, dus die moeten overeen komen.

De method krijgt twee parameters: als laatste de logger en als eerste de trigger. Aangezien dit gewoon een Azure Function is, moeten we een trigger opgeven. Onze starter function had een HTTP Trigger, deze heeft een OrchestrationTrigger (type DurableOrchestrationContext, naam ctx).

En in de body doen we niets anders dan loggen.

Ik heb de method niet async gemaakt, we hebben ook geen return value.

Laten we dit eens gaan runnen. Als we de runtime starten, zien we de normale debug informatie en de URL die we kunnen gebruiken om de starter aan te roepen. Daar is niets veranderd. Als we echter in de browser die URL aanroepen, krijgen we een heel ander resultaat. In de output van de runtime zien we dit:

Output van de runtime bij de eerste run

En in onze browser zien we dit:

Output van browser window bij eerste run

Ok. Wellicht heb jij in je browser hele andere informatie: ik heb in Google Chrome de extension JSON Viewer geinstalleerd die JSON iets vriendelijker weergeeft. Maar de data moet ongeveer hetzelfde zijn.

Laten we eens beginnen met de debug info.

We zien keurig in het geel boven in dat onze data ontvangen is door de starter functie. Prima. Maar verder op zien we in het geel de log van onze orchestrator. Deze is dus ook gevonden!

De output in de browser is ook veelzeggend. Als eerste de datum en tijd van de aanroep. De tweede regel geeft de volledige request URL weer. En daarna komt er wat extra informatie.

Deze info is afkomstig uit de call naar client.CreateCheckStatusResponse.

Ik zei al dat je dit niet in productie wil. We gaan de verschillende URL’s nog wel nakijken, maar ik kan je wel verklappen dat hier een aantal secret keys in staan die je liever niet deelt met de buitenwereld. Zo is er bijvoorbeeld een URL genaamd terminatePostUri, die je kunt gebruiken om een lopende workflow om zeep te helpen. Dat is iets wat je liever niet deelt met de rest van de wereld. De query parameter “code” is de secret key die je nodig hebt om dit uit te kunnen voeren, deze moet je in principe nooit delen.

Klik voor de gein nu eens op de URL voor “statusQueryGetUri” of kopieer deze uit je JSON als je geen JSON Viewer hebt en plak deze in een nieuw window.

Status info van een workflow

Hier zie je meer informatie over de workflow die je aangeklikt had. Zo zien we hier de naam van de orchestrator (O_PerformRegistration), de instanceId van de workflow, de status (completed) en nog meer. Dit scherm geeft ons, zeker later als we meer interessante dingen gaan doen, heel veel info.

Onze eerste activity

Ik wil je niet achter later zonder een echte activity. In de volgende post gaan we deze uitbreiden en er meer induiken, maar ik wil hem nu toch alvast even geven.

Maak een nieuwe class aan (ik noem hem ActivityFunctions) met daarin een method. Deze ziet er bij mij zo uit:

using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;

namespace MeetingRegistration
{
    public static class ActivityFunctions
    {
        [FunctionName("A_ValidateInput")]
        public static bool ValidateInput([ActivityTrigger] string email, 
            ILogger log)
        {
            var isValid = email.IndexOf('@') != -1;
            log.LogWarning($"Email {email} check result: {isValid}");

            return isValid;
        }
    }
}

Het moet nu redelijk bekend voorkomen: een class, een method en de namen maken niet zo veel uit. Een functionname, (dit keer met A_ ervoor om mij later duidelijk te maken dat dit een activity is).

We hebben een ActivityTrigger hier als trigger. Echter, welk data type we hier doorgeven is niet relevant voor de runtime. We controleren hier alleen maar het email adres, dus we geven alleen die maar door. Weet je nog? Micro services krijgen zo weinig mogelijk data en dus afhankelijkheden.

Deze code is nogal dom: we kijken of het email adres geldig is door te kijken of er een @ teken in staat. Een regular expression zou beter zijn, maar ik beschouw ze als write-once code dus die probeer ik zo veel mogelijk te vermijden. Verder loggen we het resultaat.

Deze functie zullen we verder niet uitbreiden. Hij is goed zo.

Om deze nu te gebruiken, moeten we deze aanroepen. Duh. Omdat te doen moeten we terug naar onze orchestrator.

Als eerste moeten we even de return type en signature van de orchestrator aanpassen. Dit was public static void, maar dat wordt nu public static async Task. We gaan namelijk onze activity async aanroepen (ook al is er niets async aan, maar dat leg ik volgende post uit).

In onze starter function geven we een Attendee instance mee aan de orchestrator. Die moeten we op de een of andere manier uit lezen. Gelukkig is daar een mooie method voor: client.GetInput<T>(). Daarmee kunnen we de parameters uitlezen die in deze instance van de workflow zitten. Het lijkt dus wel stateful, maar let op: dat is het niet! Het lijkt alleen maar zo..

De code:

[FunctionName("O_PerformRegistration")]
public static async Task PerformRegistration(
    [OrchestrationTrigger] DurableOrchestrationContext ctx,
    ILogger log)
{
    log.LogWarning("We are in the orchestrator! Yeah!");
    var attendee = ctx.GetInput<Attendee>();
            
    await ctx.CallActivityAsync<bool>("A_ValidateInput", attendee.Email);
}

De signature is nu dus public static async Task geworden.

Na de log lezen we de Attendee uit met een call naar ctx.GetInput<Attendee>()

En dan: de magie. we roepen onze eerste activity aan! Deze is asynchroon dus die moeten we awaiten. Deze geeft een bool terug (want: true als er een @ in zit en anders False). De function heet A_ValidateInput. En de data is attendee.Email.

Er is dus een verschil tussen orchestration functions en activity functions als het gaat om het doorgeven van data. Hoewel je bij beiden de data doorgeeft in de call naar de client of context (in de starter zeiden we “StartNewAsync(“O_PerformRegistration”, attendee) en hier zeggen we CallActivityAsync<bool>(“A_ValidateInput”, attendee.Email)), lees je ze uit op verschillende manieren: in de orchestration function gebruiken we ctx.GetInput en in de activity function krijgen we hem geinjecteerd in de functie aanroep. Het is maar dat je het weet…

Tijd om te runnen!

Resultaat van de eerste run van de Activity Function

De output in de browser is ongeveer hetzelfde als voorgaande keren. De output van de runtime is anders: we zien dat onze activity aangeroepen is en we zien of we een @ in het email veld hebben!

En…. voor de scherpzinnigen onder ons: inderdaad, de regel “We are in the orchestrator! Yeah!” staat er twee keer. En dat is geen bug. Dat is bij design…

Maar dat bespreken we de volgende keer!

Published by Dennis Vroegop

Passionate Azure Cloud Solutions Architect. I am a enthusiastic guitar player (though not as good at it as I'd like to be) and in my daytime job I teach software developers to be better at their job. Married to my wonderful wife Diana with whom I try to raise our daughter Emma.

2 thoughts on “Azure Durable Functions, deel 3: de eerste Orchestrator en Activity

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.