Azure Durable Functions, deel 6: Sub-orchestrations

We zijn al best ver gekomen. Maar we zijn er nog niet.

In het vorige deel hebben we de twee activities geschreven die de namen van de usergroups ophaalt en vervolgens een bericht stuurt naar die groepen met het email adres van de bezoeker. We konden de berichten parallel versturen, de code ging pas verder nadat we alle berichten verzonden hadden.

Tenminste, zo leek het.

We weten nu dat het eigenlijk anders werkt. Na iedere aanroep van een activity gaat de orchestrator weg om daarna weer in het leven geroepen te worden, waarna we via een replay alles opnieuw uitvoeren. Nou ja, alles.. de activities niet: alleen het resultaat wordt opgehaald. Nu is dit de prijs de we moeten betalen voor een durable omgeving in een stateless, serverless omgeving. En de penalty is ook niet zo hoog. Maar: stel dat we honderden usergroups zouden willen verwittigen, gevolgd door honderden notificaties dat iedereen akkoord gaat. Dan krijgen we heel veel replays.

Kijk eens naar de Storage Explorer, na een run van onze relatief kleine workflow:

Storage Explorer met onze workflow

De hoeveelheid data in deze tabel kan snel heel groot worden. Dat geeft niet, maar dat betekent ook dat de replays steeds langer duren. Daar is een oplossing voor. We kunnen delen van de orchestration er uit halen en in een nieuwe orchestration zetten. Deze orchestration draait zelfstandig en krijgt ook een eigen InstanceId, alsof het een totaal nieuwe orchestration is. Dat is het dan ook.

Dus: voor de hoofd orchestration is de sub-orchestration iets als een activity, in de sub-orchestration is het een echte orchestration. Klinkt als een win-win situatie, niet waar? Laten we dat dan maar bouwen!

Refactoring van de orchestration

Als we de hoeveelheid replays willen beperken, zullen we de stukken code die vaak herhaald worden uit de hoofd method moeten halen. In ons geval is dat de code die door de lijst van usergroups heen gaat en daar dan berichten heen stuurt. Maar: voor die lus staat een call naar een andere activity: het ophalen van de usergroups. Wat doen we daar mee? Gaan we die naar de sub-orchestration verhuizen? Dan wordt hij iedere keer als we een bericht naar een usergroup sturen aangeroepen. Nou ja… niet echt aangeroepen: het resultaat wordt opgehaald. Maar het is wel weer een extra entry in de tabellen. En dat voor iedere usergroup.

Aan de andere kant: hij hoort functioneel wel echt bij de code die die berichten verstuurd. Dus wat dat betreft moet hij in de suborchestration.

Het is een keuze die je moet maken. Ik kies voor leesbaarheid en overzichtelijkheid in mijn code. Die tabel zal ik toch niet veel lezen dus als daar wat meer data in komt vind ik dat wel prima.

Laten we de code voor het versturen van de notificaties maar eens refactoren en in een aparte method zetten:

private static async Task NotifyAllUserGroups(DurableOrchestrationContext ctx, ILogger log, Attendee attendee)
{
    if (!ctx.IsReplaying)
        log.LogWarning("Getting all usergroups.");

    var allUsergroups = await ctx.CallActivityAsync<string[]>("A_GetUsergroupNames", null);

    var allCalls = new List<Task>();
    foreach (var usergroupName in allUsergroups)
    {
        var notification = new UsergroupNotification 
            {AttendeeEmail = attendee.Email, 
                UsergroupName = usergroupName

            };
        allCalls.Add(ctx.CallActivityAsync("A_NotifyUsergroup", notification));
    }

    await Task.WhenAll(allCalls);
}

En uiteraard in de orchestrator method roep ik deze aan na de aanroep na ValidateInput() en voor het einde. Affijn, je weet wel wat refactoren is:

Nu is dit geen suborchestration. Deze method is nog steeds onderdeel van de hoofd orchestration. Dus ik ga hem even herschrijven:

[FunctionName("O_NotifyAllUsergroups")]
public static async Task NotifyAllUsergroups([OrchestrationTrigger] DurableOrchestrationContext ctx, ILogger log)
{
    if(!ctx.IsReplaying)
        log.LogWarning("Calling all usergroups");

    var attendeeEmail = ctx.GetInput<string>();
    var allUsergroups = await ctx.CallActivityAsync<string[]>("A_GetUsergroupNames", null);

    var allCalls = new List<Task>();
    foreach (var usergroupName in allUsergroups)
    {
        var notification = new UsergroupNotification { AttendeeEmail = attendeeEmail, UsergroupName = usergroupName };
        allCalls.Add(ctx.CallActivityAsync("A_NotifyUsergroup", notification));
    }

    await Task.WhenAll(allCalls);
}

Dit is al een heel stuk beter. Ik heb er nog even een log bij gezet die alleen aangeroepen wordt als we niet aan het replayen zijn.

Het is een nieuwe orchestrator functie (snap je nu waarom ik de static class OrchestratorFunctions genoemd heb?)

Ik ga de email van de attendee meegeven (weet je nog: micro services krijgen niet meer data dan wat ze nodig hebben, dus ik ga niet de hele attendee meegeven). Ik haal die attendee email op uit de context. Dan haal ik de usergroups op in de activity A_GetUsergroupNames. Vervolgens maak ik de lijst met Tasks aan, geef deze de nieuwe activities mee en uiteindelijk roep ik alles aan met Task.WhenAll();

In mijn hoofd-orchestrator vervang ik de call naar de gerefactorde method met de volgende call:

await ctx.CallSubOrchestratorAsync("O_NotifyAllUsergroups", attendee.Email);

Ik gebruik de method CallSubOrchestratorAsync en geef de naam van de nieuwe orchestrator en de email mee.

Als ik dit aanroep, zal de orchestrator, net als bij de activities, weer uit het geheugen gaan. We komen pas weer terug in de orchestrator als de suborchestrator klaar is. Net als bij de activities dus.

De suborchestrator doet zijn ding op dezelfde manier als de hoofd orchestrator: zo gauw je een activity aanroept verdwijnt hij uit het geheugen, komt terug vanaf het begin en gaat het resultaat uitlezen van die activity. Je kent het nu wel.

Het mooie is dat deze sub-orchestrator nu steeds herhaalt wordt voor iedere usergroup. De log wordt maar een keer weggeschreven dankzij de call naar ctx.IsReplaying. De hoofd orchestrator echter blijft wachten.

Als we dit gaan draaien en we kijken in de TableStorage, dan zien we dat de suborchestration inderdaad een andere instance Id krijgt. Het kan zelfs zo zijn dat deze suborchestration op een hele andere VM draait, of zelfs in een andere regio. We zijn nog steeds stateless en serverless en dus schaalbaar, maar we zijn wel een stuk overzichtelijker geworden.

Als je nu meerdere suborchestrators parallel wilt draaien, kun je hetzelfde trucje uithalen als we vorige keer deden: niet await aanroepen bij ctx.CallSubOrchestratorAsync maar deze opslaan in een lijst en die dan met Task.WhenAll laten uitvoeren. Cool he!

Next step

Dit is allemaal leuk en prima, maar wat als een workflow nu handmatige stappen kent? Hoe gaan we daar mee om? Veel workflow engines hebben daar iets voor, iets wat er voor zorgt dat de flow gepauzeerd wordt tot iemand of iets zegt dat we verder kunnen. Met Durable Functions kan dat ook. Hoe? Dat zien we in de volgende twee posts.

Azure Durable Functions, deel 5: Parallele execution

We gaan verder met onze inschrijving function workflow. Je zag in de vorige post hoe de engine intern werkt. Dat is op zich goed om te weten, maar we hoeven ons er nu niet al te druk meer over te maken. Echter, je moet wel onthouden dat we in de orchestrator geen code mogen gebruiken die niet deterministisch is. Oftewel: geen code die iedere keer andere resultaten zou kunnen terug geven.

Fan out – Fan in

In ons systeem hebben we besloten de andere user groups op de hoogte te stellen van het voorgenomen bezoek van deze bezoeker. We willen graag van elkaar weten wie nu vaak meetups bezoekt, wie zich altijd aanmeldt maar niet komt, en hoe goed we het nu eigenlijk doen. Even voor de duidelijkheid: dat doen we in het echt dus niet: zelfs al zou het van de wet mogen (en dat mag dus niet) dan nog nemen we privacy heel serieus. Het is maar een voorbeeld…

Nu kunnen we die code relatief makkelijk schrijven. We kunnen een functie maken en daarin een REST api aanroepen. We gaan er even vanuit dat alle usergroups een standaard API hanteren, dus dat is niet zo ingewikkeld.

