Loupe

Azure Functions : créer votre propre binding de paramètres

Les Azure Functions peuvent être déclenchées de pleins de manières possibles : http, message service bus, modifications blob storage, etc. Pour chacune de ces manières il est intéressant de récupérer les paramètre à l'origine du déclenchement de l'exécution et le lien qui permet de créer ces paramètres est un Binding. Dans cet article nous verrons comment en créer un nouveau et pourquoi c'est intéressant.

Un exemple avec ServiceBus

Afin de mieux cerner ce que l'on essaye ici de faire, prenons un exemple. Lorsque l'on branche une AzureFunction sur ServiceBus, on va déclarer une méthode avec le message ServiceBus en paramètre et l'attribut ServiceBusTrigger apposé dessus :

[FunctionName (AzureFunctionName)]
public async Task Run (
    [ServiceBusTrigger (
        "NomduTopic"
        "NomDeLaSouscription",
        Connection = "connection_string")] Message message,
    ILogger logger) {
}

L'attribut ServiceBus est justement un Binding qui va transformer les données qu'il reçoit du runtime AzureFunction en un objet Message.  Il n'y a rien de magique et le code source est disponible sur GitHub

Nous allons donc essayer de faire notre propre Binding !

Comment faire ?

Pour mettre en place tout cela il faut créer ce que l'on appelle une extension. Ce n'est pas très évident et je me suis donc fortement inspiré du code source des extensions de Microsoft (ServiceBus, CosmosDb, etc.).

La première des choses (et la plus simple) et de déclarer un attribut portant lui même l'attribut "Binding". Ce dernier fait partie du SDK WebJob et c'est normal car tout le système d'Azure Function repose sur des WebJobs : 

[Binding]
[AttributeUsage(AttributeTargets.Parameter,)]
public class MonBindingAttribute : Attribute
{
}

Il faut ensuite créer une classe capable de représenter techniquement notre Binding en implémentant l'interface IBinding. C'est ce code qui va vraiment créer la valeur à retourner en lisant les paramètres que la fonction reçoit. La valeur est retournée sous la forme d'un objet implémentant IValueProvider que j'ai ici implémenté dans une classe interne et qui se content de contenir la valeur calculée dans la méthode BindAsync. Les paramètres reçus sont un dictionnaire "string / object" que je décide de retourner tel quel dans l'exemple ci-dessous mais on pourrait l'interpréter pour aller lire une valeur particulière.

public class MonBinding : IBinding {

  public MonBinding () { }

  public ParameterDescriptor ToParameterDescriptor ()
     => new ParameterDescriptor ();

  public bool FromAttribute => true;

  public Task<IValueProvider> BindAsync (object v, ValueBindingContext c) =>
    Task.FromResult((IValueProvider) new MonBindingValueProvider (v));

  public Task<IValueProvider> BindAsync (BindingContext context) {
    return BindAsync (context.BindingData, context.ValueContext);
  }

  private class MonBindingValueProvider : IValueProvider {
    private readonly object _value;

    public WorkflowSchedulerValueProvider (object value) 
      => _value = value;

    public Type Type => _value.GetType ();

    public Task<object> GetValueAsync () => Task.FromResult (_value);

    public string ToInvokeString () => _value?.ToString ();
  }
}

Si par exemple, je veux aller lire le contenu d'un message ServiceBus, je peux faire cela de cette manière : 

var mesageRaw = (byte[]) context.BindingData
  .First (kvp => kvp.Key == "Body");

var messageServiceBus = new Message (mesageRaw);

Il faut maintenant créer une classe chargée de fournir une instance de Binding si nécessaire en implémentant l'interface IBindingProvider. Rien de spécial ici, il suffit de l'instancier. C'est aussi le bon endroit pour passer des paramètres à votre binding si nécessaire.

public class MonBindingProvider : IBindingProvider
{
  public Task<IBinding> TryCreateAsync(BindingProviderContext context)
    => Task.FromResult<IBinding>(new MonBinding());
}

Finalement on va créer une classe capable de déclarer une extension et d'enregistrer notre Binding. Il faut pour cela implémenter l'interface IExtensionConfigProvider et sa méthode Initialize. Dans l'exemple ci-dessous je vous montre comment on peut se faire injecter des services (ici le ServiceProvider) et les passer en paramètre au BindingProvider qui pourra lui même les passer aux Bindings si nécessaire.

