Azure Functions – Optionale Parameter und eigene Erweiterungen

2. Juli 2019

image244_thumb3_thumbAzure Functions erlauben mehr Parameter, als nur Trigger, Input- und Output-Bindings. Tatsächlich lässt sich mit Bindings weit mehr anstellen.
Der letzte Beitrag hat sich mit Triggern und Input- bzw. Output-Bindings beschäftigt, also mit der Frage, wie eine Function Daten mit der Außenwelt austauscht. Die Laufzeitumgebung stellt jedoch noch ein paar mehr Möglichkeiten zur Verfügung – allerdings ohne darauf besonders hinzuweisen.

Optionale Parameter

Die Laufzeitumgebung unterstützt einige optionale Parameter, um die man seine Parameterliste ergänzen kann. Diese werden über den Typ identifiziert:

  • ILogger (früher TraceWriter) für Logging
  • CancellationToken für vorzeitige Abbrüche
  • ExecutionContext für Informationen über die Umgebung und die derzeitige Ausführung (z.B. eine ID)

 

Die ersten beiden dürften selbsterklärend sein, besonders da eine neu erzeugte Function in der Regel über einen Parameter vom Typ ILogger und einen beispielhaften Aufruf verfügt. Der Parameter von Typ ExecutionContext ist weniger offensichtlich, liefert aber zwei interessante Informationen:

Die Ausführungs-ID, die von der Laufzeitumgebung im Log ausgegeben wird:

image

Diese ID lässt sich damit leicht auch in eigene Log-Einträge aufnehmen, so dass sich Log-Einträge leichter korrelieren lassen.

 

Über die zweite wichtige Information stolpert man, wenn man auf die Konfiguration zugreifen will. Die üblichen Antworten nutzen das Property FunctionAppDirectory, um die Konfiguration selbst zu laden:

   1: [FunctionName("Function2")]

   2: public static async Task<IActionResult> Run(

   3:     [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,

   4:     ExecutionContext executionContext,

   5:     ILogger log)

   6: {

   7:     var config = new ConfigurationBuilder()

   8:         .SetBasePath(executionContext.FunctionAppDirectory)

   9:         .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true)

  10:         .AddEnvironmentVariables()

  11:         .Build();

  12:     var value = config["test"];

  13:     return new OkObjectResult(value);

  14: }

Produktiver Code sollte die Konfiguration natürlich nicht jedes mal neu laden.

Eigene Erweiterungen

Wenn es spezielle Sonderbehandlungen für optionale Parameter gibt, stellt sich natürlich die Frage, ob man hier selbst weitere Parameter “ergänzen” kann. Um das letzte Beispiel nochmal aufzugreifen: Die Laufzeitumgebung nutzt selbst die Konfiguration, kann man diese also nicht über einen Parameter vom Typ IConfiguration verfügbar machen?

Um es kurz zu machen: Ja, das geht. Aber man bewegt sich dabei in wenig bis nicht dokumentierten Bereichen. Blogbeiträge findet man zwar, aber die sind teilweise veraltet oder unvollständig. Letztlich kommt man nicht umhin, sich die Quelltexte von Azure Functions anzuschauen, insbesondere azure-functions-host und azure-webjobs-sdk.

Das nachfolgende Beispiel gibt eine kurze Einführung, um die grundsätzliche Arbeitsweise aufzuzeigen.

Konfiguration über Binding Provider

Die Implementierung dieses Beispiels basiert auf der Umsetzung für ILogger, sowie Hinweisen, wie dies mit dem neuen Erweiterungsmodell von Azure Functions zusammenspielt.

Ein eigenes Binding besteht aus dem Binding selbst (IBinding) und einem Provider, der dieses für jeden Parameter erzeugen muss (IBindingProvider). Als drittes muss man noch dafür sorgen, dass das der Laufzeitumgebung der Provider bekannt gemacht wird.

Das Binding sieht dabei etwas wild aus, ist aber im Grunde recht simpel und reicht nur Informationen weiter:

   1: internal class ConfigurationBinding : IBinding

   2: {

   3:     private readonly ParameterInfo _parameter;

   4:     private readonly IConfiguration _configuration;

   5:

   6:     public bool FromAttribute => false;

   7:

   8:     public ConfigurationBinding(ParameterInfo parameter, IConfiguration configuration)

   9:     {

  10:         _parameter = parameter;

  11:         _configuration = configuration;

  12:     }

  13:

  14:     public Task<IValueProvider> BindAsync(object value, ValueBindingContext context)

  15:     {

  16:         return Task.FromResult<IValueProvider>(new ValueBinder(value, _parameter.ParameterType));

  17:     }

  18:

  19:     public Task<IValueProvider> BindAsync(BindingContext context)

  20:     {

  21:         return BindAsync(_configuration, context.ValueContext);

  22:     }

  23:

  24:     public ParameterDescriptor ToParameterDescriptor()

  25:     {

  26:         return new ParameterDescriptor { Name = _parameter.Name };

  27:     }

  28:

  29:     private sealed class ValueBinder : IValueBinder

  30:     {

  31:         private readonly object _value;

  32:

  33:         public Type Type { get; private set; }

  34:

  35:         public ValueBinder(object value, Type type)

  36:         {

  37:             _value = value;

  38:             Type = type;

  39:         }

  40:

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

  42:         public string ToInvokeString() => null;

  43:         public Task SetValueAsync(object value, CancellationToken cancellationToken) => Task.CompletedTask;

  44:     }

  45: }

 

Der Provider nimmt das IConfiguration entgegen und erzeugt für passende Parameter ein entsprechendes Binding. Wichtig ist nur, dass er null zurückliefert, wenn der Parameter-Typ nicht passt, denn der Provider wird für alle Parameter aufgerufen.

   1: internal class ConfigurationBindingProvider : IBindingProvider

   2: {

   3:     private readonly IConfiguration _configuration;

   4:

   5:     public ConfigurationBindingProvider(IConfiguration configuration)

   6:     {

   7:         this._configuration = configuration;

   8:     }

   9:

  10:     public Task<IBinding> TryCreateAsync(BindingProviderContext context)

  11:     {

  12:         var parameter = context.Parameter;

  13:         if (parameter.ParameterType != typeof(IConfiguration))

  14:             return Task.FromResult<IBinding>(null);

  15:

  16:         IBinding binding = new ConfigurationBinding(parameter, _configuration);

  17:         return Task.FromResult(binding);

  18:     }

  19: }

Fehlt noch der letzte Schritt: Der Provider muss beim Start der Function App bei der Laufzeitumgebung für Dependency Injection angemeldet werden. Die Laufzeitumgebung erzeugt ihn dann bei Bedarf und stellt auch die Konfiguration zur Verfügung.

Hierzu sind zwei Schritte nötig. Da Function Apps auf das WebJobs SDK aufsetzen, muss eine Startup-Klasse angelegt und angemeldet werden, die Microsoft.Azure.WebJobs.Hosting.IWebJobsStartup implementiert. Diese kann dann den Provider registrieren:

   1: [assembly: WebJobsStartup(typeof(Startup))]

   2:

   3: namespace FunctionApp

   4: {

   5:     internal class Startup : IWebJobsStartup

   6:     {

   7:         public void Configure(IWebJobsBuilder builder)

   8:         {

   9:             builder.Services.AddSingleton<IBindingProvider, ConfigurationBindingProvider>();

  10:         }

  11:     }

  12: }

Das reicht aber noch nicht ganz. Damit die Laufzeitumgebung die Klasse findet, muss sie in der Datei extensions.json im bin-Verzeichnis angemeldet werden. Der dokumentierte Weg dies zu tun ist recht einfach: Man muss nur das Nuget-Paket Microsoft.Azure.WebJobs.Script.ExtensionsMetadataGenerator in das Projekt aufnehmen.

Bleibt als letzter Schritt das neue Binding auch zu nutzen:

   1: [FunctionName("Function3")]

   2: public static async Task<IActionResult> Run(

   3:     [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,

   4:     IConfiguration configuration)

   5: {

   6:     var value = configuration["test"];

   7:     return new OkObjectResult(value);

   8: }

Voilà.

Natürlich würde man diesen Aufwand nicht für jeden Parameter treiben, aber der hier gezeigte Code ist allgemein und generisch genug, um in einer eigenen kleinen Bibliothek wiederverwendet zu werden.

Dependency Injection

Dependency Injection (DI) ist ein weiteres typisches Beispiel, das auf der Wunschliste der Entwickler steht. Dies ist in Arbeit, aber solange muss man nicht warten – es gibt einige Blog-Beiträge, die das jetzt schon ermöglichen.

Boris Wilhelms bietet eine nahezu vollständige Umsetzung. Allerdings sollte man das nicht ganz kritikfrei übernehmen: er nutzt sein eigenes Repository für den DI-Container, statt sich in den existierenden Kontext einzuklinken; und die Generierung der extensions.json löst er in seinem Beispiel von Hand.

Holger Leichsenring nutzt ganz gezielt Autofac und kann als Beispiel dienen, falls man besondere Anforderungen an den DI-Container hat.

Beide Beispiele zeigen zudem, wie man ein Binding mit einem Attribut für den Parameter kombiniert, was sicher gute Praxis ist.

Fazit

Die optionalen Parameter zu kennen ist wichtig, insbesondere den ExecutionContext. Eigene Bindings wird man sicher nicht jeden Tag schreiben, aber gerade die hier vorgestellten sind so allgemein, dass man sie leicht in eine Bibliothek auslagern und wiederverwenden kann. Die Anforderungen lassen sich zwar auch anders lösen, aber sauber integriert ist das sicher angenehmer und weniger fehleranfällig.