Maar: Micro Services zijn klein en doen maar 1 ding. Dus een service schrijven die alle REST API’s van alle usergroups aanroept, past daar niet binnen. Daarnaast kan de lijst met usergroups best lang worden, dus waarom zouden we dat niet parallel uitvoeren? Dit zou de hele execution van de totale workflow wel ten goede komen.

Het proces ziet dan als volgt uit:

Fan out, fan in process

We hebben ons start punt. Dat is het einde van onze vorige A_CheckInput. Vervolgens gaan we parallel een bericht sturen naar de usergroups SDN, DevNetNoord, DotNetOost en de anderen. Pas als deze berichten allemaal verstuurd zijn, gaan we verder (en in ons voorbeeld tot nu toe is dat het einde van het proces).

Dit is het Fan out – fan in patroon. We waaieren eerst uit naar de verschillende functies, en dan wachten we tot ze klaar zijn waarna we verder gaan. De resultaten worden gebundeld en we kunnen verder gaan.

In ons voorbeeld doen we even niets met de eventuele resultaten van de REST calls. We zouden in dat geval dus een fire-and-forget actie kunnen doen. Wel fan-out maar niet wachten. Maar ik wil graag het hele proces laten zien. Fan out only is makkelijker te bouwen dus dat kun je zelf wel bedenken.

Goed. Laten we eerst de activity eens maken. Ik ga niet echt de implementatie maken van het aanroepen van een REST API: dat is gewoon standaard .net code.

Als eerste moeten we weer bedenken wat we mee gaan geven aan de usergroups. Ik denk dat een email adres genoeg is. De naam is niet relevant. De activity zelf heeft echter ook informatie nodig over welke usergroup we gaan aanroepen. Ik gebruik even een string met de naam van de groep. Dus:

public class UsergroupNotification
{
    public string UsergroupName { get; set; }
    public string AttendeeEmail { get; set; }
}

Geen verrassingen hier.

De activity zelf is ook niet ingewikkeld, zeker omdat we hier niets in het echt gaan doen:

[FunctionName("A_NotifyUsergroup")]
public static void NotifyUsergroup(
    [ActivityTrigger] UsergroupNotification notification, 
    ILogger log)
{
    log.LogWarning($"We are notifying {notification.UsergroupName} about {notification.AttendeeEmail}.");            
}

We maken weer een standaard activity. De FunctionName krijgt de prefix A_. Het is een ActivityTrigger en als data geven we mee de net aangemaakte UsergroupNotification. Uiteraard willen we een logger erbij.

In de body doe ik niets anders dan een log wegschrijven.

Nu naar de orchestrator. Na de call naar A_CheckInput (als dat goed gegaan is) moeten we deze activity starten. Dit is een eerste opzetje:

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

            var isValid = await ctx.CallActivityAsync<bool>("A_ValidateInput", attendee.Email);
            if (!isValid)
            {
                return new RegistrationResult
                {
                    IsSucces = false,
                    Reason = "Not a valid email given."
                };
            }

            await ctx.CallActivityAsync("A_NotifyUsergroup", 
                new UsergroupNotification { 
                    AttendeeEmail = attendee.Email, 
                    UsergroupName = "SDN" });

            await ctx.CallActivityAsync("A_NotifyUsergroup",
                new UsergroupNotification
                {
                    AttendeeEmail = attendee.Email,
                    UsergroupName = "DNN"
                });

            await ctx.CallActivityAsync("A_NotifyUsergroup",
                new UsergroupNotification
                {
                    AttendeeEmail = attendee.Email,
                    UsergroupName = "DNO"
                });


            if (!ctx.IsReplaying)
                log.LogWarning("At the end of the workflow.");

            return new RegistrationResult {
                IsSucces = true,
                Reason = "Everything checked out."
            };
        }

Ik heb even de hele orchestrator geplaatst zodat je ziet waar we zijn.

Dit werkt wel maar is niet echt handig: we roepen de verschillende usergroups nu sequentieel aan. We wachten iedere keer tot er een call geweest is en gaan dan verder. Dat willen we niet: we willen het parallel doen.

Ok. Poging 2: ik vervang de 3 calls door het volgende stuk code:

var allUsergroups = new string[] { "SDN", "DNN", "DNO" };
foreach(var usergroupName in allUsergroups)
{
    var notification = new UsergroupNotification { AttendeeEmail = attendee.Email, UsergroupName = usergroupName };
    await ctx.CallActivityAsync("A_NotifyUsergroup", notification);
}

Dat lost niet veel op. De code is leesbaarder maar het is nog steeds sequentieel. Poging 3 dan maar:

var allUsergroups = new string[] { "SDN", "DNN", "DNO" };
var allCalls = new List<Task>();
foreach(var usergroupName in allUsergroups)
{
    var notification = new UsergroupNotification { AttendeeEmail = attendee.Email, UsergroupName = usergroupName };
    allCalls.Add(ctx.CallActivityAsync("A_NotifyUsergroup", notification));
}

await Task.WhenAll(allCalls);

Dit is veel beter. We maken een lijst van de usergroups, dan maken we een lege lijst van Tasks aan. In de loop roepen we CallActivityAsync aan maar we awaiten het niet. Dan krijgen we een Task terug (we hebben geen return type, dus geen Task<T> maar gewoon Task). De verzamelen we in de lijst. Pas als we alle Tasks aangemaakt hebben, roepen we await Task.WhenAll(allCalls); aan.

Deze gaat alle Tasks opstarten en komt pas terug als alle tasks uitgevoerd zijn. Let op: ook hier geldt dat het systeem iedere keer als er een task gestart wordt, de orchestrator weggooid en daarna opnieuw opstart. Met andere woorden: samen met de call naar A_CheckInput wordt de orchestrator nu 5 keer gestart… Een keer voor de eerste run, daarna na het beeindigen van A_Checkinput en daarna 3 keer na het beeindigen van iedere call naar A_NotifyUsergroup. Je ziet: het aantal invocations kan snel oplopen.

We zouden het resultaat van de calls makkelijk kunnen uitlezen hier. Ze geven immers allemaal een result terug. Dit gaan we niet doen, maar ik vraag je wel even om na te denken hoe je dit soort dingen zou bouwen in een normale Azure Function. Realiseer je eens hoeveel controle code je zou moeten schrijven. Nu doen we een fan-out, fan-in in een paar regels code.

Configuration ophalen

Ik ben nog niet tevreden. De lijst met usergroups is nu hardcoded in de orchestrator. Gezien het tempo waarin Meetups verschijnen en weer verdwijnen, is dat niet optimaal. We moeten die data ergens vandaan halen.

Het ligt voor de hand om die lijst in een storage te hebben. We hebben immers al de TableStorage. Maar in dit voorbeeld maak ik het leven wat eenvoudiger en sla ik het op in de config. Daar kan ik later makkelijk bij komen als het aantal usergroups dat we willen benaderen veranderd.

In de file local.settings.json verander ik de huidige settings in:

{
    "IsEncrypted": false,
    "Values": {
        "AzureWebJobsStorage": "UseDevelopmentStorage=true",
      "FUNCTIONS_WORKER_RUNTIME": "dotnet",
      "UserGroups": "SDN,DNN,DNO"
    }
}

Ik heb dus de “UserGroups” key toegevoegd en de waardes erbij gezet in een comma-seperated list.

Als ik dit later wil deployen naar Azure, zal ik die waardes daar ook moeten toevoegen. De file local.settings.json is immers, zoals de naam al zegt, local.

Dit moeten we even uitlezen:

var allUsergroups = System.Environment.GetEnvironmentVariable("Usergroups")
    .Split(',')
    .ToArray(); 
         
var allCalls = new List<Task>();
foreach(var usergroupName in allUsergroups)
{
    var notification = new UsergroupNotification { AttendeeEmail = attendee.Email, UsergroupName = usergroupName };
    allCalls.Add(ctx.CallActivityAsync("A_NotifyUsergroup", notification));
}

await Task.WhenAll(allCalls);

We lezen de data uit, doen een split op de komma en veranderen dit in een array. De rest blijft hetzelfde.

Zijn we er nu?

Nee.

Dit mag dus niet. Orchestrator functions moeten deterministic zijn. Stel dat tussen de invocation van de eerste call naar A_NotifyUsergroup en de tweede de config wijzigt. Dat kan zo maar gebeuren. Wat betekent dat dan? We hebben al eerder gezien dat we geen code mogen gebruiken die eventueel andere resultaten tussen invocations kan opleveren. Dus dit mag niet.