public class MonExtensionConfigProvider 
   : IExtensionConfigProvider {
  private readonly IServiceProvider _serviceProvider;

  public MonExtensionConfigProvider (IServiceProvider sp) {
    _serviceProvider = sp;
  }

  public void Initialize (ExtensionConfigContext context) {
    context
      .AddBindingRule<MonAttribute> ()
      .Bind (new MonBindingProvider (_serviceProvider));
  }
}

Enregistrement de l'extension

Il reste maintenant à enregistrer notre extension et un conflit de documentation se présente à nous :

L'impasse dans laquelle on se trouve est résolue en regardant le code source de FunctionStartup pour en reproduire le comportement. Ainsi, il faudra que notre classe de Startup implémente l'interface IWebJobsStartup et on se passera d'hériter de FunctionStartup. Le tour est alors joué pour pouvoir enregistrer notre extension : 

public abstract class MonStartup
  : IWebJobsStartup
{
  public void Configure (IWebJobsBuilder builder) {
    builder.AddExtension<MonExtensionConfigProvider> ();
  }
}

Pourquoi on veut faire cela ?

Le principal intérêt est de comprendre comment fonctionne sous le capot les différents mécanismes que l'on mets en oeuvre en créant des Azure Functions mais cela permet aussi d'exposer des comportements / objets dépendant des paramètres d'entrée de votre fonction de manière centralisée.

Plus concrètement j'ai eu dernièrement un cas de figure de mise en oeuvre de Binding très intéressant. J'injecte dans le conteneur de dépendances de mon Azure Function un service Scoped et j'en ai donc une instance par exécution de mon AzureFunction. Ce service est ainsi présent dans le constructeur de ma Fonction et utilisé lors de son exécution : 

public class MaFunction {
  private readonly IMonServiceScoped _monService;
  public ProcessorFunction (
    IMonServiceScoped monService) {
    _monService = monService;
  }

  [FunctionName ("MaFonction")]
  public async Task Run (
    [ServiceBusTrigger (
      "NomduTopic"
      "NomDeLaSouscription",
      Connection = "connection_string")] Message message,
    ILogger logger) {

    _monService.Coucou ();

  }
}

Mon seul problème c'est que pour initialiser mon service j'ai besoin d'une valeur présente dans le message déclenchant ma fonction...  Cette valeur doit être renseignée sur un service IContexteDExecution spécifique à mon application. Sur une API classique j'aurais pu utiliser le service IHttpContextAccessor pour aller lire les informations dans ma requête HTTP mais ce n'est pas possible ici...

 services.AddScoped<IMonServiceScoped> (
     provider => {
         var uneValeur = provider
             .GetService<IContexteDExecution> ()
             .UneValeur;

         return new MonServiceScoped (uneValeur);
     });

 

Je suis un peu coincé car le message n'est disponible qu'après que la fonction soit créée et donc que le service ne soit créé. J'ai pu répondre à cette problématique vi a un Binding qui me créée le service après avoir lu les informations nécessaires dans le message ServiceBus reçu. Concrètement, le service provider est injecté dans mon Binding, je l'utilise pour renseigner une information issue du message ServiceBus dans IContexteDExecution et je peux alors demander la création de mon service via le Service Provider. Ma méthode BindAsync ressemble alors à ceci : 

var contexte = _serviceProvider
 .GetRequiredService<IContexteDExecution>();

var json = Encoding.UTF8.GetString (mesageRaw);
var parsed = JsonConvert.DeserializeObject<TMonObject> (json);


contexte.UneValeur = parsed.UneValeur;

return _serviceProvider
 .GetRequiredService<IMonServiceScoped>();

Finalement, il ne me reste plus qu'à utiliser mon attribut et changer la déclaration de ma fonction pour ceci : 

public class MaFunction {
  public ProcessorFunction () { }

  [FunctionName ("MaFonction")]
  public async Task Run (
    [ServiceBusTrigger (
      "NomduTopic"
      "NomDeLaSouscription",
      Connection = "connection_string")] Message message, 
      [MonBinding] IMonServiceScoped monService, 
      ILogger logger) {

    monService.Coucou ();

  }
}

Si vous voulez creuser un peu plus le sujet, n'hésitez pas à lire les autres articles du blog !

Happy coding !

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus