Au cœur d’Azure avec les Fonctions Durables

Au cœur d’Azure avec les Fonctions Durables

Dans l’écosystème Azure, les fonctions Azure ou « Azure Functions » font référence à la partie serverless des ressources mises à la disposition des architectes ou des développeurs. Elles servent à écrire moins de code et à simplifier la maintenance comme la scalabilité de son infrastructure. Ainsi, les fonctions Azure offrent au développeur d’innombrables possibilités en termes de développement, mais également au niveau de la connectivité avec les autres briques de l’environnement Azure.  

De l’utilisation d’un déclencheur basique en fonction d’un minuteur à une tâche planifiée (ou tâche Cron dans linux), Azure Fonctions s’interconnecte avec du stockage blob ou de l’Azure Cosmos DB, en passant par une gestion évènementielle comme Event Grid ou Iot Hub, voire même du Kafka. Ce ne sont pas moins d’une vingtaine de connecteurs disponibles pour interagir avec des événements, comme générer une API, traiter des flux de données métiers, ou encore déclencher des actions de modifications de base de données.  

Par exemple, vous souhaitez traiter des fichiers issus de flux métier en entrée, puis par la suite, envoyer un message d’information dans votre Event Grid afin de dispatcher l’évènement à différents services ? Pour cela, vous pouvez utiliser un déclencheur sur votre blob storage, faire appel à votre ressource Event Grid et insérer votre code entre le deux :  

Au cœur d’Azure avec les Fonctions Durables - Figure 1

Figure 1 Implémentation d'une fonction azure 

Si vous souhaitez aller plus loin, consultez l’ensemble des déclencheurs et des liaisons

Pour finir sur les fonctions Azure, sachez qu’à l’heure actuelle, les langages pouvant être utilisés sont les suivants : C#, Javascript, F#, Java, Powershell, Python et TypeScript (par transpilation en Javascript). Vous trouverez les renseignements sur les langages supportés en cliquant ici.  

Les fonctions durables dans l’univers des fonctions Azure 

Les fonctions Azure sont de puissants outils pour se simplifier la tâche dans certains cas d’usage. Ils offrent également une simplification de la scalabilité de l’architecture, sans compter la facilité à déployer les fonctions à partir d’outils d’intégration continue comme Github ou Gitlab.  

Toutefois, les fonctions Azure ont des limites dans leur exploitation. Par exemple, par défaut, une fonction Azure a un timeout de 30 minutes. Même si la durée du timeout est configurable jusqu’à l’infini, il réside tout de même une limitation « philosophique » sur la gestion du temps de traitement d’une fonction Azure. En effet, à l’origine, la partie serverless est optimisée pour des traitements courts se basant sur des évènements, afin de délivrer de manière optimale les informations dont l’utilisateur à besoin. 

C’est là qu’entre en jeu les « fonctions durables ». Elles sont tout simplement une extension des fonctions Azure offrant aux développeurs la possibilité de penser en mode workflow et non utilisation unique. Microsoft gère en arrière-plan l’état des processus en cours et le redémarrage des tâches en cas de problème ainsi qu’un framework avec différents types de fonctions. Pour information, les deux principaux types de fonctions sont les fonctions d’orchestration et les fonctions d’activité.  

La fonction d’orchestration 

En s’appuyant sur des fonctions d’orchestration, il est ainsi possible de gérer un ordonnancement de tâche spécifique, de déléguer des traitements ou d’attendre une action utilisateur. Les fonctions d’orchestration sont les cerveaux de la gestion du traitement que le développeur veut mettre en place. En décrivant les actions et l’ordre dans lequel elles doivent être exécutées, il est envisageable de d’ordonnancer des tâches de différentes manières afin de réaliser un workflow complet de validation de facture, par exemple, ou de traitement d’un incident depuis sa détection jusqu’à sa résolution. Par ailleurs, une fonction d’orchestration peut appeler une autre fonction d’orchestration. 

Une fonction d’orchestration se présente comme ceci :  

[FunctionName("ChainingDurableFunction")] 

    public static async Task<List<string>> RunOrchestrator( 

        [OrchestrationTrigger] IDurableOrchestrationContext context) 

    { 

 

        var commandNumber = context.GetInput<int>(); 

 

        var outputs = new List<string>(); 

 

        outputs.Add(await context.CallActivityAsync<string>("VerifyCommand", commandNumber)); 

        outputs.Add(await context.CallActivityAsync<string>("SendEmail", "La commande est accepté")); 

        outputs.Add(await context.CallActivityAsync<string>("PrepareCommand", commandNumber)); 

 

        return outputs; 

    } 

