Cet article ouvre une série sur les Azure Functions. Certains parmi vous vont penser « encore un article sur les Azure Functions! ». Certes oui, mais nous avons jugé utile de faire le tour de ce service pour montrer les bénéfices que vous pouvez en tirer mais également les pièges à éviter.

Nous n’oublions pas les articles déjà publiés sur ce sujet dont nous mettons la liste en référence.

L’objet de cet article est de faire un résumé synthétique des capacités et caractéristiques des Azure Functions. C’est parti !

C’est quoi une Azure Function ?

Il y aurait de nombreuses façons de répondre à cette question. Aussi, nous choisissons de commencer en nous mettant à la place d’un développeur, et après tout, c’est notre ADN chez SoftFluent.

Techniquement parlant, une Azure Function est une méthode de classe (1) qui va être appelée, ou « déclenchée » (2), à partir d’un événement défini par déclaration (3).

Bon. Voilà une phrase très courte mais qui en dit déjà beaucoup. Décryptage.

Méthode de classe (1)

Cela signifie qu’il faut créer une classe, et une méthode. Oui, mais alors qu’est-ce qui en fait une « Function » ? C’est une bonne question dont les réponses sont apportées dans la suite de cet article. Mais disons que la réponse courte serait de dire qu’une Function est une méthode caractérisée par les attributs que l’on va lui attacher pour définir son comportement. On placera des attributs sur la méthode et ses paramètres. Ce sera encore plus clair en poursuivant l’explication.

Méthode « déclenchée » (2)

Le terme déclenchée vient du terme anglais trigger qui signifie déclencheur. La méthode sera ainsi appelée à partir d’un événement extérieur, souvent asynchrone (le terme asynchrone ici est important). Par exemple, un message dans une file d’attente (Queue) ou une Rubrique (Topic) de Service Bus sont deux exemples de déclencheurs (triggers). Il existe plusieurs type de déclencheurs, qui par ailleurs peuvent également être des Liaisons (Bindings).

Hé ! attendez une minute. Vous parlez maintenant de liaisons, c’est quoi ça ?

Pas de panique, finissons le décryptage de notre phrase et nous y reviendrons rapidement.

Evénément défini déclarativement (3)

C’est là une des magies des Azure Functions, vous n’avez plus besoin de code pour capturer les messages qui vont vous appeler, recevoir ou envoyer des données de et vers d’autres services Azure. Il suffit de dire à une Function « abonne toi aux messages qui viennent de l’abonnement (topic) « X » du Service Bus « Y », et voilà, sans rien faire de plus, avec zéro code, vous recevrez les messages dans un paramètre de la méthode.

Voici un exemple de méthode de Function.