De oplossing is uiteraard simpel: verplaats die code naar een eigen activity. Immers: de eerste keer dat die activity uitgevoerd wordt, lezen we de settings file daadwerkelijk uit. De keren daarna krijgen we het resultaat daarvan terug uit de TableStorage, ongeacht of de onderliggende data veranderd is. Dat maakt voor deze instance van de workflow niet uit.

Dus onze orchestration ziet er als volgt uit:

if (!ctx.IsReplaying)
    log.LogWarning("Getting all usergroups.");

var allUsergroups = await ctx.CallActivityAsync<string[]>("A_GetUsergroupNames", null);
            
var allCalls = new List<Task>();
foreach(var usergroupName in allUsergroups)
{
    var notification = new UsergroupNotification { AttendeeEmail = attendee.Email, UsergroupName = usergroupName };
    allCalls.Add(ctx.CallActivityAsync("A_NotifyUsergroup", notification));
}

await Task.WhenAll(allCalls);

In regel 4 zien we de call naar de Activity. Deze moet data mee krijgen maar we kunnen daar null voor gebruiken.

De activity:

[FunctionName("A_GetUsergroupNames")]
public static string[] GetUsergroupNames(
    [ActivityTrigger] object input, 
    ILogger log)
{
    var allUsergroups = System.Environment.GetEnvironmentVariable("Usergroups")
        .Split(',')
        .ToArray();

    log.LogWarning("Loaded the usergroup list.");

    return allUsergroups;
}

Het is een activity, dus we moeten een parameter meegeven met als attribuut [ActivityTrigger]. Echter, we geven null mee en we doen er niets mee, dus ik heb er object van gemaakt met als naam input. Er moet toch iets staan.

In de body lezen we de config uit. Hier mag dat wel: dit wordt per instance van de workflow maar een keer uitgevoerd. Het resultaat komt voor deze instance in de TableStorage te staan dus we hebben een deterministische orchestrator.

Als we deze runnen kunnen we in de output ook zien dat het werkt:

Ouput van fan-out, fan-in calls

De volgorde van de output kan varieren, de timings ook. Maar alle usergroups die we hebben staan in de config worden aangeroepen.

