Workshop zu serverlosen Web-APIs

1. Übersicht

In diesem Codelab lernen Sie die „serverlosen“ Dienste der Google Cloud Platform kennen:

  • Cloud Functions: Damit können Sie kleine Einheiten von Geschäftslogik in Form von Funktionen bereitstellen, die auf verschiedene Ereignisse reagieren (Pub/Sub-Nachrichten, neue Dateien in Cloud Storage, HTTP-Anfragen usw.).
  • App Engine: Web-Apps, Web-APIs, mobile Back-Ends und statische Assets bereitstellen und bereitstellen, mit schnellen Skalierungsfunktionen,
  • Cloud Run: zum Bereitstellen und Skalieren von Containern, die eine beliebige Sprache, Laufzeit oder Bibliothek enthalten können.

Außerdem erfahren Sie, wie Sie diese serverlosen Dienste zum Bereitstellen und Skalieren von Web- und REST APIs nutzen können. Dabei werden auch einige gute RESTful-Designprinzipien vorgestellt.

In diesem Workshop erstellen wir einen Bücherregal-Explorer, der aus Folgendem besteht:

  • Eine Cloud Functions-Funktion zum Importieren des ersten Datasets mit Büchern, die in unserer Bibliothek verfügbar sind, in die Cloud Firestore-Dokumentdatenbank.
  • Ein Cloud Run-Container, der eine REST API für den Inhalt unserer Datenbank bereitstellt.
  • Ein App Engine-Web-Frontend: zum Durchsuchen der Liste der Bücher durch Aufrufen unserer REST API.

So sieht das Web-Frontend am Ende dieses Codelabs aus:

705e014da0ca5e90.png

Lerninhalte

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

2. Einrichtung und Anforderungen

Umgebung zum selbstbestimmten Lernen einrichten

  1. Melden Sie sich in der Google Cloud Console an und erstellen Sie ein neues Projekt oder verwenden Sie ein vorhandenes. Wenn Sie noch kein Gmail- oder Google Workspace-Konto haben, müssen Sie eines erstellen.

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • Der Projektname ist der Anzeigename für die Teilnehmer dieses Projekts. Es handelt sich um einen String, der nicht von Google APIs verwendet wird. Sie können sie jederzeit aktualisieren.
  • Die Projekt-ID ist für alle Google Cloud-Projekte eindeutig und unveränderlich (kann nach dem Festlegen nicht mehr geändert werden). In der Cloud Console wird automatisch ein eindeutiger String generiert. Normalerweise ist es nicht wichtig, wie dieser String aussieht. In den meisten Codelabs müssen Sie auf Ihre Projekt-ID verweisen (in der Regel als PROJECT_ID angegeben). Wenn Ihnen die generierte ID nicht gefällt, können Sie eine andere zufällige ID generieren. Alternativ können Sie es mit einem eigenen Namen versuchen und sehen, ob er verfügbar ist. Sie kann nach diesem Schritt nicht mehr geändert werden und bleibt für die Dauer des Projekts bestehen.
  • Zur Information: Es gibt einen dritten Wert, die Projektnummer, die von einigen APIs verwendet wird. Weitere Informationen zu diesen drei Werten
  1. Als Nächstes müssen Sie die Abrechnung in der Cloud Console aktivieren, um Cloud-Ressourcen/-APIs zu verwenden. Die Durchführung dieses Codelabs kostet wenig oder gar nichts. Wenn Sie Ressourcen herunterfahren möchten, um Kosten zu vermeiden, die über diese Anleitung hinausgehen, können Sie die erstellten Ressourcen oder das Projekt löschen. Neue Google Cloud-Nutzer können am kostenlosen Testzeitraum mit einem Guthaben von 300$ teilnehmen.

Cloud Shell starten

Während Sie Google Cloud von Ihrem Laptop aus per Fernzugriff nutzen können, wird in diesem Codelab Google Cloud Shell verwendet, eine Befehlszeilenumgebung, die in der Cloud ausgeführt wird.

Klicken Sie in der Google Cloud Console rechts oben in der Symbolleiste auf das Cloud Shell-Symbol:

84688aa223b1c3a2.png

Die Bereitstellung und Verbindung mit der Umgebung sollte nur wenige Augenblicke dauern. Anschließend sehen Sie in etwa Folgendes:

320e18fedb7fbe0.png

Diese virtuelle Maschine verfügt über sämtliche Entwicklertools, die Sie benötigen. Sie bietet ein Basisverzeichnis mit 5 GB nichtflüchtigem Speicher und läuft in Google Cloud, was die Netzwerkleistung und Authentifizierung erheblich verbessert. Alle Aufgaben in diesem Codelab können in einem Browser ausgeführt werden. Sie müssen nichts installieren.

3. Umgebung vorbereiten und Cloud-APIs aktivieren

Damit wir die verschiedenen Dienste verwenden können, die wir für dieses Projekt benötigen, aktivieren wir einige APIs. Dazu führen wir den folgenden Befehl in Cloud Shell aus:

$ gcloud services enable \
      appengine.googleapis.com \
      cloudbuild.googleapis.com \
      cloudfunctions.googleapis.com \
      compute.googleapis.com \
      firestore.googleapis.com \
      run.googleapis.com

Nach einiger Zeit sollte der Vorgang erfolgreich abgeschlossen sein:

Operation "operations/acf.5c5ef4f6-f734-455d-b2f0-ee70b5a17322" finished successfully.

Wir richten auch eine Umgebungsvariable ein, die wir später benötigen: die Cloud-Region, in der wir unsere Funktion, App und unseren Container bereitstellen:

$ export REGION=europe-west3

Da wir Daten in der Cloud Firestore-Datenbank speichern, müssen wir die Datenbank erstellen:

$ gcloud app create --region=${REGION}
$ gcloud firestore databases create --location=${REGION}

Später in diesem Codelab müssen wir die Daten sortieren und filtern, wenn wir die REST API implementieren. Dazu erstellen wir drei Indexe:

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=language,order=ascending \
      --field-config field-path=updated,order=descending 

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=author,order=ascending \
      --field-config field-path=updated,order=descending 

Diese drei Indexe entsprechen den Suchvorgängen, die wir nach Autor oder Sprache durchführen, während die Reihenfolge in der Sammlung über ein aktualisiertes Feld beibehalten wird.

4. Code abrufen

Rufen Sie den Code aus dem folgenden GitHub-Repository ab:

$ git clone https://github.com/glaforge/serverless-web-apis

Der Anwendungscode wird mit Node.JS geschrieben.

Sie haben die folgende Ordnerstruktur, die für dieses Lab relevant ist:

serverless-web-apis
 |
 ├── data
 |   ├── books.json
 |
 ├── function-import
 |   ├── index.js
 |   ├── package.json
 |
 ├── run-crud
 |   ├── index.js
 |   ├── package.json
 |   ├── Dockerfile
 |
 ├── appengine-frontend
     ├── public
     |   ├── css/style.css
     |   ├── html/index.html
     |   ├── js/app.js
     ├── index.js
     ├── package.json
     ├── app.yaml

Das sind die relevanten Ordner:

  • data: Dieser Ordner enthält Beispieldaten für eine Liste mit 100 Büchern.
  • function-import: Diese Funktion bietet einen Endpunkt zum Importieren von Beispieldaten.
  • run-crud: Dieser Container stellt eine Web-API für den Zugriff auf die in Cloud Firestore gespeicherten Buchdaten bereit.
  • appengine-frontend: Diese App Engine-Webanwendung zeigt ein einfaches schreibgeschütztes Front-End zum Durchsuchen der Liste der Bücher an.

5. Beispieldaten für die Buchmediathek

Im Datenordner befindet sich eine books.json-Datei mit einer Liste von 100 Büchern, die sich wahrscheinlich lohnen. Dieses JSON-Dokument ist ein Array mit JSON-Objekten. Sehen wir uns die Form der Daten an, die wir über eine Cloud Functions-Funktion aufnehmen werden:

[
  {
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  },
  {
    "isbn": "9781414251196",
    "author": "Hans Christian Andersen",
    "language": "Danish",
    "pages": 784,
    "title": "Fairy tales",
    "year": 1836
  },
  ...
]

Alle unsere Bucheinträge in diesem Array enthalten die folgenden Informationen:

  • isbn: Die ISBN-13-Nummer des Buchs.
  • author: Der Name des Autors des Buchs.
  • language – Die gesprochene Sprache, in der das Buch geschrieben ist.
  • pages: Die Anzahl der Seiten im Buch.
  • title: Der Titel des Buchs.
  • year: Das Jahr, in dem das Buch veröffentlicht wurde.

6. Ein Funktionsendpunkt zum Importieren von Beispielbuchdaten

In diesem ersten Abschnitt implementieren wir den Endpunkt, der zum Importieren von Beispielbuchdaten verwendet wird. Dazu verwenden wir Cloud Functions.

Code ansehen

Sehen wir uns zuerst die Datei package.json an:

{
    "name": "function-import",
    "description": "Import sample book data",
    "license": "Apache-2.0",
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9"
    },
    "devDependencies": {
        "@google-cloud/functions-framework": "^3.1.0"
    },
    "scripts": {
        "start": "npx @google-cloud/functions-framework --target=parseBooks"
    }
}

In den Laufzeitabhängigkeiten benötigen wir nur das NPM-Modul @google-cloud/firestore, um auf die Datenbank zuzugreifen und unsere Buchdaten zu speichern. Intern stellt die Cloud Functions-Laufzeit auch das Express-Web-Framework bereit, sodass wir es nicht als Abhängigkeit deklarieren müssen.

In den Entwicklungsabhängigkeiten deklarieren wir das Functions Framework (@google-cloud/functions-framework), das das Laufzeit-Framework ist, das zum Aufrufen Ihrer Funktionen verwendet wird. Es ist ein Open-Source-Framework, das Sie auch lokal auf Ihrem Computer (in unserem Fall in Cloud Shell) verwenden können, um Funktionen auszuführen, ohne sie bei jeder Änderung bereitstellen zu müssen. So wird der Entwicklungs-Feedback-Zyklus verbessert.

Verwenden Sie den Befehl install, um die Abhängigkeiten zu installieren:

$ npm install

Das start-Skript verwendet das Functions Framework, um Ihnen einen Befehl zu geben, mit dem Sie die Funktion lokal ausführen können:

$ npm start

Sie können curl oder möglicherweise die Cloud Shell-Webvorschau für HTTP GET-Anfragen verwenden, um mit der Funktion zu interagieren.

Sehen wir uns nun die Datei index.js an, die die Logik unserer Importfunktion für Buchdaten enthält:

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Wir instanziieren das Firestore-Modul und verweisen auf die Sammlung „books“ (ähnlich einer Tabelle in relationalen Datenbanken).

functions.http('parseBooks', async (req, resp) => {
    if (req.method !== "POST") {
        resp.status(405).send({error: "Only method POST allowed"});
        return;
    }
    if (req.headers['content-type'] !== "application/json") {
        resp.status(406).send({error: "Only application/json accepted"});
        return;
    }
    ... 
})

Wir exportieren die JavaScript-Funktion parseBooks. Das ist die Funktion, die wir deklarieren, wenn wir sie später bereitstellen.

Mit den nächsten beiden Anleitungen wird Folgendes geprüft:

  • Wir akzeptieren nur HTTP-POST-Anfragen und geben andernfalls den Statuscode 405 zurück, um anzugeben, dass die anderen HTTP-Methoden nicht zulässig sind.
  • Wir akzeptieren nur application/json-Nutzlasten. Andernfalls senden wir den Statuscode 406, um anzugeben, dass dies kein akzeptables Nutzlastformat ist.
    const books = req.body;

    const writeBatch = firestore.batch();

    for (const book of books) {
        const doc = bookStore.doc(book.isbn);
        writeBatch.set(doc, {
            title: book.title,
            author: book.author,
            language: book.language,
            pages: book.pages,
            year: book.year,
            updated: Firestore.Timestamp.now()
        });
    }

Anschließend können wir die JSON-Nutzlast über die body der Anfrage abrufen. Wir bereiten einen Firestore-Batchvorgang vor, um alle Bücher im Bulk zu speichern. Wir durchlaufen das JSON-Array mit den Buchdetails und gehen die Felder isbn, title, author, language, pages und year durch. Die ISBN des Buchs dient als Primärschlüssel oder ID.

    try {
        await writeBatch.commit();
        console.log("Saved books in Firestore");
    } catch (e) {
        console.error("Error saving books:", e);
        resp.status(400).send({error: "Error saving books"});
        return;
    };

    resp.status(202).send({status: "OK"});

Nachdem der Großteil der Daten bereit ist, können wir den Vorgang ausführen. Wenn der Speichervorgang fehlschlägt, geben wir den Statuscode 400 zurück. Andernfalls können wir eine OK-Antwort mit dem Statuscode 202 zurückgeben, der angibt, dass die Anfrage zum Speichern von Daten im Bulk-Verfahren akzeptiert wurde.

Importfunktion ausführen und testen

Bevor wir den Code ausführen, installieren wir die Abhängigkeiten mit:

$ npm install