01: public class SendMail
02: {
03:     private readonly ILogger _logger;
04: 
05:     public SendMail(ILoggerFactory loggerFactory)
06:     {
07:         _logger = loggerFactory.CreateLogger<SendMail>();
08:     }
09: 
10:     [Function("SendMail")]
11:     public HttpResponseData Run([HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
12:     {
13:         _logger.LogInformation("Starts sending mail");
14: 
15:         var response = req.CreateResponse(HttpStatusCode.OK);
16:         response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
17: 
18:         response.WriteString("Welcome to Azure Functions!");
19: 
20:         return response;
21:     }
22: }

Dans cet exemple,

  • Ligne 1 : c’est une classe ordinaire qui est déclarée
  • Ligne 7 : on recoit en injection de dépendance une classe de logging. Heureusement, les Azure Functions supportent les injections de dépendances. Vous pourrez donc déclarer des types de classes à injecter, pour accéder au stockage Azure ou à des bases de données.
  • ligne 10 : l’attribut [Function"SendMail")], c’est lui qui fera que votre méthode est une Function (et non qu’une simple méthode).
  • ligne 11 : l’attribut [HttpTrigger] définit le déclencheur que votre Function va capturer. Ici, c’est une requête POST HTTP qui fera le travail. Le paramètre req recevra les données à traiter dans le corps (body) du message. Dans le cas d’une requête GET, ce même paramètre contiendra les paramètres de l’url.

Pour un déclencheur de type Rubrique (Topic) de Service Bus, on aurait:

1: [Function("SBTopic")]
2: public void Run([ServiceBusTrigger("TopicName", "SubscriptionName", Connection = "ServiceBusConnectionString")] ServiceBusReceivedMessage message)
3: {
4:     _logger.LogInformation("Message ID: {id}", message.MessageId);
5:     _logger.LogInformation("Message Body: {body}", message.Body);
6:     _logger.LogInformation("Message Content-Type: {contentType}", message.ContentType);
7: }
  • Ligne 1: on a toujours la déclaration de la méthode en Function.
  • Ligne 2: cette fois nous avons un déclencheur ServiceBusTrigger qui a besoin d’un nom de Rubrique TopicName, d’un nom d’abonnement SubscriptionName et d’une chaîne de connexion ServiceBusConnectionString. Le message à traiter se trouvera dans le paramètre message. Le code vous donne une idée de la façon de lire le message.

Vous voyez comme c’est simple ? Vous déclarez, vous codez. Et voilà !

Et les fameuses « Liaisons« , c’est quoi alors ? Vous avez raison, venons-en au fait.

Les Liaisons (Bindings)

Non, ce n’est pas une histoire d’amour, quoi que… c’est une connexion implicitement déclarée entre votre Function (parlons de Function dorénavant plutôt que de méthode), et une autre ressource dans Azure. En anglais, on utilise le mot « Bindings » pour parler des liaisons. Une liaison reçoit des données entrantes, on parlera de liaisons d’entrée (input bindings), ou de liaisons de sortie (output bindings) pour des données sortantes.

Ces données entrantes et/ou sortantes vous sont envoyées automatiquement sans que vous ayez besoin de coder une seule ligne de code. Par exemple, vous pouvez recevoir des données d’un compte de stockage Azure, les transformer dans votre Function et les envoyer dans une file d’attente Service Bus.

Voici un exemple de code qui exploite des liaisons.

01: [FunctionName("TimerFunction")]
02: [ServiceBusOutput("outputQueue", Connection = "ServiceBusConnection")]
03: public static void Run(
04:     [TimerTrigger("0 */5 * * * *")] TimerInfo timerInfo,
05:     [BlobInput("inputContainer/inputBlob")] Stream inputBlob,
06:     ILogger log)
07: {
08:     // Read data from the input blob
09:     using (StreamReader reader = new StreamReader(inputBlob))
10:     {
11:         string data = await reader.ReadToEndAsync();
12: 
13:         // Process the data
14: 
15:         // Send processed data to the output queue
16:         return data;
17:     }
18: }
  • Ligne 1: classiquement, on déclare la Function
  • Ligne 2: ceci est nouveau, on déclare le type de sortie de la fonction. Le message de retour sera envoyé dans une file d’attente du Service Bus. La connexion au Service Bus est déclarée dans la chaîne de connexion. Vous noterez que c’est bien un attribut de méthode et non de paramètre pour déclarer ce type de liaison.
  • Ligne 4: on déclenche la Function toutes les 5 minutes avec une expression Cron grâce à un déclencheur de minuteur (TimerTrigger).
  • Ligne 5: C’est nouveau également. On lit la valeur du blob passée en paramètre qui sera traitée dans la Function, puis envoyée en sortie au Service Bus.

Pour réaliser ce traitement, vous noterez que vous n’avez pas à vous soucier de la plomberie. C’est à dire ouvrir les connexions vers les services externes, lire le blob en entrée, écrire le message en sortie. Non, c’est le Function runtime qui s’en charge pour vous. Votre seul responsabilité est d’écrire ce qui se passe dans la Function, c’est à dire votre code métier !

Voilà ce que sont les liaisons. Et vous imaginez que si vous envoyez un message dans une file d’attente elle-même traitée par une autre Function, vous pouvez chainer les Functions entre elles et créer des flux de traitements (workflows) automatiques.

Finalement, c’est moins sentimental que ce que l’on pourrait penser à priori, mais plus utile dans notre contexte :-).

Modèle Sans Etat (Stateless)

Une Function ne conserve aucun état (stateless). Tout se passe en mémoire et disparait en se volatilisant à la fin. Autrement dit, toutes les variables et données que l’on vous donne en entrée dans les paramètres ne sont pas stockées. Idem pour les données que vous pouvez par exemple, lire par vous-même dans une base de données. Certes, elles sont dans votre base, mais en cas de multiple appels à la Function, sauf à les lire à nouveau dans la base, vous ne les retrouvez pas dans le contexte d’exécution suivant.