Au-delà du nom, elle prend en paramètre un « contexte d’orchestration » définissant les informations nécessaires au bon déroulement de la fonction en cours ainsi que les méthodes ou fonctionnalités à disposition pour orchestrer les différentes tâches. Dans l’exemple, nous pouvons voir que l’orchestrateur appelle grâce à la méthode « CallActivityAsync » une fonction de type activité. 

La fonction d’activité 

Si la fonction d’orchestration est le cerveau d’un traitement, la fonction d’activité est la base de tous les traitements de la fonction durable. Ce sont les tâches accomplies durant le workflow. Elles ne peuvent être appelées que par la fonction d’orchestration et le développeur a le choix de retourner un résultat afin d’affiner les différentes facettes de son traitement. Concrètement, cela donne : 

[FunctionName("VerifyCommand")] 

    public static bool VerifyCommand([ActivityTrigger] int commandNumber, ILogger log) 

    { 

        log.LogInformation($"Verification Command number : {commandNumber}."); 

 

        //Verification de la commande 

 

        return true; 

    } 

La notion d’orchestration 

Il faut savoir que derrière les fonctions durables, nous retrouvons le framework open source DurableTask que vous pouvez utiliser dans vos applications afin de réaliser les mêmes workflows que sur Azure. Par conséquent même si vous n’utilisez pas les fonctions Azure, vous pouvez tout de même utiliser le framework sur vos serveurs. 

L’orchestration des fonctions durables repose sur une table d’historique. Au moment de démarrer une nouvelle instance d’un traitement du workflow, l’orchestrateur assigne un Id unique sous forme de Guid. Cet Id est autogénéré par le service mais vous pouvez le générer vous-même au besoin, toutefois, cela n’est conseillé que si vous souhaitez réellement avoir un lien entre votre Id et une instance métier comme un bon de commande.  

Afin de gérer au mieux les étapes du workflow, l’orchestrateur s’appuie sur la notion d’évènements, un peu comme nous le ferions en mode « event sourcing » où chaque événement est consigné et pour connaître l’avancement d’un workflow, il suffit à l’orchestrateur de regarder les événements survenus pour une instance donnée. 

Par exemple, dans notre fonction d’orchestration, nous aurons pour chaque étape du workflow de commande, des entrées dans l’historique pour « VerifiyCommand », « SendEmail » et « PrepareCommand ». Pour information, chaque appel à une fonction d’activité a au moins deux événements émis qui correspondent au démarrage et à la complétion de l’activité.  

En cas de coupure, l’orchestrateur sait ainsi où il en est et quelle activité doit-il redémarrer pour achever le workflow.  

Pour plus de détails, n’hésitez pas à regarder dans la documentation.


Le déclenchement d’une fonction durable 

Le déclenchement d’une fonction durable fait appel aux fonctions classiques avec la particularité d’avoir en paramètre l’interface cliente de l’orchestrateur « IDurableOrchestrationClient ». Vous pouvez ainsi lancer le traitement d’une fonction durable à l’aide d’un appel http ou encore à partir d’une heure spécifique :  

[FunctionName("DurableTimerTrigger")] 

        public async Task Run([TimerTrigger("0 */5 * * * *")]TimerInfo myTimer,  

            [DurableClient] IDurableOrchestrationClient starter, 

            ILogger log) 

        { 

            log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}"); 

 

            string instanceId = await starter.StartNewAsync("DelegateFunction", null); 

 

            log.LogInformation($"Started orchestration with ID = '{instanceId}'."); 

        } 

Les différents cas d’usage 

Maintenant, que vous y voyez plus clair sur le fonctionnement des fonctions durables, je vous propose de regarder d’un peu plus près quelques cas d’usage qui m’ont été utiles afin de bâtir différents processus de workflow. Vous verrez, ils s’avèrent complémentaires. 

Cas simple, le chainage 

Le cas le plus simple est le chaînage des fonctions d’activité. Son modèle réside dans l’enchainement des tâches les unes après les autres jusqu’à la fin du traitement. Par exemple, le processus d’une commande comme vue précédemment peut faire partie du chaînage des tâches :  

[FunctionName("DurableFunction")] 

    public static async Task<List<string>> RunOrchestrator( 

        [OrchestrationTrigger] IDurableOrchestrationContext context, int commandNumber) 

    { 

        var outputs = new List<string>(); 

 

        outputs.Add(await context.CallActivityAsync<string>("VerifyCommand", commandNumber)); 

        outputs.Add(await context.CallActivityAsync<string>("SendEmail", "La commande est accepté")); 

        outputs.Add(await context.CallActivityAsync<string>("PrepareCommand", commandNumber)); 

 

        return outputs; 

    } 

 

    [FunctionName("VerifyCommand")] 

    public static bool VerifyCommand([ActivityTrigger] int commandNumber, ILogger log) 

    { 

        log.LogInformation($"Verification Command number : {commandNumber}."); 

 

        //Verification de la commande 

 

        return true; 

    } 

 

    [FunctionName("SendEmail")] 

    public static bool SendEmail([ActivityTrigger] string body, ILogger log) 

    { 

        log.LogInformation($"Send Email : {body}."); 

 

        //Envoi d'un email de confirmation 

 

        return true; 

    } 

 

    [FunctionName("PrepareCommand")] 

    public static bool PrepareCommand([ActivityTrigger] int commandNumber, ILogger log) 

    { 

        log.LogInformation($"Prepare command number: {commandNumber}."); 

 

        //Préparation de la commande 

 

        return true; 

    } 

 

 

 

Nous ne démarrons une tâche que si la précédente est terminée. 

La délégation de traitement 

Le deuxième cas d’usage concerne la délégation d’un traitement. Nous en avons besoin soit parce que le traitement est trop long soit parce que son résultat n’est pas immédiatement souhaité par l’utilisateur. Dans ce cas, un traitement asynchrone est de rigueur et les fonctions durables vous aideraient dans cette tâche. 

Imaginons que nous souhaitons effectuer un diagnostic précis de l’ensemble des sondes IoT d’une maison ou d’un immeuble. Le superviseur de cette tâche lance son diagnostic depuis sa console d’administration. Dans ce cas, l’objectif est de réaliser une tâche asynchrone et d’attendre que celle-ci soit terminée pour afficher le résultat à l’utilisateur. 

Pour cela, il vous faut un déclencheur : 