Um die Funktion lokal auszuführen, verwenden wir dank des Functions Framework den Skriptbefehl start, den wir in package.json definiert haben:

$ npm start

> start
> npx @google-cloud/functions-framework --target=parseBooks

Serving function...
Function: parseBooks
URL: http://localhost:8080/

So senden Sie eine HTTP-POST-Anfrage an Ihre lokale Funktion:

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       http://localhost:8080/

Wenn Sie diesen Befehl ausführen, wird die folgende Ausgabe angezeigt, die bestätigt, dass die Funktion lokal ausgeführt wird:

{"status":"OK"}

Sie können auch in der Cloud Console-UI prüfen, ob die Daten tatsächlich in Firestore gespeichert sind:

409982568cebdbf8.png

Im Screenshot oben sehen wir die erstellte Sammlung books, die Liste der anhand des ISBN-Codes des Buchs ermittelten Buchdokumente und die Details dieses bestimmten Bucheintrags auf der rechten Seite.

Funktion in der Cloud bereitstellen

Um die Funktion in Cloud Functions bereitzustellen, verwenden wir den folgenden Befehl im Verzeichnis function-import:

$ gcloud functions deploy bulk-import \
         --gen2 \
         --trigger-http \
         --runtime=nodejs20 \
         --allow-unauthenticated \
         --max-instances=30
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

Wir stellen die Funktion mit dem symbolischen Namen bulk-import bereit. Diese Funktion wird über HTTP-Anfragen ausgelöst. Wir verwenden die Node.JS 20-Laufzeit. Wir stellen die Funktion öffentlich bereit (idealerweise sollten wir diesen Endpunkt sichern). Wir geben die Region an, in der sich die Funktion befinden soll. Wir verweisen auf die Quellen im lokalen Verzeichnis und verwenden parseBooks (die exportierte JavaScript-Funktion) als Einstiegspunkt.

Nach wenigen Minuten ist die Funktion in der Cloud bereitgestellt. In der Benutzeroberfläche der Cloud Console sollte die Funktion angezeigt werden:

c910875d4dc0aaa8.png

In der Bereitstellungsausgabe sollte die URL Ihrer Funktion angezeigt werden, die einer bestimmten Namenskonvention (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}) folgt. Sie finden diese HTTP-Trigger-URL natürlich auch in der Cloud Console-Benutzeroberfläche auf dem Tab „Trigger“:

380ffc46eb56441e.png

Sie können die URL auch über die Befehlszeile mit gcloud abrufen:

$ export BULK_IMPORT_URL=$(gcloud functions describe bulk-import \
                                  --region=$REGION \
                                  --format 'value(httpsTrigger.url)')
$ echo $BULK_IMPORT_URL

Speichern wir sie in der Umgebungsvariable BULK_IMPORT_URL, damit wir sie zum Testen unserer bereitgestellten Funktion wiederverwenden können.

Bereitgestellte Funktion testen

Mit einem ähnlichen curl-Befehl, den wir bereits zum Testen der lokal ausgeführten Funktion verwendet haben, testen wir die bereitgestellte Funktion. Die einzige Änderung ist die URL:

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       $BULK_IMPORT_URL

Bei Erfolg sollte wieder die folgende Ausgabe zurückgegeben werden:

{"status":"OK"}

Nachdem unsere Importfunktion bereitgestellt wurde und wir unsere Beispieldaten hochgeladen haben, ist es an der Zeit, die REST API zu entwickeln, die dieses Dataset verfügbar macht.

7. REST API-Vertrag

Wir definieren zwar keinen API-Vertrag, z. B. mit der OpenAPI-Spezifikation, aber wir sehen uns die verschiedenen Endpunkte unserer REST API an.

Über die API werden JSON-Objekte für Bücher ausgetauscht, die Folgendes enthalten:

  • isbn (optional): eine 13-stellige String, die einen gültigen ISBN-Code darstellt.
  • author – eine nicht leere String, die den Namen des Autors des Buchs darstellt.
  • language – ein nicht leeres String mit der Sprache, in der das Buch geschrieben wurde,
  • pages – eine positive Integer für die Seitenzahl des Buchs,
  • title – ein nicht leeres String mit dem Titel des Buchs,
  • year: Ein Integer-Wert für das Erscheinungsjahr des Buchs.

Beispiel für eine Buchnutzlast:

{
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  }

GET /books

Ruft die Liste aller Bücher ab, die möglicherweise nach Autor und/oder Sprache gefiltert und in Gruppen von jeweils 10 Ergebnissen paginiert werden.

Nutzlast im Hauptteil: keine.

Suchparameter:

  • author (optional): Filtert die Buchliste nach Autor.
  • language (optional): filtert die Buchliste nach Sprache.
  • page (optional, Standardwert = 0): Gibt den Rang der Seite mit den zurückzugebenden Ergebnissen an.

Gibt ein JSON-Array mit Buchobjekten zurück.

Status codes:

  • 200 – wenn die Anfrage zum Abrufen der Liste der Bücher erfolgreich ist.
  • 400 – wenn ein Fehler auftritt.

POST /books und POST /books/{isbn}

Senden Sie eine neue Buch-Nutzlast, entweder mit dem Pfadparameter isbn (in diesem Fall ist der isbn-Code in der Buch-Nutzlast nicht erforderlich) oder ohne (in diesem Fall muss der isbn-Code in der Buch-Nutzlast vorhanden sein).

Nutzlast des Textkörpers: ein Buchobjekt.

Abfrageparameter: keine.

Gibt nichts zurück.

Status codes:

  • 201 – wenn das Buch erfolgreich gespeichert wurde.
  • 406 – wenn der isbn-Code ungültig ist.
  • 400 – wenn ein Fehler auftritt.

GET /books/{isbn}

Ruft ein Buch aus der Bibliothek ab, das durch seinen isbn-Code identifiziert wird, der als Pfadparameter übergeben wird.

Nutzlast im Hauptteil: keine.

Abfrageparameter: keine.

Gibt ein Buch-JSON-Objekt oder ein Fehlerobjekt zurück, wenn das Buch nicht vorhanden ist.

Status codes:

  • 200 – wenn das Buch in der Datenbank gefunden wird,
  • 400 – wenn ein Fehler auftritt,
  • 404 – wenn das Buch nicht gefunden werden konnte.
  • 406 – wenn der isbn-Code ungültig ist.

PUT /books/{isbn}

Aktualisiert ein vorhandenes Buch, das durch die als Pfadparameter übergebene isbn identifiziert wird.

Body-Nutzlast: ein Buchobjekt. Es können nur die Felder übergeben werden, die aktualisiert werden müssen. Die anderen Felder sind optional.

Abfrageparameter: keine.

Gibt das aktualisierte Buch zurück.

Status codes:

  • 200: wenn das Buch erfolgreich aktualisiert wurde.
  • 400 – wenn ein Fehler auftritt,
  • 406 – wenn der isbn-Code ungültig ist.

DELETE /books/{isbn}

Löscht ein vorhandenes Buch, das durch seine als Pfadparameter übergebene isbn identifiziert wird.

Nutzlast im Hauptteil: keine.

Abfrageparameter: keine.

Gibt nichts zurück.

Status codes:

  • 204 – wenn das Buch erfolgreich gelöscht wurde.
  • 400 – wenn ein Fehler auftritt.

8. REST API in einem Container bereitstellen und verfügbar machen

Code ansehen

Dockerfile

Sehen wir uns zuerst die Dockerfile an, die für die Containerisierung unseres Anwendungscodes verantwortlich ist:

FROM node:20-slim
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . ./
CMD [ "node", "index.js" ]

Wir verwenden ein „slim“-Image für Node.JS 20. Wir arbeiten im Verzeichnis /usr/src/app. Wir kopieren die Datei package.json (Details unten), in der unter anderem unsere Abhängigkeiten definiert sind. Wir installieren die Abhängigkeiten mit npm install und kopieren den Quellcode. Zuletzt wird mit dem Befehl node index.js angegeben, wie diese Anwendung ausgeführt werden soll.

package.json

Als Nächstes können wir uns die Datei package.json ansehen:

{
    "name": "run-crud",
    "description": "CRUD operations over book data",
    "license": "Apache-2.0",
    "engines": {
        "node": ">= 20.0.0"
    },
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9",
        "cors": "^2.8.5",
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "scripts": {
        "start": "node index.js"
    }
}

Wir geben an, dass wir Node.JS 14 verwenden möchten, wie es auch bei der Dockerfile der Fall war.

Unsere Web-API-Anwendung hängt von Folgendem ab:

  • Das Firestore-NPM-Modul für den Zugriff auf die Buchdaten in der Datenbank.
  • Die cors-Bibliothek zur Verarbeitung von CORS-Anfragen (Cross-Origin Resource Sharing), da unsere REST API vom Clientcode unseres App Engine-Webanwendungs-Frontends aufgerufen wird.
  • Das Express-Framework, das wir als Webframework für das Design unserer API verwenden werden,
  • Und dann das isbn3-Modul, das bei der Validierung von Buch-ISBN-Codes hilft.

Wir geben auch das start-Skript an, das zum lokalen Starten der Anwendung für Entwicklungs- und Testzwecke nützlich ist.

index.js

Kommen wir nun zum Kern des Codes und sehen wir uns index.js genauer an:

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Wir benötigen das Firestore-Modul und verweisen auf die Sammlung books, in der unsere Buchdaten gespeichert sind.

const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.json());

const querystring = require('querystring');

const cors = require('cors');
app.use(cors({
    exposedHeaders: ['Content-Length', 'Content-Type', 'Link'],
}));

Wir verwenden Express als Web-Framework, um unsere REST API zu implementieren. Wir verwenden das body-parser-Modul, um die JSON-Nutzlasten zu parsen, die mit unserer API ausgetauscht werden.

Das Modul querystring ist hilfreich, um URLs zu bearbeiten. Das ist der Fall, wenn wir Link-Header für die Paginierung erstellen (mehr dazu später).

Als Nächstes konfigurieren wir das Modul cors. Wir geben die Header an, die über CORS weitergeleitet werden sollen, da die meisten normalerweise entfernt werden. Hier möchten wir jedoch die übliche Inhaltslänge und den ‑typ sowie den Link-Header beibehalten, den wir für die Paginierung angeben.

const ISBN = require('isbn3');

function isbnOK(isbn, res) {
    const parsedIsbn = ISBN.parse(isbn);
    if (!parsedIsbn) {
        res.status(406)
            .send({error: `Invalid ISBN: ${isbn}`});
        return false;
    }
    return parsedIsbn;
}

Wir verwenden das isbn3-NPM-Modul, um ISBN-Codes zu parsen und zu validieren. Außerdem entwickeln wir eine kleine Hilfsfunktion, die ISBN-Codes parst und mit dem Statuscode 406 antwortet, wenn die ISBN-Codes ungültig sind.

  • GET /books

Sehen wir uns den GET /books-Endpunkt Schritt für Schritt an:

app.get('/books', async (req, res) => {
    try {
        var query = new Firestore().collection('books');

        if (!!req.query.author) {
            console.log(`Filtering by author: ${req.query.author}`);
            query = query.where("author", "==", req.query.author);
        }
        if (!!req.query.language) {
            console.log(`Filtering by language: ${req.query.language}`);
            query = query.where("language", "==", req.query.language);
        }

        const page = parseInt(req.query.page) || 0;

        // - -  - -  - -  - -  - -  - -

    } catch (e) {
        console.error('Failed to fetch books', e);
        res.status(400)
            .send({error: `Impossible to fetch books: ${e.message}`});
    }
});

Wir bereiten eine Abfrage vor, um die Datenbank abzufragen. Diese Abfrage hängt von den optionalen Abfrageparametern ab, um nach Autor und/oder Sprache zu filtern. Außerdem geben wir die Buchliste in Blöcken von 10 Büchern zurück.

Wenn beim Abrufen der Bücher ein Fehler auftritt, wird ein Fehler mit dem Statuscode 400 zurückgegeben.

Sehen wir uns den gekürzten Teil dieses Endpunkts genauer an:

        const snapshot = await query
            .orderBy('updated', 'desc')
            .limit(PAGE_SIZE)
            .offset(PAGE_SIZE * page)
            .get();

        const books = [];

        if (snapshot.empty) {
            console.log('No book found');
        } else {
            snapshot.forEach(doc => {
                const {title, author, pages, year, language, ...otherFields} = doc.data();
                const book = {isbn: doc.id, title, author, pages, year, language};
                books.push(book);
            });
        }

Im vorherigen Abschnitt haben wir nach author und language gefiltert. In diesem Abschnitt sortieren wir die Liste der Bücher nach dem Datum der letzten Aktualisierung (die zuletzt aktualisierten Bücher werden zuerst angezeigt). Außerdem wird das Ergebnis paginiert, indem ein Limit (die Anzahl der zurückzugebenden Elemente) und ein Offset (der Startpunkt, ab dem die nächste Gruppe von Büchern zurückgegeben werden soll) definiert werden.

Wir führen die Abfrage aus, rufen den Snapshot der Daten ab und fügen die Ergebnisse in ein JavaScript-Array ein, das am Ende der Funktion zurückgegeben wird.

Sehen wir uns zum Abschluss der Erläuterungen zu diesem Endpunkt eine Best Practice an: die Verwendung des Headers Link zum Definieren von URI-Links zur ersten, vorherigen, nächsten oder letzten Datenseite (in unserem Fall stellen wir nur „previous“ und „next“ bereit).

        var links = {};
        if (page > 0) {
            const prevQuery = querystring.stringify({...req.query, page: page - 1});
            links.prev = `${req.path}${prevQuery != '' ? `?${prevQuery}` : ''}`;
        }
        if (snapshot.docs.length === PAGE_SIZE) {
            const nextQuery = querystring.stringify({...req.query, page: page + 1});
            links.next = `${req.path}${nextQuery != '' ? `?${nextQuery}` : ''}`;
        }
        if (Object.keys(links).length > 0) {
            res.links(links);
        }

        res.status(200).send(books);

Die Logik mag hier zuerst etwas komplex erscheinen, aber wir fügen einen previous-Link hinzu, wenn wir uns nicht auf der ersten Datenseite befinden. Wenn die Datenseite voll ist (d. h. die maximale Anzahl von Büchern enthält, die durch die Konstante PAGE_SIZE definiert ist, und davon ausgegangen wird, dass eine weitere Seite mit mehr Daten folgt), fügen wir einen next-Link hinzu. Wir verwenden dann die resource#links()-Funktion von Express, um den richtigen Header mit der richtigen Syntax zu erstellen.

Der Link-Header sieht in etwa so aus:

link: </books?page=1>; rel="prev", </books?page=3>; rel="next"
  • POST /books und POST /books/:isbn

Beide Endpunkte dienen dazu, ein neues Buch zu erstellen. Bei der einen wird der ISBN-Code in der Buch-Payload übergeben, bei der anderen als Pfadparameter. In beiden Fällen wird unsere createBook()-Funktion aufgerufen:

async function createBook(isbn, req, res) {
    const parsedIsbn = isbnOK(isbn, res);
    if (!parsedIsbn) return;

    const {title, author, pages, year, language} = req.body;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            title, author, pages, year, language,
            updated: Firestore.Timestamp.now()
        });
        console.log(`Saved book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} created`});
    } catch (e) {
        console.error(`Failed to save book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to create book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
}

Wir prüfen, ob der isbn-Code gültig ist. Andernfalls wird die Funktion beendet und der Statuscode 406 wird festgelegt. Wir rufen die Buchfelder aus der Nutzlast ab, die im Textkörper der Anfrage übergeben wird. Anschließend speichern wir die Buchdetails in Firestore. Bei Erfolg wird 201 und bei einem Fehler 400 zurückgegeben.

Bei erfolgreicher Rückgabe wird auch der Location-Header festgelegt, um dem Client der API Hinweise darauf zu geben, wo sich die neu erstellte Ressource befindet. Der Header sieht so aus:

Location: /books/9781234567898
  • GET /books/:isbn

Wir rufen ein Buch, das über seine ISBN identifiziert wird, aus Firestore ab.

app.get('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        const docSnapshot = await docRef.get();

        if (!docSnapshot.exists) {
            console.log(`Book not found ${parsedIsbn.isbn13}`)
            res.status(404)
                .send({error: `Could not find book ${parsedIsbn.isbn13}`});
            return;
        }

        console.log(`Fetched book ${parsedIsbn.isbn13}`, docSnapshot.data());

        const {title, author, pages, year, language, ...otherFields} = docSnapshot.data();
        const book = {isbn: parsedIsbn.isbn13, title, author, pages, year, language};

        res.status(200).send(book);
    } catch (e) {
        console.error(`Failed to fetch book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to fetch book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

Wie immer prüfen wir, ob die ISBN gültig ist. Wir stellen eine Anfrage an Firestore, um das Buch abzurufen. Die Property snapshot.exists ist nützlich, um festzustellen, ob ein Buch gefunden wurde. Andernfalls senden wir einen Fehler und den Statuscode 404 (Nicht gefunden) zurück. Wir rufen die Buchdaten ab und erstellen ein JSON-Objekt, das das Buch darstellt, das zurückgegeben werden soll.

  • PUT /books/:isbn

Wir verwenden die PUT-Methode, um ein vorhandenes Buch zu aktualisieren.

app.put('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            ...req.body,
            updated: Firestore.Timestamp.now()
        }, {merge: true});
        console.log(`Updated book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} updated`});
    } catch (e) {
        console.error(`Failed to update book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to update book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
});

Wir aktualisieren das Feld updated, um zu speichern, wann wir diesen Datensatz zuletzt aktualisiert haben. Wir verwenden die {merge:true}-Strategie, bei der vorhandene Felder durch ihre neuen Werte ersetzt werden. Andernfalls werden alle Felder entfernt und nur die neuen Felder in der Nutzlast werden gespeichert. Dadurch werden vorhandene Felder aus der vorherigen Aktualisierung oder der ursprünglichen Erstellung gelöscht.

Außerdem legen wir den Location-Header so fest, dass er auf den URI des Buchs verweist.

  • DELETE /books/:isbn

Das Löschen von Büchern ist ganz einfach. Wir rufen einfach die Methode delete() für die Dokumentreferenz auf. Wir geben den Statuscode 204 zurück, da wir keine Inhalte zurückgeben.

app.delete('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.delete();
        console.log(`Book ${parsedIsbn.isbn13} was deleted`);

        res.status(204).end();
    } catch (e) {
        console.error(`Failed to delete book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to delete book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

Express-/Node-Server starten

Zu guter Letzt starten wir den Server, der standardmäßig Port 8080 überwacht:

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Books Web API service: listening on port ${port}`);
    console.log(`Node ${process.version}`);
});

Anwendung lokal ausführen

Um die Anwendung lokal auszuführen, installieren wir zuerst die Abhängigkeiten mit:

$ npm install

Wir können dann mit Folgendem beginnen:

$ npm start

Der Server wird auf localhost gestartet und überwacht standardmäßig Port 8080.

Mit den folgenden Befehlen können Sie auch einen Docker-Container erstellen und das Container-Image ausführen:

$ docker build -t crud-web-api .

$ docker run --rm -p 8080:8080 -it crud-web-api

Die Ausführung in Docker ist auch eine gute Möglichkeit, um zu überprüfen, ob die Containerisierung unserer Anwendung ordnungsgemäß funktioniert, wenn wir sie mit Cloud Build in der Cloud erstellen.

API testen

Unabhängig davon, wie wir den REST API-Code ausführen (direkt über Node oder über ein Docker-Container-Image), können wir jetzt einige Abfragen dagegen ausführen.

  • Neues Buch erstellen (ISBN in der Nutzlast):
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books
  • Neues Buch erstellen (ISBN in einem Pfadparameter):
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books/9782070368228
  • Löschen Sie ein Buch (das von uns erstellte):
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • Buch anhand der ISBN abrufen:
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • So aktualisieren Sie ein vorhandenes Buch, indem Sie nur den Titel ändern:
$ curl -XPUT \
       -d '{"title":"Book"}' \
       -H "Content-Type: application/json" \      
       http://localhost:8080/books/9780003701203
  • So rufen Sie die Liste der Bücher (die ersten 10) ab:
$ curl http://localhost:8080/books
  • So finden Sie die Bücher eines bestimmten Autors:
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • Liste die Bücher auf, die auf Englisch geschrieben wurden:
$ curl http://localhost:8080/books?language=English
  • Vierte Seite mit Büchern laden:
$ curl http://localhost:8080/books?page=3

Wir können auch die Abfrageparameter author, language und books kombinieren, um die Suche zu verfeinern.

Containerisierte REST API erstellen und bereitstellen

Da die REST API wie geplant funktioniert, ist es an der Zeit, sie in der Cloud, in Cloud Run, bereitzustellen.

Das geht in zwei Schritten:

  • Erstellen Sie zuerst das Container-Image mit Cloud Build. Verwenden Sie dazu den folgenden Befehl:
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • Stellen Sie den Dienst dann mit diesem zweiten Befehl bereit:
$ gcloud run deploy run-crud \
         --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
         --allow-unauthenticated \
         --region=${REGION} \
         --platform=managed

Mit dem ersten Befehl erstellt Cloud Build das Container-Image und hostet es in Container Registry. Mit dem nächsten Befehl wird das Container-Image aus der Registry in der Cloud-Region bereitgestellt.

Wir können in der Cloud Console-Benutzeroberfläche noch einmal prüfen, ob unser Cloud Run-Dienst jetzt in der Liste angezeigt wird:

f62fbca02a8127c0.png

Als letzten Schritt rufen wir mit dem folgenden Befehl die URL des neu bereitgestellten Cloud Run-Dienstes ab:

$ export RUN_CRUD_SERVICE_URL=$(gcloud run services describe run-crud \
                                       --region=${REGION} \
                                       --platform=managed \
                                       --format='value(status.url)')

Wir benötigen die URL unserer Cloud Run REST API im nächsten Abschnitt, da unser App Engine-Frontend-Code mit der API interagieren wird.

9. Web-App zum Durchsuchen der Bibliothek hosten

Der letzte Schritt, um diesem Projekt etwas Glanz zu verleihen, ist die Bereitstellung eines Web-Frontends, das mit unserer REST API interagiert. Dazu verwenden wir Google App Engine mit etwas clientseitigem JavaScript-Code, der die API über AJAX-Anfragen aufruft (mit der clientseitigen Fetch-API).

Unsere Anwendung besteht zwar hauptsächlich aus statischen Ressourcen, wird aber in der Node.JS App Engine-Laufzeitumgebung bereitgestellt. Es gibt nicht viel Backend-Code, da die meisten Nutzerinteraktionen im Browser über clientseitiges JavaScript erfolgen. Wir verwenden kein ausgefallenes Frontend-JavaScript-Framework, sondern nur „Vanilla“-JavaScript mit einigen Webkomponenten für die Benutzeroberfläche, die auf der Shoelace-Webkomponentenbibliothek basieren:

  • ein Auswahlfeld, in dem die Sprache des Buchs ausgewählt werden kann:

6fb9f741000a2dc1.png

  • Eine Kartenkomponente zum Anzeigen der Details zu einem bestimmten Buch, einschließlich eines Barcodes zur Darstellung der ISBN des Buchs mithilfe der JsBarcode-Bibliothek:

3aa21a9e16e3244e.png

  • und eine Schaltfläche zum Laden weiterer Bücher aus der Datenbank:

3925ad81c91bbac9.png

Wenn Sie alle diese visuellen Komponenten kombinieren, sieht die resultierende Webseite zum Durchsuchen unserer Bibliothek so aus:

18a5117150977d6.png

Die app.yaml Konfigurationsdatei

Sehen wir uns zuerst die app.yaml-Konfigurationsdatei dieser App Engine-Anwendung an. Diese Datei ist spezifisch für App Engine und ermöglicht die Konfiguration von Dingen wie Umgebungsvariablen, den verschiedenen Handlern der Anwendung oder die Angabe, dass einige Ressourcen statische Assets sind, die vom integrierten CDN von App Engine bereitgestellt werden.

runtime: nodejs14

env_variables:
  RUN_CRUD_SERVICE_URL: CHANGE_ME

handlers:

- url: /js
  static_dir: public/js

- url: /css
  static_dir: public/css

- url: /img
  static_dir: public/img

- url: /(.+\.html)
  static_files: public/html/\1
  upload: public/(.+\.html)

- url: /
  static_files: public/html/index.html
  upload: public/html/index\.html

- url: /.*
  secure: always
  script: auto

Wir geben an, dass unsere Anwendung eine Node.JS-Anwendung ist und dass wir Version 14 verwenden möchten.

Anschließend definieren wir eine Umgebungsvariable, die auf die URL unseres Cloud Run-Dienstes verweist. Wir müssen den Platzhalter CHANGE_ME durch die richtige URL ersetzen (siehe unten).

Danach definieren wir verschiedene Handler. Die ersten drei verweisen auf den Speicherort des clientseitigen HTML-, CSS- und JavaScript-Codes im Ordner public/ und seinen Unterordnern. Die vierte Zeile gibt an, dass die Stamm-URL unserer App Engine-Anwendung auf die Seite index.html verweisen soll. So wird das index.html-Suffix in der URL nicht angezeigt, wenn auf das Stammverzeichnis der Website zugegriffen wird. Die letzte ist die Standard-URL, über die alle anderen URLs (/.*) an unsere Node.JS-Anwendung weitergeleitet werden, d. h. an den dynamischen Teil der Anwendung im Gegensatz zu den statischen Assets, die wir beschrieben haben.

Aktualisieren Sie jetzt die Web-API-URL des Cloud Run-Dienstes.

Führen Sie im Verzeichnis appengine-frontend/ den folgenden Befehl aus, um die Umgebungsvariable zu aktualisieren, die auf die URL unserer Cloud Run-basierten REST API verweist:

$ sed -i -e "s|CHANGE_ME|${RUN_CRUD_SERVICE_URL}|" app.yaml

Sie können den String CHANGE_ME in app.yaml auch manuell durch die richtige URL ersetzen:

env_variables:
    RUN_CRUD_SERVICE_URL: CHANGE_ME

Die Node.JS-package.json-Datei

{
    "name": "appengine-frontend",
    "description": "Web frontend",
    "license": "Apache-2.0",
    "main": "index.js",
    "engines": {
        "node": "^14.0.0"
    },
    "dependencies": {
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "devDependencies": {
        "nodemon": "^2.0.7"
    },
    "scripts": {
        "start": "node index.js",
        "dev": "nodemon --watch server --inspect index.js"
    }
}

Wir möchten diese Anwendung mit Node.JS 14 ausführen. Wir verwenden das Express-Framework sowie das NPM-Modul isbn3 zur Validierung von ISBN-Codes von Büchern.

In den Entwicklungsabhängigkeiten verwenden wir das Modul nodemon, um Dateiänderungen zu überwachen. Wir können unsere Anwendung zwar mit npm start lokal ausführen, einige Änderungen am Code vornehmen, die App mit ^C beenden und dann neu starten, aber das ist etwas mühsam. Stattdessen können wir den folgenden Befehl verwenden, damit die Anwendung bei Änderungen automatisch neu geladen bzw. neu gestartet wird:

$ npm run dev

Der index.js Node.JS-Code

const express = require('express');
const app = express();

app.use(express.static('public'));

const bodyParser = require('body-parser');
app.use(bodyParser.json());

Wir benötigen das Express-Web-Framework. Wir geben an, dass das öffentliche Verzeichnis statische Assets enthält, die (zumindest bei lokaler Ausführung im Entwicklungsmodus) von der static-Middleware bereitgestellt werden können. Schließlich benötigen wir body-parser, um unsere JSON-Nutzlasten zu parsen.

Sehen wir uns die beiden Routen an, die wir definiert haben:

app.get('/', async (req, res) => {
    res.redirect('/html/index.html');
});

app.get('/webapi', async (req, res) => {
    res.send(process.env.RUN_CRUD_SERVICE_URL);
});

Die erste, die mit / übereinstimmt, wird zu index.html in unserem Verzeichnis public/html weitergeleitet. Da wir im Entwicklermodus nicht in der App Engine-Laufzeitumgebung ausgeführt werden, findet kein URL-Routing von App Engine statt. Stattdessen leiten wir hier die Stamm-URL einfach zur HTML-Datei weiter.

Der zweite Endpunkt, den wir definieren, /webapi, gibt die URL unserer Cloud Run REST API zurück. So weiß der clientseitige JavaScript-Code, wo er die Liste der Bücher abrufen kann.

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Book library web frontend: listening on port ${port}`);
    console.log(`Node ${process.version}`);
    console.log(`Web API endpoint ${process.env.RUN_CRUD_SERVICE_URL}`);
});