Interessant is ook de output. Klik maar op de status query (je weet wel: die in de browser staat onder de naam statusQueryGetUri. Voeg aan het einde van deze url de parameters &showHistory=true&showHistoryOutput=true en zie het resultaat.

// 20191103143449
// http://localhost:7071/runtime/webhooks/durabletask/instances/a9f84fe8ee0f42529b26b9cfeeb68a17?taskHub=DurableFunctionsHub&connection=Storage&code=6ZoZrnZw/qufYwLVultvsgS7RiwKNf1g6t0AjF9YNUMERC84wKrOLA==&showHistory=true&showHistoryOutput=true

{
  "name": "O_PerformRegistration",
  "instanceId": "a9f84fe8ee0f42529b26b9cfeeb68a17",
  "runtimeStatus": "Completed",
  "input": {
    "$type": "MeetingRegistration.Attendee, MeetingRegistration",
    "Name": "Dennis",
    "Email": "dennis@vroegop.org",
    "WantsTweet": true
  },
  "customStatus": null,
  "output": {
    "IsSucces": true,
    "Reason": "Everything checked out."
  },
  "createdTime": "2019-11-03T13:34:20Z",
  "lastUpdatedTime": "2019-11-03T13:34:25Z",
  "historyEvents": [
    {
      "EventType": "ExecutionStarted",
      "Timestamp": "2019-11-03T13:34:20.8306001Z",
      "FunctionName": "O_PerformRegistration"
    },
    {
      "EventType": "TaskCompleted",
      "Result": true,
      "Timestamp": "2019-11-03T13:34:22.6157852Z",
      "ScheduledTime": "2019-11-03T13:34:21.8540948Z",
      "FunctionName": "A_ValidateInput"
    },
    {
      "EventType": "TaskCompleted",
      "Result": [
        "SDN",
        "DNN",
        "DNO"
      ],
      "Timestamp": "2019-11-03T13:34:23.3958736Z",
      "ScheduledTime": "2019-11-03T13:34:23.1352168Z",
      "FunctionName": "A_GetUsergroupNames"
    },
    {
      "EventType": "TaskCompleted",
      "Result": null,
      "Timestamp": "2019-11-03T13:34:24.0132401Z",
      "ScheduledTime": "2019-11-03T13:34:23.7418444Z",
      "FunctionName": "A_NotifyUsergroup"
    },
    {
      "EventType": "TaskCompleted",
      "Result": null,
      "Timestamp": "2019-11-03T13:34:24.158211Z",
      "ScheduledTime": "2019-11-03T13:34:23.7418405Z",
      "FunctionName": "A_NotifyUsergroup"
    },
    {
      "EventType": "TaskCompleted",
      "Result": null,
      "Timestamp": "2019-11-03T13:34:24.5138455Z",
      "ScheduledTime": "2019-11-03T13:34:23.7418457Z",
      "FunctionName": "A_NotifyUsergroup"
    },
    {
      "EventType": "ExecutionCompleted",
      "OrchestrationStatus": "Completed",
      "Result": {
        "IsSucces": true,
        "Reason": "Everything checked out."
      },
      "Timestamp": "2019-11-03T13:34:25.1201956Z"
    }
  ]
}

En zo kunnen we dus zaken parallel laten draaien!

Toch ben ik nog niet helemaal tevreden. Onze orchestrator functie wordt wat groot en er gebeurt te veel in. Daar gaan we in de volgende post wat aan doen.

Azure Durable Functions, deel 4: de workflow van de workflow

Goed, In het vorige deel hebben we een starter function, een orchestration function en een activity function gemaakt. Het lijkt een hoop werk voor een relatief eenvoudig systeem, maar dat valt wel mee: de rest van de code wordt niet veel ingewikkelder dan dit. Nou ja, wel een beetje maar de plumbing zit er nu ongeveer wel in.

We hebben ook gezien dat als we onze heel eenvoudige workflow starten, er in de output twee keer de log message van de orchestrator stond. En ik vertelde dat dat geen bug is maar dat dat zo hoort.

In deze post, zal ik uitleggen hoe dat werkt en gelijk hoe stateless Azure Functions ineens toch stateful kunnen lijken.

Als je wilt meekijken, raad ik je aan om de gratis tool Azure Storage Explorer te downloaden vanaf deze site. Deze tool gebruik je om te kijken wat er in je storage accounts staat, ook in je emulated versie van je storage account. Dus: we kunnen kijken hoe onze Durable Functions in het echt werken.

De orchestrator aanpassen

Om een goed beeld te krijgen van de werking van Durable Functions, moeten we even onze orchestrator aanpassen. Dat moesten we toch al: onze validator geeft aan of een attendee een geldig email adres meegeeft of niet. Als dat zo is: prima, we gaan door. Maar als dat niet zo is, dan moeten we stoppen met de functies.

Dit was een van de voorbeelden die ik aanhaalde om aan te geven dat we best veel code moeten schrijven om die case af te handelen. In “traditionele” Azure Functions moet je op de een of andere manier bijhouden wat er gebeurt met het resultaat van die Validator functie. Ik zei al dat die functie messages in de queue post om aan te geven wat het resultaat is. Maar: dat betekent dat de functie zelf kennis heeft van de flow en de messages die er allemaal zijn. Dat wil je niet: die functie is minder herbruikbaar op die manier. De functie zoals we die nu geschreven hebben is proces-agnostisch. Hij heeft geen idee waar hij voor gebruikt wordt, het enige wat hij weet is dat hij en boolean terug geeft om aan te geven of een email adres geldig is of niet (nou ja, of er een @ in zit of niet, maar dat is muggenziften,,,)

De orchestrator regelt per definitie de flow. Dat is wat een orchestrator doet. Dus daar gaan we iets met het return resultaat doen.

Ik introduceer een nieuwe DTO: RegistrationResult. Deze geeft de orchestrator terug: is het goed gegaan of niet?

namespace MeetingRegistration
{
    public class RegistrationResult
    {
        public bool IsSucces { get; set; }
        public string Reason { get; set; }
    }
}

Deze spreekt voor zich, denk ik.

Dit moeten we gaan gebruiken in de orchestrator functie:

[FunctionName("O_PerformRegistration")]
public static async Task<RegistrationResult> PerformRegistration(
    [OrchestrationTrigger] DurableOrchestrationContext ctx,
    ILogger log)
{
    log.LogWarning("We are in the orchestrator! Yeah!");
    var attendee = ctx.GetInput<Attendee>();
            
    var isValid = await ctx.CallActivityAsync<bool>("A_ValidateInput", attendee.Email);
    if (!isValid)
    {
        return new RegistrationResult
        {
            IsSucces = false,
            Reason = "Not a valid email given."
        };
    }

    return new RegistrationResult { 
        IsSucces = true, 
        Reason = "Everything checked out." 
    };
}

De return type is veranderd van Task naar Task<RegistrationResult>. Aan het einde van de method geven we de default terug met de info dat alles gelukt is. Als onze activity aangeeft dat er een probleem was (dus het resultaat is False), geven we die info ook terug aan onze aanroepende client.

Als we dit gaan testen, krijgen we het volgende resultaat met een geldig email adres (dus: in de browser de link van statusQueryGetUri klikken of selecteren en pasten)

Succes met het runnen

En als we een ongeldig adres meegeven:

Geen succes met ongeldig email adres

(ik weet het: waarschijnlijk weet je het wel, maar… je hoeft de app niet iedere keer opnieuw op te starten.. gewoon de URL in de browser aanpassen. Je krijgt toch een nieuwe instance van de workflow)

Je ziet dan in het eerste geval de “output” alle data bevat die aangeeft dat alles goed ging, en in het tweede geval dat er een fout was (not a valid email given).

Dit is het resultaat van de orchestration. Maar kunnen we ook kijken wat er in de activities gebeurt? Ik zou de vraag niet stellen als het antwoord niet ‘ja’ was..

In het scherm met de output van het resultaat moeten we de querystring even aanpassen (let op dat je runtime nog steeds draait.. mocht dit niet zo zijn dan kun je gewoon in Visual Studio op F5 drukken. Je hoeft geen nieuwe request aan te maken: deze oude is nog gewoon beschikbaar).

Voeg aan het einde van de URL in de browser de volgende parameter toe:

&showHistory=true

Meer debug output in de browser

Je ziet dat je nu voor alle stappen meer informatie krijgt. Meer nog dan je wellicht zou verwachten (komt goed.. moment!)

Voeg nu de volgende parameter toe aan wat we net al hadden:

&showHistoryOutput=true

En ververs de browser

Nog meer input

Als je even zoekt, zie je nu per stap de return values. In ons geval zien we dat A_ValidateInput false terug geeft, wat resulteerde in die message die we deze attendee niet kunnen accepteren. We kunnen deze attendee helaas niet laten weten dat hij of zij niet mag komen: we hebben geen geldig email adres.

Let op: deze informatie wordt heel snel heel groot. Vandaar dat we twee opties hebben om het al dan niet te laten zien.

Async / await in durable functions

Maar hoe zit het nu met die dubbele log messages? En al die aangeroepen functies die ik zie? Ik ben blij dat je het vraagt.

We komen nu bij de kern van hoe Durable Functions durable kunnen zijn en toch stateless. Heb je de Storage Explorer open staan? Die hebben we namelijk zo nodig. Kijk even mee!

Het hart van het systeem wordt gedefinieerd door de keywords Async / Await. De meeste .net / c# ontwikkelaars kennen deze wel, hoewel maar weinigen echt weten hoe het intern werkt. Mijn uitleg over de werking hieronder is totaal niet correct, maar geeft wel weer hoe de werking zou kunnen zijn. Met andere woorden: ook al werkt het intern anders, zo zou het kunnen werken…

Async / await is een pattern on asynchrone code synchroon uit te voeren. Kijk eens naar de volgende dummy code:

public async Task DoSomethingUsefull()
{
    // Hier komen we binnen
    Console.WriteLine("Hello world");

    // Nu doen we de volgende stap, maar...
    // dit wordt uitgevoerd in een andere thread (niet dus, maar het
    // idee klopt wel)
    // Pas als die thread klaar is, komen we hier terug in deze thread

    await DoSomethingThatLastsLong();
            
    // Als bovenstaande thread klaar is, gaan we hier verder
    // (nog steeds: niet dus...)

}

Dit is ongeveer hoe de meeste mensen denken dat async/ await werkt. En het idee klopt wel, hoewel dit pattern in het echt niet (altijd) met threads werkt.

Je zou verwachten dat onze code van de orchestrator hetzelfde doet.

Echter… dit is niet wat er gebeurt. Dit is namelijk weer een mooi voorbeeld van hoe keywords in C# hergebruikt worden maar iets anders doen in een andere context.

Laten we de volgende dummy orchestrator eens bekijken:

public static async Task DummyOrchestrator(
    [OrchestrationClient] DurableOrchestrationClient client
    )
{
    // Entry point. Hier beginnen we.

    var result = await client.StartNewAsync<string>("A_Activity", SomeData);
    // De thread wacht hier tot de activity klaar is, 
    // en gaat dan verder

    // Meer code..
}

Dit lijkt op de code die we eerder zagen. Maar de compiler maakt er iets heel anders van. De Activity bijvoorbeeld doet het volgende in het echt:

public static string Activity([ActivityTrigger] SomeData someData)
{
    // Doe wat werk
    // Sla de data op in TableStorage in Azure (of Emulator)
    // Return void... 
}

Hoewel deze activity een string terug moet geven, geeft deze niets terug… het resultaat echter wordt opgeslagen in TableStorage.

De orchestrator doet iets als deze pseudocode:

public static async Task DummyOrchestrator(
    [OrchestrationClient] DurableOrchestrationClient client
    )
{
    // Entry point. Hier beginnen we.

    bool a_activityHasRun = GetRunStatus("A_Activity");
    if (!a_activityHasRun)
    {
        client.StarAsync("A_Activity", SomeData);
        return;
    }
    var result = ReadFromTableStorage("A_ActivityResult");

    // Meer code..
}

Hier moeten we even iets langer bij stilstaan.

De workflow wordt gestart. Als eerste wordt er ergens in een tabel in TableStorage gekeken of de eerste activity (A_Activity) al gedraaid heeft voor deze workflow instance. Als dat niet zo is, wordt deze functie uitgevoerd in een aparte context en de orchestrator verlaat het geheugen. De code wordt opgeruimd. Het app-domain verdwijnt. De VM kan zelfs weggegooid worden, afhankelijk van de tijd en de geheugendruk.

Als de activity klaar is met zijn werk en het resultaat opgeslagen heeft in de TableStorage, wordt er een nieuwe instance van de orchestrator gemaakt. En deze begint van voor af aan…

Alleen: dit keer is het resultaat van GetRunStatus(“A_Activity”) true: we hebben hem al uitgevoerd! Dus de method gaat door en haalt dat resultaat op uit de TableStorage. Nu hebben we het resultaat, en we gaan verder.

Als hier nu weer een activity gestart wordt, gebeurt hetzelfde: de orchestrator stopt en verdwijnt uit het geheugen. Als de activity klaar is, wordt de orchestrator weer opgestart en begint alles weer van voor af aan.. ook A_Activity (maar die is al uitgevoerd dus het enige wat er gebeurt is dat het resultaat uit de TableStorage komt.

Onze log message verschijnt daarom dus 2 keer. Await / Async werkt hier dus heel anders dan we gewend zijn. Await hier betekent: stop met verwerken, verlaat de functie en ruim je resources op. Dat is iets heel anders dan de thread pauzeren!

Regels voor orchestrators

Aangezien de code in de orchestrator iedere stap van de workflow opnieuw uitgevoerd wordt zijn er een aantal beperkingen aan wat je kunt doen in die code. Je kunt niet zomaar alles doen wat je wilt: de flow in de method moet deterministisch zijn. Het systeem moet volledig voorspelbaar zijn. Kijk even naar de volgende dummy orchestrator:

[FunctionName("O_MyOrchestrator"]
public static async Task MyOrchestrator(
    [OrchestrationTrigger] DurableOrchestrationContext ctx, 
    ILogger log)
{
    var invocationDate = System.DateTime.UtcNow;
    log.LogInformation($"Code is invoked at {invocationDate}.");

    var result = await ctx.CallActivityAsync<bool>("A_MyActivity", invocationDate);
    var moreResult = await ctx.CallActivityAsync<Attendee>("A_EnrichAttendee", invocationDate);
}

Op zich gebeurt hier niets raars. We halen de tijd op, we loggen de informatie en we roepen twee activities aan. Prima. In activity A_MyActivity geven we die datum mee. Blijkbaar is dat nodig om een berekening uit te voeren.

Maar wat gebeurt er nu echt? Laten we het eens stap voor stap bekijken.

  1. We halen de huidige UTC tijd op
  2. We loggen die tijd
  3. We zorgen ervoor dat de functie A_MyActivity gestart wordt, met de huidige tijd als data
  4. We verlaten deze method.
  5. Als A_MyActivity afgerond is, start deze method weer op
  6. We halen de huidige UTC tijd op
  7. We loggen de nieuwe tijd
  8. We halen het resultaat van A_MyActivity uit de TableStorage
  9. We starten activity A_EnrichAttendee op, met als parameter een nieuwe waarde voor invocationDate…

Wacht even. Dat kan niet kloppen. Als die twee activities iets met die tijd doen, en daar dus afhankelijk zijn, dan kun je in de flow van deze method aflezen dat ze allebei dezelfde waarde krijgen. Maar die krijgen ze niet. Als A_MyActivity uren er over doet om te runnen (en dat kan makkelijk!) dan zit er een behoorlijk verschil tussen de waardes van invocationDate die aan de twee methods doorgegeven worden.

Dit levert potentieel grote problemen op.

Ander voorbeeld: we maken in de eerste regel een GUID aan. Deze hebben we nodig als key in een storage bijvoorbeeld. De eerste activity krijgt deze GUID mee en slaat data op met die GUID als key. De tweede activity heeft die GUID ook nodig om de data weer op te halen. Maar: omdat de orchestrator opnieuw begint krijgen we een nieuwe GUID.

Dit mag dus niet.

Net als het ophalen van data uit een storage, uit een config, uitlezen van een web-resources als REST server, enzovoorts. Alle data die mogelijk tussen twee calls in kan veranderen, mag je niet gebruiken in een orchestrator functie.

Dat klinkt als een beperking, maar dat valt wel mee. We zullen snel zien hoe we hier mee om moeten gaan, maar ik kan je wel alvast de volgende code meegeven:

public static async Task MyOrchestrator(
    [OrchestrationTrigger] DurableOrchestrationContext ctx, 
    ILogger log)
{
    var invocationDate = ctx.CurrentUtcDateTime;

    if(!ctx.IsReplaying)
        log.LogInformation($"Code is invoked at {invocationDate}.");

    var result = await ctx.CallActivityAsync<bool>("A_MyActivity", invocationDate);
    var moreResult = await ctx.CallActivityAsync<Attendee>("A_EnrichAttendee", null);
}

In plaats van System.DateTime.UtcNow vragen we ct.CurrentUtcDateTime op. De eerste keer dat we in deze orchestrator komen voor deze instance van de workflow, geeft dit de huidige datum en tijd terug. De volgende keer als we hier inkomen, nadat A_MyActivity klaar is, dan krijgen we gegarandeerd dezelfde waarde terug. Hoewel die tweede call dus dagen later kan gebeuren (want activities kunnen heel lang duren) krijgen we altijd die eerste waarde terug.

Hetzelfde kunnen we doen met ctx.NewGuid. Die genereert een GUID die bij het opnieuw uitvoeren hetzelfde resultaat terug geeft.

Verder kunnen we aan de context vragen of dit de eerste keer is dat deze regel uitgevoerd wordt: ctx.IsReplaying geeft aan of dit een replay is of niet. Let wel op: als A_MyActivity klaar is, is op dit punt IsReplaying true, maar zo gauw de code na de call naar A_MyActivity is (dus wanneer eigenlijk het resultaat uit de TableStorage gehaald wordt) dan is IsReplaying weer false. We zijn immers hier nog niet geweest.

Ga nu niet proberen de boel te optimaliseren door dit te doen:

public static async Task MyOrchestrator(
    [OrchestrationTrigger] DurableOrchestrationContext ctx, 
    ILogger log)
{
    var invocationDate = ctx.CurrentUtcDateTime;

    if(!ctx.IsReplaying)
        log.LogInformation($"Code is invoked at {invocationDate}.");

    if(!ctx.IsReplaying)
        var result = await ctx.CallActivityAsync<bool>("A_MyActivity", invocationDate);

    if (!ctx.IsReplaying)
        var moreResult = await ctx.CallActivityAsync<Attendee>("A_EnrichAttendee", null);
}

Je moet echt iedere keer die CallActivityAsync aanroepen. Weet je nog? De eerste keer zorgt dit er voor dat de functie uitgevoerd wordt, de tweede, derde en andere keren haalt die code regel het resultaat op uit de TableStorage. Dus die moet iedere keer opnieuw aangeroepen worden.

Ik gebruik die ctx.IsReplaying dan eigenlijk alleen maar voor het loggen van informatie. Dat hoef ik namelijk niet iedere keer te zien. De data is steeds gelijk dus 1 logregel per keer is genoeg.

Stateless durable functions

En nu weet je gelijk hoe het kan dat we stateless kunnen zijn en toch onze data kunnen behouden. Alle data wordt continue opgeslagen in een TableStorage. Deze storage kunnen we zo configureren dat we zelfs geo-redundant zijn. Dus de data staat keurig ergens in de cloud, de functies worden iedere keer opnieuw gestart (en de app-domain opnieuw aangemaakt, zelfs de VM waar het op draait kan opnieuw aangemaakt worden). We zijn volledig serverless en stateless bezig maar voor ons als ontwikkelaar lijkt het alsof we een eenvoudige flow hebben in een method waarin data bewaard blijft tussen de calls in. Het lijkt wel magie!

Even terug naar onze originele code. Als we deze voor de allereerste keer draaien, maakt de runtime alle structuren voor ons aan. Ik heb het al een paar keer over die TableStorage gehad, laten we eens kijken hoe dat er uit ziet. En daar hebben we die Storage Explorer voor nodig.

Als ik onze code nu een keer ga draaien, krijg ik het resultaat dat we al gezien hebben. Ik geef even een geldig email adres mee zodat we de hele flow (hoe kort ook) doorlopen.

Daarna start ik de Storage Explorer. Die ziet er dan bij mij als volgt uit:

Storage Explorer

Zoals je ziet, heeft de runtime van alles aangemaakt. We hebben blob containers, queues, en tables.

Die queues (4 control queues en 1 workitems queue, die het lopende werk bevatten) zijn het hart van het systeem. Ik vertelde in de allereerste post hoe je functions kunt laten communiceren: door het versturen van messages in een queue. Later vertelde ik dat we dat kunnen voorkomen door Durable Functions te gebruiken. Maar wat blijkt nu: dat is precies hoe Durable Functions werken. Ze versturen zelf allemaal berichten in allerlei queues om het werk te synchroniseren. Het verschil: we hoeven het dit keer niet zelf te schrijven. Maar uiteindelijk, onder water, zijn alle orchestrator en activity functions gewoon queue-triggered functions. We zien het alleen niet.

Alle data die we nodig hebben voor het runnen van een workflow staat in de tabel DurableFunctionsHistory. Durable Functions zijn een voorbeeld van event-sourcing: alle data wordt stap voor stap opgeslagen zodat we deze later kunnen herhalen.

In het voorbeeld dat ik hier gaf kun je dat duidelijk zien. Je ziet precies welke stappen er genomen worden. In de kolom EventType staat precies welke stap er genomen wordt en daar achter in de andere kolommen input en result (niet zichtbaar in mijn voorbeeld maar bij jou kun je hem wel zien) staat de data die heen en weer gaat.

We kunnen ook mooi zien wat de flow is:

  1. Orchestrator started. We beginnen de flow dus
  2. Execution started. We gaan een instance uitvoeren
  3. Task Scheduled. Onze A_CheckInput method wordt klaar gezet om te beginnen
  4. Orchestrator Completed. Tot A_CheckInput klaar is, is de orchestrator voorlopig klaar
  5. Orchestrator started. Blijkbaar is A_CheckInput klaar: we gaan opnieuw beginnen
  6. Task Completed. We gaan het resultaat van A_CheckInput ophalen (in de result staat nu dan de waarde true)
  7. Execution Completed. We zijn aan het einde van deze instance flow
  8. Orchestrator Completed. De orchestrator wordt weer afgebroken

Je kunt de flow met alle data, timestamps en dergelijke dus perfect volgen.

En nu?

We hebben weer een hoop besproken. In de volgende post gaan we een wat meer uitdagende activity maken: we gaan de andere usergroups informeren over het feit dat iemand zich aanmeldt.

Tot dan!

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!

Azure Durable Functions, deel 2: de eerste module

in het vorige deel hebben we gekeken naar de noodzaak voor een robuust framework die ons in staat stelt om ons te richten op de bedrijfsprocessen en niet op de randzaken. Nu kunnen we natuurlijk nooit helemaal om die randzaken heen, maar we kunnen wel proberen die zo veel mogelijk te vermijden. Het scenario wat ik schetste is die van de dotNed website en de stappen die we nemen om iemand in te schrijven voor een van onze Meetups.

In deze post gaan we een begin maken met de bouw van dit systeem.

Ik ga er even vanuit dat we al iets hebben wat de input van de gebruiker verzamelt. Dat kan een website zijn, een mobiele app, een UWP applicatie, wat dan ook, We hebben iets dat de data heeft en die moet nu verwerkt worden. De eerste stap die we nemen is het valideren van de input. Als het systeem input krijgt die geldig is, kan de rest van het systeem verder gaan. Mocht dit niet geldig zijn, dan negeren we de inschrijving en stoppen we de verwerking.

Uiteraard is het zo dat we in de echte wereld eerst controleren of er bijvoorbeeld wel een geldig email adres is gegeven voordat we dit doorgeven aan het back-end proces. Op die manier kunnen we de gebruiker op voorhand al melden dat er een probleem is. Maar we gaan er even vanuit dat we ook willen kijken of die email adres toebehoort aan iemand van het bestuur van Stichting dotNed zelf. Die gaan we niet inschrijven: die zijn standaard al aanwezig (nogmaals: fictief voorbeeld).

Serverless computing

We hebben dus iets wat de data verzamelt en we moeten nu het backend proces in werking stellen.

Het ligt voor de hand om Azure Functions hiervoor te gebruiken. Azure Functions zijn op zich staande modules die functionaliteit bevatten die we los van andere modules kunnen draaien. We praten hier dan ook vaak over “serverless computing”. Nu is dat onzin: de code draait wel degelijk op een server. In het geval van Azure Functions is dat zelfs een Windows of Linux Virtual Machine die op een fysieke Windows of Linux machine draait. Ergens moet er immers een CPU zijn die onze code draait.

Wat er bedoelt wordt met serverless computing is dat we ons niet druk hoeven te maken over die server. Die is er wel en doet z’n ding. We zien hem niet, we horen er niets van en we hoeven er niets aan te configureren. Het is alsof deze server er niet is. Vandaar de naam.

Ok. We kunnen dus een losse functie maken. Daarvoor heb je nog geen Azure subscription nodig; althans niet tijdens het ontwikkelen. Deze hele reeks zal ik gebruik maken van mijn lokale omgeving en de emulator, om pas in de laatste module naar Azure zelf te gaan.

Dus: zorg dat je Visual Studio geïnstalleerd heb samen met de Azure Workload. Start de Azure Cloud Storage Emulator alvast op. Start Visual Studio en dan kunnen we beginnen.

Het opstarten van de workflow

We zouden kunnen beginnen met de eerste functie te schrijven als Azure Function. Maar dat zou betekenen dat we alle plumbing er om heen ook moeten gaan schrijven. En dat gaan we niet doen.

Ons proces kunnen we zien als een workfow. Er zijn een aantal stappen die genomen moeten worden. Deze stappen hebben onderlinge afhankelijkheden. Vroeger zouden we daarvoor WF (Workflow Foundation) gebruiken maar die is niet echt meer van deze tijd. Dus we gaan het anders doen.

Wat we gaan doen is het maken van een zogenaamde orchestrator. Een orchestrator is een functie die er voor zorgt dat alle functies die onze functionaliteit bevatten in de juiste volgorde en onder de juiste condities aangeroepen gaan worden.

Nu zul je misschien denken: “Maar dat is toch juist weer plumbing, wat we niet zouden doen?” en dan heb je nog gelijk ook. Maar: we moeten wel iets aan plumbing doen, maar we houden het beperkt. Je zult zien dat deze code enorm leesbaar, simpel en klein is.

De orchestrator, die dus alles aanstuurt, is een Azure Function. Deze function echter moet wel gestart worden. Er moet een trigger zijn die hem afvuurt. Nu is het zo dat orchestrator functions niet zo maar van buiten af aangeroepen kunnen worden. De standaard security staat dat niet toe (we gaan het later over security hebben). Dus we moeten iets hebben wat deze orchestrator aanstuurt. En dat is de rol van een recht-toe, recht-aan azure function.

Die function wordt op zijn beurt weer aangeroepen door middel van een REST API.

Samengevat: een website (of app, of uwp applicatie, of wat dan ook) doet een REST call naar een starter functie. Deze starter functie start op zijn beurt de orchestrator, welke dan weer alle stappen doorloopt. Klinkt moeilijk of omslachtig? Valt mee!

De eerste functie

Ok. Tijd voor code. We hebben Visual Studio draaien. We gaan een nieuw project maken. Als je de Azure Workload geinstalleerd heb, kunnen we daarvoor kiezen:

Create New Project in Visual Studio

Als je een nieuw project aanmaakt, kun je zoeken naar de template Azure Functions. Die kiezen we. Klik Next. Geef het project een naam (de mijne heet MeetingRegistration in de gelijknamige solution). Klik Create.

Create new Function in Visual Studio

In dit scherm maken we een keuze voor een aantal dingen.

  1. Welke versie
  2. Welke trigger
  3. Welk Storage Account
  4. Welke authenticatie

De versie is V2. V1 is outdated en V3 is in de maak maar nog niet beschikbaar op het moment dat ik dit schrijf. Ik zal in de laatste post laten zien wat de verschillen zijn, maar voor nu kiezen we V2.

De trigger is wat de functie start. We hebben de keuze uit een aantal triggers; in dit geval wil ik dat we via een REST API de functie kunnen starten. We kiezen dus voor een HTTP Trigger

Storage account is niet nodig: we draaien lokaal voorlopig (je hebt de Microsoft Azure Storage Emulator al draaien, toch?). Je kunt kiezen voor een echt Storage Account maar je hebt het niet nodig

Authenticatie die ik op functie level. We hebben drie keuzes:

  1. Function. Dat betekent dat iedere Azure Functie een eigen secret key krijgt die je mee moet geven als je de functie wilt aanroepen. Je kunt later in de Azure Portal per functie meerdere keys aanmaken, revoken en recyclen. Je kunt dus goed bepalen wie wat mag doen.
  2. Anonymous. Spreekt voor zich: geen authtenticatie. Iedereen mag alle functies in deze function app aanroepen.
  3. Admin. Je krijg 1 key voor alle functions in de function app. Als je bijvoorbeeld alle admin-functionaliteit in een aparte function app maak, kun je op deze manier de rechten toekennen aan de hele app.

We kiezen dus voor function-level. Klik Create

Visual Studio gaat aan de gang en maakt onze eerste functie voor ons, met de enorm fantasieloze naam Function1.

Hoewel deze reeks artikelen gaan over Durable Functions en dit absoluut niet een Durable Function is, wil ik toch even door de structuur heen gaan van deze code. Immers, Durable Functions zijn Azure Functions dus een goed begrip van hoe deze dingen werken helpt.

public static class Function1
{
    [FunctionName("Function1")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
        ILogger log)
    {
        log.LogInformation("C# HTTP trigger function processed a request.");

        string name = req.Query["name"];

        string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        dynamic data = JsonConvert.DeserializeObject(requestBody);
        name = name ?? data?.name;

        return name != null
            ? (ActionResult)new OkObjectResult($"Hello, {name}")
            : new BadRequestObjectResult("Please pass a name on the query string or in the request body");
    }
}

Laten we er eens doorheen gaan.

Als eerste hebben we de class Function1. Functions zijn gewoon methods in een C# class, dus er moet ergens een class zijn. We doen er niets mee, maar hij moet er wel zijn. Het is een static class en heet Function1. Tja, wat moet ik daar nog meer over zeggen.

Op regel 4 staat de signature van onze functie. De naam hier is niet relevant. Hij heet ‘Run’ maar we hadden hem ook Fietsbel kunnen noemen. Azure roept de functies aan op basis van de naam die gegeven is met het attribuut “FunctionName” op regel 3. In ons geval dus Function1 (ja, we gaan zo alles renamen, maar nu laten we het even zo).

Onze Run method is public, static en async (en geeft dus een Task terug. Om precies te zijn geeft het een Task<IActionResult> terug met info voor de aanroepende client.

We hebben een paar parameters. Het is belangrijk om te weten dat het framework onder Azure Functions gebruik maakt van Dependency Injection. Dus alles wat we nodig hebben kan worden geinjecteerd en hoeven we niet zelf aan te maken. Een mooi voorbeeld hiervan zie je op regel 6: we krijgen een logger van het type ILogger mee, waarmee we kunnen, uh, loggen.

Daarvoor echter op regel 5 staat een andere parameter van het type HttpRequest met een aantal attributen.

Dit zie je terug bij alle Azure Functions, dus later ook bij de Durable Functions. Er moet een trigger aangegeven worden welke het framework verteld hoe deze function in het leven geroepen wordt. In ons geval is dat een HttpTrigger. Deze heeft een paar parameters: welke authorization level we gebruiken (Function dus), welke REST Verbs we accepteren (get en post) en of we een bepaalde route gebruiken (nee dus).

De body van de code is niet zo relevant. Wat er gebeurt is dat er gekeken wordt of er queryparameters zijn (om precies te zijn met de naam ‘Name’ ) of dat er wellicht in het geval van een Post een body is met daarin een Name attribuut. Als dat niet zo is, melden we dat aan de gebruiker. Als dat wel zo is, zeggen we vriendelijk hallo.

De eerste run

Laten we eens kijken wat er gebeurt als we dit gaan runnen.

Druk op F5.

Nadat Visual Studio alle packages gedownload heeft, zal de runtime opstarten. Dit is dus iets wat normaal in Azure hoort te gebeuren maar dat kan dus ook lokaal.

Je krijgt info te zien over wat er allemaal gebeurt, en uiteindelijk krijg je te zien hoe we deze function moeten gaan aanroepen:

Runtime Azure Functions

Zoals je ziet, verteld de runtime ons keurig netjes dat we een browser kunnen openen en die laten wijzen naar http://localhost:7071/api/Function1. En oh ja, hij accepteert GET en POST. Laten we de GET eens testen. Dat kan gewoon in de browser, maar we moeten wel even die Name parameter meegeven.

Eerste running function

Ok. Ik geef toe: het is niet echt indrukwekkend. Maar dat komt nog wel. We hebben in ieder geval een werkend systeem!

In de command window (die waarin die URL vernoemd werd) kun je op CTRL-C drukken om de runtime af te sluiten. Ik raad je aan om dat ook steeds te doen: er kunnen dingen blijven hangen die we anders handmatig moeten op ruimen.

Laten we de functie even iets mooier maken en wat moderniseren. We nemen de volgende stappen:

  1. Hernoemen van de class (en de code-file)
  2. Hernoemen van de C# Method
  3. Hernoemen van de Azure Function
  4. Aanpassen van de in- en output
  5. Weggooien van de “hello-world” code.
  6. Verwijderen van de POST
  7. Aanpassen van de route zodat we parameters mee kunnen geven

Mijn versie ziet er dan zo uit:

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

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

    }
}

Ok. Wat heb ik gedaan?

Ik heb de class PublicFunctions genaamd. De reden: we gaan meerdere functies maken. En zoals ik al zei: Orchestration Functions kun je niet van buiten af aanroepen, dat moet een andere Azure Function doen. En dit is er een van, dus die moet publiek zijn.

Ik heb de function name aangepast: deze is nu StartRegistration, net als de method name van de C# method zelf. Dat is wel zo duidelijk, denk ik.

De return type is veranderd in Task<HttpResponseMessage> wat in lijn ligt met de input trigger parameter van het type HttpRequestMessage. Deze is iets flexibeler en biedt mogelijkheden die we later nodig gaan hebben.

Ik heb de POST optie weggehaald en een Route toegevoegd. Deze Route is nu “dotNed/register/{name}/{email}/{wantsTweet}”

Normaal gesproken zouden we dit via een POST doen en dan in de body van de request meegeven als JSON object, maar aangezien we op deze manier dit keurig van de browser kunnen intypen zonder gebruik te maken van tools als Postman bijvoorbeeld, heb ik hier voor gekozen. Als jij liever met POST werkt en dit via een JSON body mee wilt geven, is dat natuurlijk ook prima.

In ons geval gebruik ik dus de route en de waardes worden geïnjecteerd in de method call: de parameters string name, string email en bool wantsTweet (of de gebruiker wil of er getweet wordt). Ik weet het: we missen details als welke meeting en zo, maar vergeet dat maar even hier.

We loggen de informatie maar dit keer heb ik log. Warning gebruikt. De reden is dat Warnings in een andere kleur weergegeven worden in de output zodat het opvalt tussen alle debug output van de runtime. Als laatste geven we een 202 OK terug.

Laten we dit eens gaan runnen.

Output runtime

Je ziet dat de URL die we moeten gebruiken nu meer informatie geeft. Ik kopieer dit en plak dit in een browser window, vervang de {name}, {email} en {wantsTweet} door ‘echte’ waardes en bekijk het resultaat.

Browser window

Het resultaat hier is niet echt spannend. Zelfs onze vriendelijke Hello is nu verdwenen. Het output window van de runtime daarentegen is wat spannender geworden:

Output van de nieuwe functie

We zien in het midden ongeveer keurig in het geel (omdat we een Warning geven in plaats van Information) dat onze Function de data binnen heeft gekregen en het begrepen heeft.

Volgende stappen

We hebben nu een werkende Azure Function. Ok. Hij doet niet zo veel, maar dit wordt zo de trigger voor onze orchestration.

En dat is precies het onderwerp van de volgende post!

Tot dan!

Azure Durable functions

Enige tijd geleden heb ik een lezing gehouden over Azure Durable Functions. Dit verhaal, wat uiteindelijk 2.5 uur duurde, bevatte eigenlijk alle ins-and-outs over deze technieken. Ik heb daarna van verschillende mensen het verzoek gekregen om dit verhaal nogmaals te houden op andere plekken.

Nu wil ik dat natuurlijk altijd graag doen, maar echt schaalbaar is dat niet. Ik heb dus maar besloten om het hele verhaal op te schrijven en op die manier met iedereen te delen.

Dus… als je wilt weten hoe je met Azure Durable Functions moet beginnen, wat het is, en hoe je je architectuur hiermee schaalbaar kunt maken, dan is dit voor jou.

Aangezien het verhaal erg lang is, heb ik besloten om het op te knippen in een hele reeks van posts. Dit is de eerste daarvan, met de introductie. De rest van de posts zal meer code voorbeelden bevatten en praktische informatie. Maar voor we dat kunnen doen, moeten we het eerst over de theorie hebben.

Once upon a time…

Het web bestond in de jaren 90 uit niet veel meer dan een verzameling losse html pagina’s. Het was niet meer dan een aanplakbord met wat teksten en plaatjes.

Uiteraard duurde het niet lang of de CMS’en kwamen en de pagina’s die we voorgeschoteld kregen waren dynamisch opgebouwd. Maar het basis idee bleef: het meeste was eenrichting verkeer: iemand zocht informatie en kreeg die op het web.

Verbeteringen in de browsers en vooral in de scripting mogelijkheden aan de clientkant, zorgen er al gauw voor dat het web veranderde van een billboard met info naar een systeem voor twee-weg verkeer. De web-apps waren een feit: het verving steeds meer de client-server technologie die in bedrijven al gemeengoed waren. De logge en on-premise servers werden vervangen door web servers en de thick clients werden vervangen door thin, browser based clients.

De systemen die we toendertijd bouwden waren nog erg gebaseerd op dit model. De server deed al het werk. En dat gebeurde vaak in monolytische systemen. De client stuurde informatie naar een webserver en in dat ene request werd al het werk gedaan.

Ik ben betrokken bij Stichting dotNed, een stichting met als doel het verspreiden van kennis rondom het Microsoft development platform. Dat doen we al sinds 2002, in de tijd dat Microsoft met .NET kwam.

Een van de dingen die we doen, is het organiseren van wat tegenwoordig Meetups heet: bijeenkomsten waarin ontwikkelaars bij elkaar komen om iets van elkaar te leren. Voordat iemand naar zo’n bijeenkomst gaat, verwachten we wel dat hij of zij zich inschrijft op onze website.

Die website is een mooi voorbeeld van zo’n systeem. De browser is het invulscherm, de webserver regelt alle zaken er omheen. We registreren wie er komen, we sturen een tweet de wereld in om te zeggen dat deze persoon naar onze bijeenkomst komt, we versturen de nieuwsbrief naar die persoon enzovoorts.

Old School website
“Old-school” monolitische web-app”

Op een gegeven moment kwam het begrip “micro-services” in zwang. Architecten over de hele wereld grepen dit aan om al hun systemen opnieuw te definiëren en in te delen. Maar helaas kwamen de meeste architecten niet verder dan het verplaatsen van hun core-functionaliteit naar een of twee services . Met andere woorden: we hebben nu in plaats van een monolitisch systeem meerdere min-of-meer monolitische systemen. Het is niet overzichtelijker geworden; het is zelfs nog minder doorzichtig waar nu wat gebeurt.

Iets minder beroerd systeem

Dus in plaats van alle code in de website te plaatsen, hebben we nu alle code in 1 of 2 losse stukken gezet die we aanroepen vanuit de website. Dit helpt niet echt.

Helaas zie ik dit als consultant nog steeds vaak gebeuren bij bedrijven, zeker in het midden- en kleinbedrijf. De architecten daar willen wel graag gebruik maken van een goede architectuur maar hebben of niet de kennis, of krijgen niet de ruimte van het management om dit voor elkaar te krijgen. Het gevolg: slecht onderhoudbare en slecht schaalbare systemen.

Micro-services

Een architectuur gebaseerd op micro-services werkt anders. Dit is gebaseerd op het maken van kleine, zelfstandige, discrete functies die slechts een ding doen, onafhankelijk van de rest van het systeem. Op die manier kunnen we deze functies in parallel ontwikkelen, testen en deployen. Later kunnen we deze functies onafhankelijk van elkaar aanpassen, uitbreiden, opschalen en vervangen door iets nieuws.

Stel je nog even onze dotNed website voor. In dit (fictieve!) voorbeeld hebben we een aantal functies die we moeten doen als iemand zich inschrijft. In dit voorbeeld zijn dat de volgende stappen:

  1. Het valideren van de input die de gebruiker ons geeft
  2. We melden het geplande bezoek aan andere usergroups
  3. We controleren het bestuur van dotNed of ze akkoord zijn met deze bezoeker
  4. We plaatsen een tweet op het net om te melden dat deze persoon inderdaad langs komt.

Het is fictief: we melden het geplande bezoek niet bij andere usergroups (mag ook niet in het kader van de AVG wetgeving), we controleren het ook niet bij het bestuur. Ook zetten we de bezoeker wel in onze database, een stap die we hier overslaan. Ik zal later stap voor stap zien hoe we dit zouden kunnen implementeren, en dan leg ik ook uit wat ik precies bedoel.

Benodigde functies voor inschrijven bij dotNed

De data komt binnen vanuit de website en zal al deze stappen moeten doorlopen voor we over een geslaagde inschrijving kunnen spreken. Hoe we deze functies bouwen, implementeren en testen is op dit niveau niet belangrijk. Maar je kunt je voorstellen dat niet zo moeilijk is om een module te schrijven die input valideert en dan true of false terug geeft. Je snapt ook dat dat niet afhangt van de stappen daarna. Net zoals het versturen van de tweet een module is die we los en in parallel kunnen bouwen . Het enige wat we moeten afspreken is hoe de data tussen de functies er uit ziet.

Functies met messages

De data die we versturen gaat over een message bus of via http triggers. Onze website heeft de data verzamelt en doet een REST Post naar de eerst functie. Deze valideert de data, en plaats vervolgens een message op de message queue dat er goedgekeurde data beschikbaar is. De volgende stap zal dan de data die relevant is voor deze stap (dus niet alle data!) verzamelen en de andere gebruikersgroepen informeren. Als dat gebeurt is, komt er weer een bericht in de queue wat de volgende stappen aan het werk zet.

Nu is dit natuurlijk een enorm versimplificeerde weergave. Want: wat gebeurt er als de input niet geldig is? Of als het bestuur deze bezoeker afkeurt? En over die goedkeuring van het bestuur gesproken: wat als het bestuur niet tijdig reageert?

Dat is ook wel weer op te lossen. We voegen gewoon wat messages toe, wat extra events maar dit keer op timers gebaseerd (als die afgaat en er is nog geen goedkeuring, wat doen we dan?)

Met meer messages

Je ziet: het aantal messages en controle flows neemt sterk toe. En het is ook niet eenvoudig om dit te bouwen: veel van de structuren hier hebben niets te maken met het primaire proces maar alles met de controles er om heen. Dit kost vaak een hoop tijd en voegt geen echte waarde toe.

No plumbing architecture

Ik ben van mening dat bedrijven geen frameworks moeten bouwen, behalve als ze framework-leverancier zijn. Buy before build, noemen we dat in goed Nederlands. De meeste organisaties zijn niet toebedeeld om dit soort systemen te bouwen en kunnen dat beter inkopen. Plumbing, het werk dat we moeten doen om timers, checks, fault-handling en dat soort dingen te doen, moeten we zo veel mogelijk aan een framework overlaten.

Begrijp me niet verkeerd: ik ben dol op het bouwen van frameworks,tools, utilities en dat soort dingen. Maar dat zijn hobby projecten. Een gemiddelde organisatie zit daar niet op te wachten. Dus laten we voortaan afspreken dat we dat in de tijd van de baas niet meer doen (behalve als iets echt niet voorhanden is…)

We hebben gelukkig de beschikking over een leverancier die die plumbing al voor ons geschreven heeft. Microsoft en de Azure Durable Functions geven ons de kans om de hoeveelheid plumbing enorm naar beneden te krijgen en ons te laten concentreren op dat waar het om gaat: waarde toevoegen. En dat kan verrassend eenvoudig

De volgende stappen

In de volgende artikelen gaan we bovenstaand systeem van scratch af aan opbouwen. We gaan alles vanaf de website (dus niet de site zelf) schrijven. En hoewel we hier en daar wat hoekjes afsnijden in het kader van leesbaarheid, zullen we een volledige functioneel systeem maken. Vanaf nu dus veel code en samples…

Tot dan!

Service Bus Messages en Event Grid / Hub… Het verschil!

Azure Service Bus

Een van de vragen die het meest voorbij zie of hoor komen als we het over messaging in Azure hebben is de volgende:

Leuk, maar wat is nu het verschil tussen een service bus en een event hub?

(iemand, op welke conferentie dan ook)

En dat is eigenlijk een hele goede vraag. Er zijn namelijk wel degelijk verschillen maar die zijn niet heel erg duidelijk als je zou mogen verwachten. Veel mensen worstelen dan ook met deze twee en zien niet duidelijk wanneer ze nu wat moeten gebruiken.

Toch is het niet zo ingewikkeld. Zeker niet als je ontwikkelaar bent of bent geweest. Immers, in de meeste programmeertalen gebruik je deze twee constructies continue alleen noem je ze (bijna) anders.

Ik ga er even vanuit dat je ooit eens een keer een applicatie gebouwd hebt. Stel, we maken een systeem om een hotel boeking te versturen naar een back-end systeem. Denk nu niet aan messages, queues, subscriptions en wat niet meer. Denk even puur aan de front-end. Dat kan web based zijn of een good-old Windows Forms applicatie: dat maakt niet uit.

We hebben een scherm. Dat zie er ongeveer zo uit:

Mijn geweldig booking site!

Ok. User Interface Design is niet mijn sterkste punt. Maar dat maakt even niet uit. We hebben dit soort schermen ontelbare keren eerder gebouwd.

Na het maken van de front-end moeten we de logica gaan inbouwen. Dat betekent:

  1. De gebruiker klikt op “Book it now”
  2. We verzamelen de data uit de 3 textboxen (name, address en city)
  3. We versturen die data naar onze back-end

Simpel niet waar? En dat is het inderdaad ook. Want:

  1. De klik op een button is een event.
  2. De data die we naar de back-end versturen is de Service bus message

Een button genereert heel veel events. De mouse-over, mouse-leave, click, mouse-down, mouse-up, key-press enzovoorts. De bouwer van de button class weet niet waar je die button voor wilt gebruiken, dus je hebt de beschikking over alle mogelijke events. Wat je er mee doet, moet jij als ontvanger zelf weten. Dat zijn events: kleine notifications waar een ontvanger al dan niet iets mee wil gaan doen.

De data die verstuurd wordt door het scherm (onze gegevens) zijn een message. De verzender wil in dit geval wel degelijk dat er iets met die data gebeurt! Er moet immers een boeking gemaakt worden. De data die verstuurd wordt in die message is dan ook meestal groter dan wat er met een event verstuurd wordt. Ook is het zo dat er meestal maar een ontvanger van de message is, terwijl events meerdere ontvangers kunnen hebben.

En meer valt er eigenlijk niet over te zeggen. Messages zijn groter en de verzender weet wat hij van de ontvanger verwacht (er moet een boeking gemaakt worden). Events zijn meer notificaties dat er iets gebeurt is.

Er is natuurlijk veel overlap tussen die begrippen. Het is niet altijd 100% duidelijk wanneer iets nu een message zou moeten zijn of een event. Er is een groot grijs gebied. In dat geval raad ik je aan om je gezonde verstand te gebruiken en consistent in je ontwerp te blijven. Meestal zit je dan wel goed.

Dus… nu weet je het. Events zijn net als mouse-clicks in je systeem, messages zijn operations in je systeem. That’s it.

Welkom!

Een nieuwe site, een nieuw idee.

Delen komt altijd weer bij je terug!

Wat ga ik hier doen? Simpel: kennis delen.

De mensen die mij kennen weten dat het delen van kennis iets is wat ik al jaren met passie doe. Het werk dat we met dotNed doen is daar een mooi voorbeeld van, net als wat we doen met de verschillende conferenties waar ik in de board zit.

Ik geloof er namelijk absoluut in dat kennis een van de weinige dingen is die vermeerdert als je het deelt. Hoe meer je deelt, hoe meer je krijgt.

Dus… dit wordt voor mij de plek om alles wat in mij opkomt te delen met jullie en hopelijk worden we daar allemaal iets beter van!