Si vous souhaitez conserver des données entre deux exécutions de la Function, vous aurez donc la responsabilité de les persister par vous-même. Vous pourrez utiliser les techniques habituelles telles qu’une base de données (relationnelle ou NoSQL), un stockage Azure, ou des API, mais vous devrez coder ces opérations.

Pas totalement, car en fait les Azure Functions ont des extensions magiques dénommées Durable Functions et fonctions d’entités (Entities functions ou Durable Entities) qui vous permettent de déléguer la gestion d’un état (state) par le runtime. Mais n’allons pas plus vite que la musique, ce sujet qui décrit une utilisation avancée des Functions fera partie d’un article dédié. Restons sur l’idée que les Functions ordinaires sont sans état.

IDE et autres Outils

Pour développer des Functions en .Net vous utiliserez Visual Studio. Pour les autres langages, vous utilisez Visual Studio Code avec l’extension Azure Functions.

Extension Azure Functions pour Visual Studio Code

Extension Azure Functions pour Visual Studio Code

Indépendamment de l’outil IDE retenu, il faut installer les Azure Functions Core Tools. Avec cet outil, le runtime exécutera les Functions localement pour les tester. Sachez que le code du runtime est strictement le même qu’il s’exécute localement ou dans Azure.

Azure Functions Core Tools
Core Tools Version:       4.0.5455 Commit hash: N/A  (64-bit)
Function Runtime Version: 4.27.5.21554

Modèle de travail, plan d’hébergement et configuration

Function runtime

Il existe deux versions du Function runtime.

La version 1.x dont l’unique intérêt est de pouvoir exécuter des Function sur le .Net Framework 4.8. Autant dire qu’en 2024 (et pour le futur), ce runtime n’existe que par souci de comptabilité ascendante avec du code hérité de développements anciens non encore migrés vers .Net. Pour les retardataires, sachez que le support de cette version prendra fin le 14 septembre 2026. Vous avez encore un peu de temps, mais ne trainez pas trop pour migrer votre existant.

La version 4.x qui est la version recommandée pour tous les langages. C’est celle qu’il faut utiliser. Pas de date de fin de support à ce jour.

Le modèle de travail (worker model)

La Function App s’exécute dans un modèle de travail (ou d’exécution) isolé ou in-process. De la même façon que l’on ne vit pas (que) d’amour et d’eau fraîche, une Azure Function ne s’exécute pas au milieu de rien. Il y a bien un processus qui la propulse. Revue de détail.

Modèle Isolé (Isolated Worker Model)

Tout d’abord, et c’est important, c’est un modèle réservé exclusivement à .Net ou .Net Framework, ce qui en fait le langage préféré pour les Azure Functions, même si comme indiqué précédemment, d’autres langages sont supportés.

Dans ce modèle, la Function App exécute les Functions dans un processus entièrement isolé du processus d’exécution de l’hôte sur laquelle elle se trouve déployée. Cela ouvre alors un ensemble de bénéfices parmi lesquels:

  • La version .Net des Functions peut-être différente de celle de l’hôte. Cela permet d’utiliser des versions non-LTS de .Net (.Net 7.0 par exemple), ce qui n’est pas possible sinon. Sans cela, c’est .Net 6 LTS, ou rien.
  • L’ajout des middlewares au moment du démarrage (Startup)
  • Le contrôle complet du démarrage de l’application et des composants d’injection de dépendance
  • Cela permet d’éviter des conflits d’Assemblies avec l’hôte puisque les Functions s’exécutent dans un processus à part.
  • L’utilisation des CancellationToken dans les Functions comme le montre l’exemple suivant.
[Function(nameof(ThrowOnCancellation))]
public async Task ThrowOnCancellation(
    [EventHubTrigger("sample-workitem-1", Connection = "EventHubConnection")] string[] messages,
    FunctionContext context,
    CancellationToken cancellationToken)
{
    _logger.LogInformation("C# EventHub {functionName} trigger function processing a request.", nameof(ThrowOnCancellation));
    foreach (var message in messages)
    {
        cancellationToken.ThrowIfCancellationRequested();
        await Task.Delay(6000); // task delay to simulate message processing
        _logger.LogInformation("Message '{msg}' was processed.", message);
    }
}
  • Un logger est passé dans la classe FunctionContext pour écrire des logs sans avoir à l’injecter (même si vous pouvez le faire aussi).

Vous obtiendrez tous les détails avec ce lien, et notamment le tableau des versions de .Net compatibles par version de Function runtime.

Modèle In-process

Dans ce modèle, la Function s’exécute dans le même process que son hôte. L’hôte étant la Web application sur laquelle la Function est déployée.

On peut utiliser tous les langages supportés dans ce modèle, incluant .Net naturellement. Dans le cas de .Net, seul la version .Net 6 LTS est supportée à ce jour.

Plan et modèle d’hébergement (Hosting Model)

Naturellement, les Azure Functions s’exécutent dans un contexte de modèle d’hébergement (Hosting Model) donné. Le contexte d’exécution est porté par une Function App qui peut contenir plusieurs Functions. Chaque Function est une méthode dans une classe, et une classe peut contenir plusieurs Functions.

Les Function Apps s’exécutent avec différents plan d’exécution:

  • Plan de consommation (Consumption Plan): dans ce plan, il n’y a pas de besoin de plan App Service (App Service Plan). La consommation (et donc le prix) est calculée à l’usage. Tant que la Function ne s’exécute pas les compteurs de facturation ne se déclenchent pas. Le nombre d’exécutions, sa durée et la consommation mémoire sont utilisés pour facturer.
  • Plan Premium (Premium Plan): ce plan nécessite un plan App Service et apporte de nombreux avantages.
  • Plan dédié (Dedicated Plan): ce plan apparenté à l’ASE (cf ci-dessous) procure une plateforme dédiée avec un niveau de disponibilité elevée. Elle est en revanche couteuse et complexe à mettre en place. Elle est finalement très peu uilisée et n’est pas celle que nous recommandons.

Les modèles d’hébergement sont les suivants:

  • ASE (App Service Environment): service dédié et réservé. Les ressources serveurs sous-jacentes sont exclusives au client sans collocation avec d’autres.
  • ACA (Azure Container Apps): service d’hébergement pour microservices en tant que conteneurs (Containers). Il est fondé sur Kubernetes mais sans en avoir la complexité car exposé comme serveur de plateforme et non de machines.
  • AKS (Azure Kubernetes Service): service d’orchestration d’application dans Azure donnant accès à toutes ses fonctions, et permettant d’y déployer des Azure Functions.

La configuration

On ne va pas y échapper. On a du code donc on a de la configuration.

Le runtime des Azure Functions se configure grâce au fichier host.json qui se trouve dans la racine du dossier de la Function App. L’image ci-dessous montre un exemple de son emplacement dans un projet sous Visual Studio.

Fichier de configuration d'une Function App

Fichier de configuration d’une Function App

Le fichier host.json contient les paramètres d’exécution des Functions localement ou dans Azure. Voici quelques exemples de paramétrage typique à faire.

Niveau de Log

Vous pouvez configurer les niveaux de log (Information, Warning, etc), suivant le composant exécuté par le runtime.

Dans l’exemple ci-dessous,

  • le log par défaut est de niveau Warning. Sauf indication contraire, les messages de ce niveau et supérieur seront loggués.
  • L’aggrégateur de log de l’hôte va logguer les Trace et supérieur. Cet aggrégateur logue des indicateurs (nombre d’exécution, taux de succès, et durée), dans la table customMetrics dans Application Insights.
  • Host.Results loggue le niveau Information et supérieur. Il logue les succès et échec des Functions.
  • Function logue le niveau Information et supérieur. Ce paramètre référence les logs personnalisés produits explicitement par votre code.
  • Vous pouvez définir un niveau de log spécifique pour une fonction en spécifiant Function.<nom_de_function>: <log_level>. Dans l’exemple ci-dessous, on logue au niveau Warning pour toutes les Functions sauf pour la Function Function1 où on logue au niveau Information.
{
  "logging": {
    "fileLoggingMode": "debugOnly",
    "logLevel": {
      "default": "Warning",
      "Host.Aggregator": "Trace",
      "Host.Results": "Information",
      "Function": "Warning",
      "Function.Function1": "Information"
    }
  }
}

