In diesem Artikel der Serie „Serverless mit Azure“ stellen wir vor, wie man mit einer Azure Function auf eine Azure Cosmos DB Datenbank zugreifen kann.

Im letzten Artikel der Serie haben wir gezeigt, wie einfach man eine Azure Function erstellt und in Azure deployen kann. In diesem Artikel wollen wir eine Verbindung zu Azure Cosmos DB erstellen und einfache Lese- und Schreiboperationen durchführen.

Azure Cosmos DB ist ein recht flexibler (Serverless-)Datenbankdienst welcher quasi sofort einsatzbereit ist nach Anforderung über das Azure Portal. Um die Hochverfügbarkeit der Daten zu gewährleisten, können Instanzen global repliziert werden. Weiterhin kann man Instanzen unbegrenzt skalieren, wobei die Abrechnung anhand des erforderlichen Durchsatzes und Speichergröße stattfindet. Ein sehr interessanter Aspekt sind die sogenannten Wire Protocol kompatiblen API Endpunkte. So kann man einstellen, dass eine Instanz wie eine MongoDB oder wie eine relationale SQL Datenbank nach außen hin angesprochen werden soll. Wir werden die Azure Cosmos DB wie eine MongoDB behandeln, doch dazu später mehr.

Im Rahmen dieser Artikel-Serie werden wir eine kleine Applikation bauen um verschiedene Aspekte der Serverless Welt in Azure zu beleuchten. Die Applikation wird es ermöglichen, eine priorisierte Liste von Büchern zu speichern. Man kann sie benutzen, um zu speichern, welche Bücher man als nächstes lesen möchte. Deshalb können die einzelnen Einträge mit einer Priorität versehen werden. Im Laufe der Artikelserie wird sich der Funktionsumfang ggf. erweitern oder verändern, je nachdem auf welche Aspekte der Serverless-Welt wir noch eingehen werden.

Vorbereitung

Als Vorbereitung empfehlen wir, den letzten Artikel der Serie zu lesen. Dort sind die Voraussetzungen aufgeführt und gezeigt wie man in Visual Studio ein „blanko“ Azure Function Projekt erstellt.

Abhängigkeiten

Für das Arbeiten mit Cosmos DB innerhalb von Azure Functions benötigen wir die folgenden Abhängigkeiten. Man kann diese mithilfe des NuGet Pakatmanagers installieren.

  1. MongoDB.Driver: Dieses Paket wird benötigt um Verbindungen mit MongoDB Datenbanken aufzubauen. Weiterhin stellt das Paket alle Elemente bereit die benötigt werden, um Operationen auf der Datenbank durchzuführen.
  2. MongoDB.Bson: Dieses Paket ist für das Object Mapping zuständig. Bson steht für binary JSON und ist quasi ein Superset des JSON Formats. Das Paket stellt Elemente zu Verfügung, um ein JSON oder BSON in ein Objekt einer passenden Klasse zu verwandeln und umgekehrt. Man spricht dabei von Deserialisierung bzw. Serialisierung.

Datenbanken

Wenn das Projekt soweit vorbereitet ist, können wir zunächst in Azure eine Cosmos DB Instanz anlegen.

  1. In dem Azure Portal erstellen wir eine neue Cosmos DB Resource. Im besten Fall istCosmos DB bereits in der Liste der Dienste aufgeführt, ansonsten klicken wir auf Ressource erstellen und suchen nach Cosmos DB.

    https://blog.dataone.de/2019/11/serverless-mit-azure-meine-erste-azure-function/
    Azure Portal Übersicht der Dienste
  2. In der Übersicht müssen wir jetzt einige Dinge angeben, wie die Azure Subscription, die Ressourcengruppe und natürlich einen Namen. Wichtig ist hier, dass wir MongoDB als API auswählen.

    Cosmos DB Dienst erzeugen
    Cosmos DB Dienst erzeugen
  3. Bei den restlichen Schritten können wir die Defaultwerte übernehmen und einfach weiter klicken. Es wird jetzt einige Zeit dauern, bis Azure die Cosmos DB Instanz für uns erzeugt.

Als nächstes brauchen wir auch lokal eine Datenbank, damit wir nicht jedes mal die Function auf Azure deployen müssen, um Änderungen zu testen. Wir haben uns für MongoDB entschieden, weil man relativ einfach und schnell eine MongoDB lokal zum Laufen bekommt. Aber auch, weil eine relationale Datenbank für unsere Beispiel Applikation einfach zu schwer gewichtig wäre. Um MongoDB lokal zum Laufen zu bekommen, kann man entweder auf der MongoDB Homepage die entsprechende Version herunterladen und installieren oder man verwendet Docker, was an dieser Stelle empfohlen wird. Falls man Docker bereits installiert, sind es nämlich lediglich 2 Befehle in der Powershell und MongoDB läuft lokal:

docker pull mongo 
docker run -p 27017:27017 mongo:latest

Anpassung

Nun geht es darum unser blanko Projekt mit Funktionalität zu füllen.

Datenhaltung

Wie bereits erwähnt, wollen wir eine kleine Applikation bauen, um eine priorisierte Liste von Büchern zu speichern und bearbeiten. Wir beginnen daher mit dem Erstellen einer Klasse für die Datenhaltung.

public class BookReading : IEquatable
{
    [BsonId]
    [BsonRepresentation(BsonType.ObjectId)]
    public string id;
        
    public string name = string.Empty;
    public int priority = 0;

    public bool Equals(BookReading other)
    {
        return id.Equals(other.id) && name.Equals(other.name) && priority.Equals(other.priority);
    }
}

Die Klasse ist relativ unspektakulär, beinhaltet lediglich einen Namen und eine Priorität für ein Bucheintrag. Interessant sind eventuell die Attribute für die ID und die Equals Methode. Die Attribute (in eckigen Klammern) spezifizieren lediglich aus Sicht von MongoDB wie der entsprechende Wert behandelt werden soll. Die Equals Methode ist nützlich, wenn man die Contains Methode von C# Listen benutzen will oder generell wenn es darum geht, zwei Objekte dieser Klasse zu vergleichen. Ohne diese Methode können zwei Objekte dieser Klasse nicht erfolgreich mit der Equals Methode (die jedes Objekt in C# besitzt) verglichen werden.

Functions

Als nächstes definieren wir unsere Azure Functions. Wir werden alle Functions innerhalb einer Klasse platzieren, da die Anzahl überschaubar ist und sie thematisch zusammen gehören. Zunächst zeigen wir lediglich den groben Aufbau der Klasse. Der Inhalt der einzelnen Functions wird im weiteren Verlauf genauer betrachtet.

public static class BookReadingRepository
    {
        [FunctionName("CreateBookReading")]
        public static async Task CreateBookReading([HttpTrigger(AuthorizationLevel.Function, "post", Route = "bookreadings")] HttpRequest req, ILogger log)
        {
            ...
        }

        private static async Task LastPriority()
        {
...
        }

        [FunctionName("GetBookReadings")]
        public static async Task GetBookReadings([HttpTrigger(AuthorizationLevel.Function, "get", Route = "bookreadings")] HttpRequest req, ILogger log)
        {
            ...
        }

        [FunctionName("DeleteBookReading")]
        public static async Task DeleteBookReading([HttpTrigger(AuthorizationLevel.Function, "delete", Route = "bookreadings/{id}")] HttpRequest req, string id, ILogger log)
        {
            ...
        }

        private static IMongoCollection getCollection()
        {
            string connectionString = Environment.GetEnvironmentVariable("MONGO_DB_CONNECTION_STRING");
            MongoClientSettings settings = MongoClientSettings.FromUrl(new MongoUrl(connectionString));
            settings.SslSettings = new SslSettings() { EnabledSslProtocols = SslProtocols.Tls12 };
            var mongoClient = new MongoClient(settings);

            var db = mongoClient.GetDatabase("bookreadingsdb");
            return db.GetCollection("bookreadings");
        }
    }

An dieser Stelle möchten wir zunächst lediglich auf die Methode getCollection eingehen, die von jeder Function gebraucht wird. Die Methode getCollection erstellt eine Verbindung zu der Datenbank und gibt ein Collection Objekt zurück. Im Kontext von MongoDB kann man sich eine Collection wie eine Tabelle in traditionellen relationalen Datenbanken vorstellen. Die Verbindung selbst wird über ein sogenannten Connection String in der Environment Variable namens MONGO_DB_CONNECTION_STRING spezifiziert. Bei lokaler Ausführung sieht der Connection String wie folgt aus: mongodb://localhost:27017.

Für die Verwendung in Azure müssen wir den Connection String für die CosmosDB im Azure Portal ausfindig machen. Dazu suchen wir im Portal die erstellte CosmosDB Instanz und klicken auf Verbindungszeichenfolge in den Einstellungen.

Connection String
Verbindungszeichenfolge

Um diese Verbindungzeichenfolge für die Azure Function zu setzen, gibt es mehrere Möglichkeiten. Wir werden sie aus Visual Studio heraus setzen. Dazu gehen wir in dem Kontextmenü des Projekts auf  Veröffentlichen, wie bereits im ersten Artikel gezeigt.

Azure Function veröffentlichen
Azure Function veröffentlichen

In dem sich öffnenden Modalfenster können wir nun die Environment Variable anlegen und mit dem Connection String aus dem Azure Portal befüllen. Falls die Variable bereits in der local.settings.json Datei vorhanden ist, wird der Eintrag bereits in dem Modalfenster vorhanden sein. Falls nicht, muss sie zunächst angelegt werden bevor sie befüllt werden kann.

Anwendungseinstellungen
Anwendungseinstellungen

Eintrag anlegen

Um einen neuen Eintrag in der Datenbank anzulegen, schauen wir uns den folgenden Codeausschnitt an:

[FunctionName("CreateBookReading")]
public static async Task CreateBookReading([HttpTrigger(AuthorizationLevel.Function, "post", Route = "bookreadings")] HttpRequest req, ILogger log)
{
    try
    {
        string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        var newBookReading = JsonConvert.DeserializeObject(requestBody);

        var nextLastPriority = await LastPriority() + 1;
        newBookReading.priority = nextLastPriority;

        await getCollection().InsertOne(newBookReading);
        return new OkObjectResult(newBookReading);
    } catch (Exception e)
    {
        var objectResult = new ObjectResult(e.Message);
        objectResult.StatusCode = (int)HttpStatusCode.InternalServerError;
        return objectResult;
    }
}

Als erstes wird versucht aus dem Body des eingehenden HTTP Post Request ein Objekt der Klasse BookReading zu erzeugen. Dabei werden lediglich die Instanzvariablen befüllt, die auch im Request übergeben wurden. Bei Objekten der Klasse BookReading wird die Instanzvariable id automatisch gefüllt, sobald eine Instanz persistiert wurde. Die Instanzvariable priority wird durch die Methode LastPriority ermittelt (neue Einträge bekommen immer die letzte Priorität). Demnach wird lediglich die Instanzvariable name in einem HTTP Request erwartet. Das erwartete Format ist JSON, somit sieht der Body eines Request folgendermaßen aus:

{
    "name": "universe in a nutshell"
}

Wir haben schon erwähnt, dass wir in der MongoDB Welt mit Collections arbeiten wird. Also müssen wir das neue Objekt der Collection hinzufügen. Wurde das Objekt erfolgreich hinzugefügt und somit persistiert, geben wir es in einem OkObjectResult zurück. Das hat zur Folge, dass ein HTTP Response mit dem Statuscode 200 von der Function zurückgegeben wird. Weiterhin enthält der Response Body, das persistierte Objekt inklusive id und priority.
Falls es bei dem Vorgang zu einem Fehler kommt, wird ein HTTP Response mit dem Statuscode 500 und der Fehlermeldung im Body zurückgegeben.

Alle Einträge auslesen

Als nächstes betrachten wir den Codeausschnitt, der alle Einträge zurückgibt:

[FunctionName("GetBookReadings")]
public static async Task GetBookReadings([HttpTrigger(AuthorizationLevel.Function, "get", Route = "bookreadings")] HttpRequest req, ILogger log)
{
    try
    {
        var bookReadings = await getCollection().Find(_ => true).ToListAsync();
        return new OkObjectResult(bookReadings);
    }catch (Exception e)
    {
        var objectResult = new ObjectResult(e.Message);
        objectResult.StatusCode = (int)HttpStatusCode.InternalServerError;
        return objectResult;
    }
}

Um alle Einträge einer Collection zu erhalten, müssen wir lediglich die Find Methode einer Collection mit dem gezeigten Parameter aufrufen. Auch hier wird bei Erfolg, das Ergebnis als Body des HTTP Responses zurückgegeben. Der Statuscode ist dabei ebenfalls 200. Der Fehlerfall wird gleich behandelt wie bei dem Anlegen eines Eintrags. Der Statuscode des HTTP Responses ist 500 und der Body enthält die Fehlermeldung.

Eintrag finden

Wir könnten noch einen Endpunkt anlegen um Einträge zu finden. Allerdings macht es im Kontext der App die wir entwickeln möchten nicht so viel Sinn. Zumindest nicht zu diesem Zeitpunkt. Dennoch möchten wir zeigen, wie man spezielle Einträge einer Collection finden kann. Im Code zum Anlegen eines neuen Eintrags ist uns bereits die Methode LastPriority  begegnet:

private static async Task LastPriority()
{
    var options = new FindOptions
    {
        Limit = 1,
        Sort = Builders.Sort.Descending(bookReadingEntry => bookReadingEntry.priority)
    };

    BookReading lastPriorityBookReading = (await getCollection().FindAsync(FilterDefinition.Empty, options)).FirstOrDefault() ?? new BookReading();
    return lastPriorityBookReading.priority;
}

Diese Methode findet den Eintrag mit der niedrigsten Priorität, wobei 1 die höchste Priorität darstellt. Dazu wird eine absteigende Sortierung (Anhand der Instanzvariable priority) festgelegt und das Ergebnis auf 1 limitiert. Anschließend wird das erste Element aus der Ergebnisliste genommen und dessen Priorität zurückgegeben. Falls noch keine Einträge existieren, wird die FindAsync Methode null zurückgeben. Das fangen wir ab, indem wir einfach ein neues Objekt der Klasse BookReading erzeugen. In dem Fall würde die Methode also 0 zurückgeben (da der Initialwert der Instanzvariable priority 0 ist).

Auch wenn dieses Beispiel eine ziemlich einfache Abfrage zeigt, kann man erkennen, dass wir prinzipiell sehr mächtige Abfragen mithilfe der FindOptions erzeugen können. Ein enormer Vorteil hier ist, dass die FindOptions auf Seite der Datenbank ausgewertet werden. Solche Abfragen oder Filterungen sollten generell immer auf Seite der Datenbank gemacht werden anstatt innerhalb der Applikation. Schließlich sind Datenbanken auf Performance optimiert.

Eintrag löschen

Als letztes betrachten wir das Löschen von Einträgen:

[FunctionName("DeleteBookReading")]
public static async Task DeleteBookReading([HttpTrigger(AuthorizationLevel.Function, "delete", Route = "bookreadings/{id}")] HttpRequest req, string id, ILogger log)
{
    try
    {
        await Collection().DeleteOneAsync(bookReading => bookReading.id == id);
        return new NoContentResult();
    } catch (Exception e)
    {
        var objectResult = new ObjectResult(e.Message);
        objectResult.StatusCode = (int)HttpStatusCode.InternalServerError;
        return objectResult;
    }
}

Bei dieser Function sehen wir zum ersten mal die Verwendung von Path Parametern. Diese werden einfach in geschweifte Klammern an die Route angehängt. Zusätzlich dazu kann man die Variable dann einfach in die Parameterliste der Methodensignatur hinzufügen. Somit ist der Path Parameter dann innerhalb der Methode über den Methodenparameter zugreifbar.
Das Löschen eines Eintrags ist hier relativ einfach. Wir möchten lediglich den Eintrag löschen, dessen ID mit der ID aus dem Path Parameter übereinstimmt. Für komplexere Löschvorgänge könnten wir einen Builder benutzen um einen Filter zu bauen der ganz bestimmte Einträge löscht. Wir haben die Verwendung von Buildern bereits im letzten Abschnitt gesehen.
Falls das Löschen erfolgreich war, wird ein HTTP Response mit dem Statuscode 204 zurückgegeben. Der Body ist dabei nämlich leer. Im Fehlerfall wird wie gehabt der Statuscode 500 und der Fehlermeldung im Body zurückgegeben.

Ausführung + Manuelles Testen

Für die lokale Ausführung oder die Ausführung auf Azure und das manuelle Testen sei auf den letzten Artikel der Serie verwiesen. Wir müssen bei der lokalen Ausführung lediglich darauf achten, dass lokal eine MongoDB Instanz gestartet ist und die Environment Variable auf diese Instanz verweist.

Bei Anlegen eines neuen Eintrags sollte natürlich ein Body vom Typ application/json enthalten sein. Weiter oben haben wir gezeigt wie so eine JSON Zeichenkette aussehen soll. Ebenso sollte beim Löschen auch eine ID an die Route angehängt werden.

Fazit

In diesem Artikel haben wir jetzt relativ simple Interaktionen von Azure Functions mit MongoDB gesehen. Eigentlich ist es zwar eine Cosmos DB im Kontext von Azure, allerdings wird sie wie eine MongoDB angesprochen. Das hat den Vorteil, dass man sehr einfach lokal entwickeln kann. Am besten allerdings lässt sich die Entwicklung durch automatisierte Unit- und Integrationtests testen. Dann muss man nicht alles manuell testen. Wir haben die Beispiel Applikation in Github hochgeladen und Integrationtests hinzugefügt. Falls man Unittests ergänzen möchte, müsste man wahrscheinlich die Applikation an manchen Stellen modifizieren. Wir werden in einem zukünftigen Artikel dieser Serie noch explizit auf das automatisierte Testen von Azure Functions eingehen.

Es soll abschließend gesagt sein, dass die Version der Applikation wie sie heute existiert, keine Validierung von HTTP Requests betreibt. Weiterhin kann es sein, dass über die Zeit noch weitere Veränderungen/Verbesserungen vorgenommen werden. Somit weicht sie eventuell von den in diesem Artikel gezeigten Elementen ab.

Weiteres