Zum Schluss führen wir die Express-Webanwendung aus und überwachen standardmäßig Port 8080.

Die index.html Seite

Wir werden uns nicht jede Zeile dieser langen HTML-Seite ansehen. Stattdessen möchten wir einige wichtige Zeilen hervorheben.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/themes/base.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/shoelace.js"></script>

<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.0/dist/barcodes/JsBarcode.ean-upc.min.js"></script>

<script src="/js/app.js"></script>
<link rel="stylesheet" type="text/css" href="/css/style.css">

In den ersten beiden Zeilen wird die Shoelace-Webkomponentenbibliothek importiert (ein Skript und ein Stylesheet).

In der nächsten Zeile wird die JsBarcode-Bibliothek importiert, um die Barcodes der ISBN-Codes des Buchs zu erstellen.

In den letzten Zeilen werden unser eigener JavaScript-Code und unser eigenes CSS-Stylesheet importiert, die sich in unseren public/-Unterverzeichnissen befinden.

Im body der HTML-Seite verwenden wir die Shoelace-Komponenten mit ihren benutzerdefinierten Element-Tags, z. B.:

<sl-icon name="book-half"></sl-icon>
...

<sl-select id="language-select" placeholder="Select a language..." clearable>
    <sl-menu-item value="English">English</sl-menu-item>
    <sl-menu-item value="French">French</sl-menu-item>
    ...
</sl-select>
...

<sl-button id="more-button" type="primary" size="large">
    More books...
</sl-button>
...

Außerdem verwenden wir HTML-Vorlagen und die Möglichkeit, Slots zu füllen, um ein Buch darzustellen. Wir erstellen Kopien dieser Vorlage, um die Liste der Bücher zu füllen, und ersetzen die Werte in den Slots durch die Details der Bücher:

    <template id="book-card">
        <sl-card class="card-overview">
        ...
            <slot name="author">Author</slot>
            ... 
        </sl-card>
    </template>

Genug HTML, wir sind fast mit der Überprüfung des Codes fertig. Ein letzter wichtiger Teil steht noch aus: der clientseitige JavaScript-Code von app.js, der mit unserer REST API interagiert.

Der clientseitige JavaScript-Code „app.js“

Wir beginnen mit einem Event-Listener auf oberster Ebene, der darauf wartet, dass der DOM-Inhalt geladen wird:

document.addEventListener("DOMContentLoaded", async function(event) {
    ...
}

Wenn es fertig ist, können wir einige wichtige Konstanten und Variablen einrichten:

    const serverUrlResponse = await fetch('/webapi');
    const serverUrl = await serverUrlResponse.text();
    console.log('Web API endpoint:', serverUrl);
    
    const server = serverUrl + '/books';
    var page = 0;
    var language = '';

Zuerst rufen wir die URL unserer REST API ab. Das ist dank unseres App Engine-Knotencodes möglich, der die Umgebungsvariable zurückgibt, die wir ursprünglich in app.yaml festgelegt haben. Dank der Umgebungsvariable für den /webapi-Endpunkt, der vom JavaScript-Clientseitencode aufgerufen wird, mussten wir die REST API-URL nicht in unseren Frontend-Code einfügen.

Außerdem definieren wir die Variablen page und language, mit denen wir die Paginierung und die Sprachfilterung im Blick behalten.

    const moreButton = document.getElementById('more-button');
    moreButton.addEventListener('sl-focus', event => {
        console.log('Button clicked');
        moreButton.blur();

        appendMoreBooks(server, page++, language);
    });

Wir fügen dem Button einen Event-Handler hinzu, um Bücher zu laden. Wenn darauf geklickt wird, wird die Funktion appendMoreBooks() aufgerufen.

    const langSelect = document.getElementById('language-select');
    langSelect.addEventListener('sl-change', event => {
        page = 0;
        language = event.srcElement.value;
        document.getElementById('library').replaceChildren();
        console.log(`Language selected: "${language}"`);

        appendMoreBooks(server, page++, language);
    });

Ähnlich verhält es sich mit dem Auswahlfeld. Wir fügen einen Ereignishandler hinzu, um über Änderungen bei der Sprachauswahl benachrichtigt zu werden. Wie beim Button rufen wir auch hier die Funktion appendMoreBooks() auf und übergeben die REST API-URL, die aktuelle Seite und die Sprachauswahl.

Sehen wir uns also die Funktion an, die Bücher abruft und anhängt:

async function appendMoreBooks(server, page, language) {
    const searchUrl = new URL(server);
    if (!!page) searchUrl.searchParams.append('page', page);
    if (!!language) searchUrl.searchParams.append('language', language);
        
    const response = await fetch(searchUrl.href);
    const books = await response.json();
    ... 
}

Oben erstellen wir die genaue URL, die zum Aufrufen der REST API verwendet werden soll. Normalerweise können wir drei Abfrageparameter angeben, aber hier in dieser Benutzeroberfläche geben wir nur zwei an:

  • page – eine Ganzzahl, die die aktuelle Seite für die Paginierung der Bücher angibt.
  • language: Ein Sprachstring, nach dem gefiltert werden soll.

Anschließend verwenden wir die Fetch API, um das JSON-Array mit unseren Buchdetails abzurufen.

    const linkHeader = response.headers.get('Link')
    console.log('Link', linkHeader);
    if (!!linkHeader && linkHeader.indexOf('rel="next"') > -1) {
        console.log('Show more button');
        document.getElementById('buttons').style.display = 'block';
    } else {
        console.log('Hide more button');
        document.getElementById('buttons').style.display = 'none';
    }

Je nachdem, ob der Header Link in der Antwort vorhanden ist, wird die Schaltfläche [More books...] ein- oder ausgeblendet. Der Header Link gibt an, ob noch weitere Bücher geladen werden müssen (im Header Link ist eine next-URL vorhanden).

    const library = document.getElementById('library');
    const template = document.getElementById('book-card');
    for (let book of books) {
        const bookCard = template.content.cloneNode(true);

        bookCard.querySelector('slot[name=title]').innerText = book.title;
        bookCard.querySelector('slot[name=language]').innerText = book.language;
        bookCard.querySelector('slot[name=author]').innerText = book.author;
        bookCard.querySelector('slot[name=year]').innerText = book.year;
        bookCard.querySelector('slot[name=pages]').innerText = book.pages;
        
        const img = document.createElement('img');
        img.setAttribute('id', book.isbn);
        img.setAttribute('class', 'img-barcode-' + book.isbn)
        bookCard.querySelector('slot[name=barcode]').appendChild(img);

        library.appendChild(bookCard);
        ... 
    }
}

Im obigen Abschnitt der Funktion klonen wir für jedes von der REST API zurückgegebene Buch die Vorlage mit einigen Webkomponenten, die ein Buch darstellen, und füllen die Slots der Vorlage mit den Details des Buchs.

JsBarcode('.img-barcode-' + book.isbn).EAN13(book.isbn, {fontSize: 18, textMargin: 0, height: 60}).render();

Damit der ISBN-Code etwas ansprechender aussieht, verwenden wir die JsBarcode-Bibliothek, um einen schönen Barcode wie auf der Rückseite echter Bücher zu erstellen.

Anwendung lokal ausführen und testen

Genug Code für jetzt. Es ist an der Zeit, die Anwendung in Aktion zu sehen. Zuerst führen wir das lokal in Cloud Shell aus, bevor wir es tatsächlich bereitstellen.

Wir installieren die von unserer Anwendung benötigten NPM-Module mit:

$ npm install

Wir führen die App entweder mit dem üblichen

$ npm start

Oder mit dem automatischen Neuladen von Änderungen dank nodemon:

$ npm run dev

Die Anwendung wird lokal ausgeführt und kann über den Browser unter http://localhost:8080 aufgerufen werden.

App Engine-Anwendung bereitstellen

Nachdem wir uns davon überzeugt haben, dass unsere Anwendung lokal einwandfrei ausgeführt wird, ist es an der Zeit, sie in App Engine bereitzustellen.

Führen Sie den folgenden Befehl aus, um die Anwendung bereitzustellen:

$ gcloud app deploy -q

Nach etwa einer Minute sollte die Anwendung bereitgestellt sein.

Die Anwendung ist unter einer URL mit dem folgenden Format verfügbar: https://${GOOGLE_CLOUD_PROJECT}.appspot.com.

UI unserer App Engine-Webanwendung kennenlernen

Ab sofort können Sie

  • Klicken Sie auf die Schaltfläche [More books...], um weitere Bücher zu laden.
  • Wählen Sie eine bestimmte Sprache aus, um nur Bücher in dieser Sprache zu sehen.
  • Sie können die Auswahl mit dem kleinen Kreuz im Auswahlfeld aufheben, um zur Liste aller Bücher zurückzukehren.

10. Bereinigen (optional)

Wenn Sie die App nicht behalten möchten, können Sie Ressourcen bereinigen, um Kosten zu sparen und nicht mehr benötigte Ressourcen für andere freizugeben. Löschen Sie dazu das gesamte Projekt:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. Glückwunsch!

Wir haben eine Reihe von Diensten mit Cloud Functions, App Engine und Cloud Run erstellt, um verschiedene Web-API-Endpunkte und ein Web-Frontend bereitzustellen, um eine Bibliothek von Büchern zu speichern, zu aktualisieren und zu durchsuchen. Dabei haben wir einige gute Designmuster für die REST API-Entwicklung berücksichtigt.

Behandelte Themen

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

Weitere Informationen

Wenn Sie dieses konkrete Beispiel weiter untersuchen und ausbauen möchten, finden Sie hier eine Liste mit möglichen Aspekten:

  • Nutzen Sie API Gateway, um eine gemeinsame API-Fassade für die Datenimportfunktion und den REST API-Container bereitzustellen und Funktionen wie die Verarbeitung von API-Schlüsseln für den Zugriff auf die API hinzuzufügen oder Ratenbeschränkungen für API-Nutzer zu definieren.
  • Stellen Sie das Swagger-UI-Node-Modul in der App Engine-Anwendung bereit, um die REST API zu dokumentieren und eine Testumgebung dafür anzubieten.
  • Fügen Sie dem Frontend über die vorhandene Browsing-Funktion hinaus zusätzliche Bildschirme hinzu, um die Daten zu bearbeiten und neue Bucheinträge zu erstellen. Da wir die Cloud Firestore-Datenbank verwenden, können wir die Echtzeitfunktion nutzen, um die angezeigten Buchdaten bei Änderungen zu aktualisieren.