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 :
- La doc des extensions nous dit d'utiliser la méthode AddExtension sur un IWebJobBuilder.
- La doc Azure Function nous dit d'utiliser comme classe de démarrage (Startup) un dérivé de FunctionStartup qui expose un IFunctionBuilder et pas non pas un IWebJobBuilder...
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 !
Commentaires