[FunctionName("DelegateFunction_HttpStart")] 

        public static async Task<HttpResponseMessage> HttpStart( 

            [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestMessage req, 

            [DurableClient] IDurableOrchestrationClient starter, 

            ILogger log) 

        { 

            // Function input comes from the request content. 

            string instanceId = await starter.StartNewAsync("DelegateFunction", null); 

 

            log.LogInformation($"Started orchestration with ID = '{instanceId}'."); 

 

            return starter.CreateCheckStatusResponse(req, instanceId); 

        } 

Ici nous prenons un déclencheur de type http qui appellera l’orchestrateur pour réaliser les diagnostics : 

[FunctionName("DelegateFunction")] 

        public static async Task<List<string>> RunOrchestrator( 

            [OrchestrationTrigger] IDurableOrchestrationContext context) 

        { 

            var outputs = new List<string>(); 

 

            outputs.Add(await context.CallActivityAsync<string>("Diagnostic", "Sensor1")); 

            outputs.Add(await context.CallActivityAsync<string>("Diagnostic", "Sensor2")); 

            outputs.Add(await context.CallActivityAsync<string>("Diagnostic", "Sensor3")); 

 

            return outputs; 

        } 

Chaque diagnostic se fera par l’intermédiaire de la fonction d’activité « Diagnostic ». 

Lorsque que le déclencheur est appelé, le système se met automatiquement en marche et renvoie les éléments suivants dont le développeur peut profiter :  

    "id": "bfd62d52ede6428bb5106548342e0df6", 

    "statusQueryGetUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/bfd62d52ede6428bb5106548342e0df6?taskHub=TestHubName&connection=Storage&code=HbWNFwiTivwtFIHntG043Drw34rmoy81G8hjInqosa21bPLR9lTtXA==", 

    "sendEventPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/bfd62d52ede6428bb5106548342e0df6/raiseEvent/{eventName}?taskHub=TestHubName&connection=Storage&code=HbWNFwiTivwtFIHntG043Drw34rmoy81G8hjInqosa21bPLR9lTtXA==", 

    "terminatePostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/bfd62d52ede6428bb5106548342e0df6/terminate?reason={text}&taskHub=TestHubName&connection=Storage&code=HbWNFwiTivwtFIHntG043Drw34rmoy81G8hjInqosa21bPLR9lTtXA==", 

    "purgeHistoryDeleteUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/bfd62d52ede6428bb5106548342e0df6?taskHub=TestHubName&connection=Storage&code=HbWNFwiTivwtFIHntG043Drw34rmoy81G8hjInqosa21bPLR9lTtXA==", 

    "restartPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/bfd62d52ede6428bb5106548342e0df6/restart?taskHub=TestHubName&connection=Storage&code=HbWNFwiTivwtFIHntG043Drw34rmoy81G8hjInqosa21bPLR9lTtXA==" 

 

Comme indiqué précédemment, un Id d’instance est transmis permettant de faire référence à l’instance du traitement créé. De plus, le framework est livré avec plusieurs Url permettant, entre autres, de connaître le statut, de renvoyer un event, d’annuler le job ou encore de purger le résultat. 

Si nous nous arrêtons sur l’Url de la propriété « statusQueryGetUri » nous pouvons rafraîchir le statut du diagnostic en cours :  

    "name": "DelegateFunction", 

    "instanceId": "bfd62d52ede6428bb5106548342e0df6", 

    "runtimeStatus": "Running", 

    "input": null, 

    "customStatus": null, 

    "output": null, 

    "createdTime": "2022-04-19T15:54:07Z", 

    "lastUpdatedTime": "2022-04-19T16:00:52Z" 

 

Puis en rafraîchissant quelque temps plus tard, le « runtimeStatus » indique que le traitement est « completed » :  

    "name": "DelegateFunction", 

    "instanceId": "416a5afd0ece49939420d2b1193d80d5", 

    "runtimeStatus": "Completed", 

    "input": null, 

    "customStatus": null, 

    "output": [ 

        "Sensor1 OK !", 

        "Sensor2 OK !", 

        "Sensor3 OK !" 

    ], 

    "createdTime": "2022-04-19T16:06:46Z", 

    "lastUpdatedTime": "2022-04-19T16:07:17Z" 

 

Vous remarquerez que les 3 valeurs dans la propriété « output » concernent les 3 retours de la fonction diagnostic. Nous ajoutons donc les éventuels dysfonctionnements du processus en retour de nos méthodes. Toutefois, retenez bien deux choses importantes : la  propriété « output » n’est remplie qu’une fois le processus complet terminé, tandis que l’orchestrateur ne conserve l’historique tant que celui-ci n’est pas purgé par l’utilisateur.  

Dans tous les cas, grâce aux fonctions durables, il est possible de déléguer à une plateforme des traitements plus ou moins longs et récupérer le résultat par la suite.  

 

Parallélisme de traitement 

Dans le cas d’usage précédent, la fonction est exécutée en mode chaînage comme le premier cas d’usage. Toutefois, quand nous y réfléchissons, paralléliser le traitement serait plus judicieux surtout s’il faut diagnostiquer de nombreuses sondes. 

Dans ce cas, le traitement sur la fonction d’orchestration est légèrement différent, mais les développeurs ayant l’habitude de lancer plusieurs « Tasks » en parallèle ne devraient pas être perdus. Ainsi, il nous est nécessaire de changer la manière d’appeler les fonctions d’activité : 

[FunctionName("ParallelDurableFunction")] 

        public static async Task<List<string>> RunOrchestrator( 

            [OrchestrationTrigger] IDurableOrchestrationContext context) 

        { 

 

            var parallelTasks = new List<Task<string>>(); 

 

            for (int i = 1; i <= 3; i++) 

            { 

                Task<string> task = context.CallActivityAsync<string>("ParallelDiagnostic", $"Sensor{i}"); 

                parallelTasks.Add(task); 

            } 

 

            await Task.WhenAll(parallelTasks); 

 

            return parallelTasks.Select(parallelTask => parallelTask.Result).ToList(); 

        } 

Le résultat étant le même et obtenu plus rapidement :  

    "name": "ParallelDurableFunction", 

    "instanceId": "20d542983ba84d7eb2392b0464c630c2", 

    "runtimeStatus": "Completed", 

    "input": null, 

    "customStatus": null, 

    "output": [ 

        "Sensor1 OK !", 

        "Sensor2 OK !", 

        "Sensor3 OK !" 

    ], 

    "createdTime": "2022-04-19T16:57:34Z", 

    "lastUpdatedTime": "2022-04-19T16:57:45Z" 

  

L’interaction humaine 

Enfin le dernier cas d’usage que je souhaitais partager avec vous est sûrement l’un des plus importants car que serait un workflow sans la validation d’un acteur pendant le processus ?  

Les fonctions durables offrent la possibilité de gérer ce type d’événement externe grâce à la méthode « WaitForExternalEvent ».  

En reprenant notre exemple d’une commande, nous pouvons aisément ajouter une validation dans le workflow, par exemple, au moment de la validation de la commande ou encore au moment de la préparation. Pour ce faire, il nous faut modifier notre workflow et assigner un nom faisant référence à l’événement comme « ApprovalCommand » :  

[FunctionName("HumanInteractionDurableFunction")] 

        public static async Task<List<string>> RunOrchestrator( 

            [OrchestrationTrigger] IDurableOrchestrationContext context) 

        { 

            var outputs = new List<string>(); 

 

            var commandNumber = context.GetInput<int>(); 

 

            using (var timeoutCts = new CancellationTokenSource()) 

            { 

                var limitDateTime = context.CurrentUtcDateTime.AddHours(24); 

                var durableTimeout = context.CreateTimer(limitDateTime, timeoutCts.Token); 

 

                var approvalEvent = context.WaitForExternalEvent<bool>("ApprovalCommand"); 

                if (approvalEvent == await Task.WhenAny(approvalEvent, durableTimeout)) 

                { 

                    timeoutCts.Cancel(); 

                    outputs.Add(await context.CallActivityAsync<string>("VerifyCommand", commandNumber)); 

                    outputs.Add(await context.CallActivityAsync<string>("SendEmail", "La commande est accepté")); 

                } 

                else 

                { 

                    outputs.Add(await context.CallActivityAsync<string>("CancelCommand", commandNumber)); 

                    outputs.Add(await context.CallActivityAsync<string>("SendEmail", "La commande est annulé")); 

                } 

            } 

 

             

             

            outputs.Add(await context.CallActivityAsync<string>("PrepareCommand", commandNumber)); 

 

            return outputs; 

        } 

Avec Postman, vous pouvez appeler la fonction durable, grâce à son nom « ApprovalCommand » pour approuver l’événement :  

curl --location --request POST 'http://localhost:7071/runtime/webhooks/durabletask/instances/8e7960063324410bbb0855de3d82ab65/raiseEvent/ ApprovalCommand?taskHub=TestHubName&connection=Storage&code=HbWNFwiTivwtFIHntG043Drw34rmoy81G8hjInqosa21bPLR9lTtXA==' \ 

--header 'Content-Type: application/json' \ 

--data-raw '"true"' 

 

Ainsi, le traitement continue et la commande peut être préparée. Toutefois, dans le cas contraire si dans les 24 heures, comme indiqué dans la variable « limitDateTime », la commande n’est pas approuvée, par conséquent à la fin du « timer » le traitement annulera la commande.  

 

Conclusion et retour d’expérience 

Adepte des fonctions Azure depuis de nombreuses années, la découverte et la mise en pratique des fonctions durables ont eu pour moi un regain d’intérêt. Cela m’a conforté dans l’idée que dans certaines situations, notamment la mise en place de workflow ou encore les traitements longs, les actions peuvent être déléguées à une instance en « background » et ainsi offrir une nouvelle expérience aux utilisateurs sans devoir mettre en place toute une usine logicielle pour autant. Intégrées totalement aux Fonctions Azures, les fonctions Durables n’en sont que plus facilement déployables au travers d’une chaîne d’intégration continue. 

En un minimum de code, nous avons mis en place des traitements chaînés et grâce aux fonctions durables et à la parallélisation, nous avons réduit le temps de traitement de certaines tâches. Enfin, la délégation de traitement offre à nos clients la possibilité de demander un export de certaines données et être prévenu par mail lorsque que le traitement est terminé. Un atout non-négligeable ! 

Vous souhaitez parler avec un expert ?

Contactez-nous