Application Insights

Vous pouvez définir des paramètres pour Application Insights tels que le taux d’échantillonnage de collecte des log, des indicateurs de temps pour les ramassage des messages, etc

{
    "version": "2.0",
    "logging": {
        "applicationInsights": {
            "samplingSettings": {
                "isEnabled": true,
                "excludedTypes": "Request;Exception"
            },
            "enableLiveMetricsFilters": true
        }
    }
}

Paramètres par type de liaisons (bindings)

Chaque type de liaisons peut être configuré spécifiquement dans host.json. Par exemple, le comportement du déclencheur/liaison servicebus peut être configuré comme ci-dessous.

{
    "version": "2.0",
    "extensions": {
        "serviceBus": {
            "clientRetryOptions":{
                "mode": "exponential",
                "tryTimeout": "00:01:00",
                "delay": "00:00:00.80",
                "maxDelay": "00:01:00",
                "maxRetries": 3
            },
            "prefetchCount": 0,
            "transportType": "amqpWebSockets",
            "webProxy": "https://proxyserver:8080",
            "autoCompleteMessages": true,
            "maxAutoLockRenewalDuration": "00:05:00",
            "maxConcurrentCalls": 16,
            "maxConcurrentSessions": 8,
            "maxMessageBatchSize": 1000,
            "minMessageBatchSize": 1,
            "maxBatchWaitTime": "00:00:30",
            "sessionIdleTimeout": "00:01:00",
            "enableCrossEntityTransactions": false
        }
    }
}

Référez-vous au lien en début de ce chapitre pour avoir la référence complète des paramètres possibles dans host.json.

C# .Net, oui, mais pas que…

Vous n’êtes pas un développeur C# .Net ? Pas de problème, car heureusement, ce n’est pas l’unique langage supporté par les Azure Functions.

Les principaux langages du marché sont supportés: Java, Javascript, Typescript, Python, Powershell, Go, Rust.

Voici un aperçu de Functions dans d’autres langages.

En Python:

import azure.functions as func
def main(req: func.HttpRequest) -> func.HttpResponse:
    logging.info('Python HTTP trigger function processed a request.')
    try:
        validation_header = req.headers.get("aeg-event-type")
        if validation_header is not None:
            body_as_json = req.get_json()
            if validation_header == "SubscriptionValidation":
                body_as_json = req.get_json()
                validation_code = body_as_json[0]["data"]["validationCode"]
                return func.HttpResponse(
                json.dumps({
                    "validationResponse": validation_code
                }),
                status_code=200)
... # code volontairement tronqué pour des raisons de brièveté
    except Exception as err:
        pass
    return func.HttpResponse("OK",
            status_code=200)

Et en Powershell:

using namespace System.Net
# Input bindings are passed in via param block.
param($Request, $TriggerMetadata)
$isRequiredStr = "is required"
#Interact with the body of the request.
$userName = $Request.Query.userName
$errors = [System.Collections.ArrayList]::new()
if ([string]::IsNullOrEmpty($userName)) {
    $errorItem = [pscustomobject]@{fieldName = 'UserName'; message = "UserName $isRequiredStr" }
    [void]$errors.Add($errorItem)
}
if ($errors.Count -ne 0) {
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
            headers    = @{'content-type' = 'application\json' }
            StatusCode = [HttpStatusCode]::BadRequest
            Body       = $errors
        })
    return;
}
... # code volontairement tronqué pour des raisons de brièveté

Surtout ne vous privez pas. Les SDK sont 100% disponibles dans ces langages.

Oui, c’est intéressant, mais comment sont configurés les liaisons et les déclencheurs car on ne voit pas d’attributs comme en .Net ?

C’est une très bonne question dont voici la réponse :

La configuration des metadonnées pour les langages de script type Python ou Powershell se fait dans un fichier function.json situé dans le même dossier.

Voici un exemple :

{
  "bindings": [
    {
      "authLevel": "function",
      "type": "httpTrigger",
      "direction": "in",
      "name": "Request",
      "route": "applications",
      "methods": [
        "post"
      ]
    },
    {
      "type": "http",
      "direction": "out",
      "name": "Response"
    }
  ]
}

Vous trouverez dans ce lien des exemples de configuration du fichier function.json

Les scénarios pertinents pour les Azure Functions ?

Nous en savons un peu plus sur les Azure Functions, mais une question importante demeure peut-être en suspend dans votre esprit: dans quel situation ou scénario utiliser des Azure Functions ? A quoi peuvent-elles me servir ? Pourquoi faire une API puisque je peux aussi les appeler avec des requêtes HTTP ?

La réponse courte est: asynchrone, asynchrone, et encore asynchrone !

Les Azure Function entrent dans le champs des traitements asynchrones (la seule exception est le HttpTrigger qui est un déclencheur synchrone). Les traitements et processus asynchrones représentent une pierre angulaire des bonnes pratiques dans un Cloud. C’est un moyen efficace pour :

  • Obtenir de la robustesse: en passant par des mécanismes de file d’attente on ne perd pas les messages qui transitent entre les services.
  • Assurer la montée en charge: parce que les services qui agissent comme émetteurs de message n’ont pas à attendre un retour immédiat d’un message, après l’avoir envoyé dans une file d’attente, ils ne sont pas bloqués, ce qui aide à la performance de l’application.
  • Faciliter la tracabilité: les messages qui transitent entre services peuvent être facilités suivis et tracés.
  • Déclencher les traitements de type batch: dans une certaine limite, des traitements de masse sur de grandes quantités de données, à des heures creuses ou la nuit, peuvent être déclenchés dans des Azure Functions.

Exemples d’usage des Azure Functions.

  • Envoi de mails et SMS. En recevant le contenus par des Rubriques (Topic) ou des files d’attente les Functions peuvent construire un mail ou SMS pour l’envoyer à l’aide d’un service tiers (MailJet, Twillio, SendGrid, etc)
    Envoi de SMS

    Envoi de SMS

  • Traitement des messages IoT (Internet Of Things)
    Traitement de messages IoT

    Traitement de messages IoT

  • Appels inter-Microservices
  • Génération de documents ou de rapports en mode batch (pdf, etc)
  • Téléchargements de fichiers (Upload/Download)
  • Workflow automatique ou avec interaction humaine
  • etc…

Arrêtons-nous là, sinon les exemples seraient infinis.

Les Azure Functions, oui, mais pas pour tout

Voici deux exemples d’usage pour lesquels les Azure Functions ne sont pas adaptées.

  • Pour exposer des API complexes, les Azure Functions ne sont pas une bonne approche. Certaines fonctionnalités « standards » (ou qui devraient l’être…) sont moins faciles à implémenter (OAuth, OIDC, Swagger, etc) et, de fait, l’intégration avec des systèmes de gestion d’API (API Management) est plus compliquée.
  • Réaliser des grilles de calculs de masse de type HPC (High Performance Computing)

En résumé

En synthèse voici ce que l’on sait maintenant:

  • Une Azure Function est, au départ, une simple méthode mais enrichie d’attributs qui lui donne la possibilité de s’exécuter quand des événements extérieurs surviennent (messages dans des files d’attente, minuteur, changement dans un stockage, et autres).
  • Une Function peut recevoir des données en entrée et envoyer des données en sortie afin de s’intégrer avec d’autres services Azure.
  • Les attributs de méthode qui configurent la Function sont appelés des déclencheurs (triggers) ou des liaisons (bindings). Ils ne s’opposent pas mais, au contraire, sont complémentaires et peuvent s’utiliser conjointement.
  • Par défaut, une Function est sans état (stateless)
  • Une Function supporte de multiples languages
  • Une Function supporte plusieurs modèles d’hébergement et de plan d’exécution

Les Azure Function sont souples et simples à développer, elles sont protéiformes, et s’adaptent à beaucoup de scénarios. Elles sont d’une certaine manière le « couteau suisse » du Cloud, tout en restant censé dans ce à quoi on les destine.

Si vous pensez que les Azure Functions peuvent vous aider dans la réalisation de votre projet et que vous souhaitez être accompagné pour les déployer du point de vue infrastructure ou applicatif, n’hésitez pas à nous contacter.

Autour du même sujet

Vous trouverez ci-dessous les liens vers d’autres articles sur les Azure Functions.

Ne ratez plus aucune actualité avec la newsletter mensuelle de SoftFluent

Newsletter SoftFluent