Workshop zu serverlosen Web-APIs

1. Übersicht

Ziel dieses Codelabs ist es, Erfahrungen mit „serverlosen“ Dienste der Google Cloud Platform:

  • Cloud Functions – zum Bereitstellen kleiner Einheiten der Geschäftslogik in Form von Funktionen, die auf verschiedene Ereignisse reagieren (Pub/Sub-Nachrichten, neue Dateien in Cloud Storage, HTTP-Anfragen usw.).
  • App Engine – zum Bereitstellen und Bereitstellen von Webanwendungen, Web-APIs, mobilen Back-Ends, statischen Assets mit Funktionen zur schnellen Hoch- und Herunterskalierung
  • 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 nutzen können, um Web- und REST-APIs bereitzustellen und zu skalieren. Außerdem lernen Sie dabei einige gute RESTful-Designprinzipien kennen.

In diesem Workshop erstellen wir einen Bücherregal-Explorer mit folgenden Elementen:

  • Eine Cloud Functions-Funktion: Sie importieren das ursprüngliche Dataset der in unserer Bibliothek verfügbaren Bücher in die Dokumentdatenbank von Cloud Firestore.
  • Einen Cloud Run-Container, der eine REST API über den Inhalt unserer Datenbank bereitstellt
  • Ein App Engine-Web-Front-End: zum Durchsuchen der Bücherliste durch Aufrufen unserer REST API.

So sieht das Web-Front-End am Ende dieses Codelabs aus:

705e014da0ca5e90.png

Aufgaben in diesem Lab

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

2. Einrichtung und Anforderungen

Umgebung für das selbstbestimmte Lernen einrichten

  1. Melden Sie sich in der Google Cloud Console an und erstellen Sie ein neues Projekt oder verwenden Sie ein vorhandenes Projekt. 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 Projektteilnehmer. Es handelt sich um eine Zeichenfolge, die von Google APIs nicht verwendet wird. Sie können sie jederzeit aktualisieren.
  • Die Projekt-ID ist für alle Google Cloud-Projekte eindeutig und unveränderlich. Sie kann nach dem Festlegen nicht mehr geändert werden. Die Cloud Console generiert automatisch einen eindeutigen String. ist Ihnen meist egal, was es ist. In den meisten Codelabs musst du auf deine Projekt-ID verweisen, die üblicherweise als PROJECT_ID bezeichnet wird. Wenn Ihnen die generierte ID nicht gefällt, können Sie eine weitere zufällige ID generieren. Alternativ können Sie einen eigenen verwenden und nachsehen, ob er verfügbar ist. Sie kann nach diesem Schritt nicht mehr geändert werden und bleibt für die Dauer des Projekts erhalten.
  • Zur Information gibt es noch einen dritten Wert, die Projektnummer, die von manchen APIs verwendet wird. Weitere Informationen zu allen drei Werten finden Sie in der Dokumentation.
  1. Als Nächstes müssen Sie in der Cloud Console die Abrechnung aktivieren, um Cloud-Ressourcen/APIs verwenden zu können. Dieses Codelab ist kostengünstig. Sie können die von Ihnen erstellten Ressourcen oder das Projekt löschen, um Ressourcen herunterzufahren, um zu vermeiden, dass über diese Anleitung hinaus Kosten anfallen. Neue Google Cloud-Nutzer haben Anspruch auf das kostenlose Testprogramm mit 300$Guthaben.

Cloud Shell starten

Sie können Google Cloud zwar von Ihrem Laptop aus der Ferne bedienen, in diesem Codelab verwenden Sie jedoch Google Cloud Shell, 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 dauert nur einen Moment. Wenn er abgeschlossen ist, sollten Sie in etwa Folgendes sehen:

320e18fedb7fbe0.png

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

3. Umgebung vorbereiten und Cloud APIs aktivieren

Zur Nutzung der verschiedenen Dienste, die wir für dieses Projekt benötigen, werden wir einige APIs aktivieren. Dazu starten wir den folgenden Befehl in Cloud Shell:

$ 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.

Außerdem richten wir eine Umgebungsvariable ein, die wir benötigen: die Cloud-Region, in der wir die Funktion, die App und den Container bereitstellen:

$ export REGION=europe-west3

Da die Daten in der Cloud Firestore-Datenbank gespeichert werden, müssen Sie die Datenbank erstellen:

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

Später in diesem Codelab müssen wir die Daten bei der Implementierung der REST API sortieren und filtern. Zu diesem Zweck 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 Indizes entsprechen Suchanfragen, 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

Dies sind die relevanten Ordner:

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

5. Beispieldaten aus der Mediathek

Im Datenordner befindet sich eine books.json-Datei mit einer Liste von hundert Büchern, die wahrscheinlich gelesen werden sollten. 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:

[
  {
    "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 Bucheinträge in diesem Array enthalten die folgenden Informationen:

  • isbn: Die ISBN-13-Code zur Identifizierung 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. Einen Funktionsendpunkt zum Importieren von Beispielbuchdaten

In diesem ersten Abschnitt implementieren wir den Endpunkt, der zum Importieren von Beispielbuchdaten verwendet wird. Zu diesem Zweck 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. Die Cloud Functions-Laufzeit bietet im Hintergrund auch das Express-Web-Framework, sodass wir es nicht als Abhängigkeit deklarieren müssen.

In den Entwicklungsabhängigkeiten wird das Functions Framework (@google-cloud/functions-framework) deklariert. Dies ist das Laufzeit-Framework, mit dem Ihre Funktionen aufgerufen werden. 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 jedes Mal eine Änderung bereitzustellen. Dadurch wird die Feedback-Schleife für die Entwicklung verbessert.

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

$ npm install

Das Skript start stellt Ihnen mithilfe von Functions Framework einen Befehl bereit, mit dem Sie die Funktion mit der folgenden Anleitung lokal ausführen können:

$ npm start

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

Sehen wir uns nun die Datei index.js an, die die Logik unserer Funktion zum Importieren von 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 Büchersammlung (ä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;
    }
    ... 
})

Die JavaScript-Funktion „parseBooks“ wird exportiert. Diese Funktion wird später bei der Bereitstellung deklariert.

Prüfen Sie mit den nächsten Schritten Folgendes:

  • Es werden nur HTTP-POST-Anfragen akzeptiert. Andernfalls wird der Statuscode 405 zurückgegeben, um anzuzeigen, dass die anderen HTTP-Methoden nicht zulässig sind.
  • Wir akzeptieren nur application/json-Nutzlasten. Andernfalls senden wir einen 406-Statuscode, um anzugeben, dass dies kein zulässiges 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, mit dem alle Bücher im Bulk gespeichert werden sollen. Wir iterieren über das JSON-Array, das aus den Buchdetails besteht, und durchlaufen dabei die Felder isbn, title, author, language, pages und year. 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"});

Jetzt, da der Großteil der Daten bereit ist, kann der Vorgang per Commit ausgeführt werden. Wenn der Speichervorgang fehlschlägt, wird der Statuscode 400 zurückgegeben, um darauf hinzuweisen. Andernfalls können wir eine OK-Antwort mit dem Statuscode 202 zurückgeben, der anzeigt, dass die Bulk-Speicheranfrage angenommen wurde.

Importfunktion ausführen und testen

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

$ npm install

Um die Funktion mithilfe von Functions Framework lokal auszuführen, verwenden wir 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/

Mit dem folgenden Befehl 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 starten, sehen Sie die folgende Ausgabe, die bestätigt, dass die Funktion lokal ausgeführt wird:

{"status":"OK"}

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

409982568cebdbf8.png

Im Screenshot oben sehen Sie die erstellte Sammlung books, die Liste der durch die ISBN des Buchs identifizierten Buchdokumente und rechts die Details des jeweiligen Bucheintrags.

Funktion in der Cloud bereitstellen

Verwenden Sie den folgenden Befehl im Verzeichnis function-import, um die Funktion in Cloud Functions bereitzustellen:

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

Die Funktion wird mit dem symbolischen Namen bulk-import bereitgestellt. 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. Und wir verweisen auf die Quellen im lokalen Verzeichnis und verwenden parseBooks (die exportierte JavaScript-Funktion) als Einstiegspunkt.

Nach maximal wenigen Minuten wird die Funktion in der Cloud bereitgestellt. In der Cloud Console-UI sollte die Funktion angezeigt werden:

c910875d4dc0aaa8.png

In der Bereitstellungsausgabe sollten Sie die URL Ihrer Funktion sehen, die einer bestimmten Namenskonvention folgt (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}). Natürlich finden Sie diese HTTP-Trigger-URL auch in der Cloud Console-UI auf dem Trigger-Tab:

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 der bereitgestellten Funktion wiederverwenden können.

Bereitgestellte Funktion testen

Mit einem ähnlichen curl-Befehl, den wir zuvor 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

Wenn der Vorgang erfolgreich war, sollte auch hier die folgende Ausgabe zurückgegeben werden:

{"status":"OK"}

Nachdem die Importfunktion nun bereitgestellt und bereit ist, nach dem Hochladen der Beispieldaten ist es an der Zeit, die REST API zu entwickeln, die dieses Dataset verfügbar macht.

7. Der REST API-Vertrag

Obwohl wir beispielsweise keinen API-Vertrag mithilfe der OpenAPI-Spezifikation definieren, werden wir uns die verschiedenen Endpunkte unserer REST API ansehen.

Über die API werden JSON-Objekte gebucht, die aus folgenden Elementen bestehen:

  • isbn (optional): eine 13-stellige String, die für einen gültigen ISBN-Code steht,
  • author: Ein nicht leeres String für den Namen des Buchautors.
  • language – eine nicht leere String, die die Sprache enthält, in der das Buch geschrieben wurde,
  • pages: ein positiver 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
  }

HERUNTERLADEN /books

Liste aller Bücher abrufen, die möglicherweise nach Autor und/oder Sprache gefiltert und nach Fenstern mit jeweils 10 Ergebnissen gegliedert sind.

Körpernutzlast: keine.

Suchparameter:

  • author (optional) – filtert die Buchliste nach Autor,
  • language (optional): Damit wird die Buchliste nach Sprache gefiltert.
  • page (optional, Standard = 0): Gibt den Rang der zurückzugebenden Ergebnisseite an.

Gibt Folgendes zurück: ein JSON-Array mit Buchobjekten.

Status codes:

  • 200: Wenn die Anfrage das Abrufen der Bücherliste erfolgreich ist,
  • 400 – wenn ein Fehler auftritt.

POST /books und POST /books/{isbn}

Eine neue Buchnutzlast veröffentlichen, entweder mit einem isbn-Pfadparameter (in diesem Fall wird der isbn-Code in der Buchnutzlast nicht benötigt) oder ohne (in diesem Fall muss der isbn-Code in der Buchnutzlast vorhanden sein).

Körpernutzlast: ein Buchobjekt

Abfrageparameter: keine.

Es wird nichts zurückgegeben.

Status codes:

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

GET /books/{isbn}

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

Körpernutzlast: 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 kann,
  • 406: Wenn der isbn-Code ungültig ist.

PUT /books/{isbn}

Aktualisiert ein vorhandenes Buch. Dies wird anhand des isbn-Parameters identifiziert, der als Pfadparameter übergeben wird.

Körpernutzlast: ein Buchobjekt Nur die Felder, die aktualisiert werden müssen, können übergeben werden. Die anderen Felder sind optional.

Abfrageparameter: keine.

Gibt Folgendes zurück: das aktualisierte Buch.

Status codes:

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

/books/{isbn} löschen

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

Körpernutzlast: keine.

Abfrageparameter: keine.

Es wird nichts zurückgegeben.

Status codes:

  • 204: Wenn das Buch 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 zuständig 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 von Node.JS 20. Wir arbeiten im Verzeichnis /usr/src/app. Wir kopieren die Datei package.json (Details siehe unten), in der u. a. unsere Abhängigkeiten definiert sind. Wir installieren die Abhängigkeiten mit npm install und kopieren den Quellcode. Schließlich geben wir mit dem Befehl node index.js an, 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 bei Dockerfile der Fall war.

Unsere API-Webanwendung hängt von folgenden Faktoren 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 aus dem Clientcode des Front-Ends unserer App Engine-Webanwendung aufgerufen wird.
  • Das Express-Framework, unser Web-Framework für die Entwicklung unserer API,
  • Das Modul isbn3 hilft bei der Überprüfung der ISBN-Codes von Büchern.

Außerdem geben wir das Skript start an, das sich beim lokalen Starten der Anwendung zu Entwicklungs- und Testzwecken als nützlich erweist.

index.js

Kommen wir nun zur eigentlichen Struktur des Codes und sehen 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 Modul body-parser, um die mit unserer API ausgetauschten JSON-Nutzlasten zu parsen.

Das Modul querystring ist hilfreich bei der Bearbeitung von URLs. Dies ist der Fall, wenn Link-Header für die Paginierung erstellt werden (mehr dazu später).

Anschließend konfigurieren wir das Modul cors. Die Header, die über CORS übergeben werden sollen, werden explizit angegeben, da die meisten in der Regel entfernt werden. Hier möchten wir jedoch die übliche Inhaltslänge und den üblichen 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 NPM-Modul isbn3, um ISBN-Codes zu parsen und zu validieren. Außerdem entwickeln wir eine kleine Dienstfunktion, die ISBN-Codes parst und mit einem 406-Statuscode in der Antwort antwortet, wenn die ISBN-Codes ungültig sind.

  • GET /books

Sehen wir uns den Endpunkt GET /books im Detail 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 Suchparametern ab, um nach Autor und/oder Sprache zu filtern. Außerdem wird die Buchliste in Blöcken von je 10 Büchern zurückgegeben.

Tritt beim Abrufen der Bücher ein Fehler auf, geben wir einen Fehler mit dem Statuscode 400 zurück.

Zoomen wir den ausgeschnittenen Teil dieses Endpunkts heran:

        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, wobei die letzte Aktualisierung an erster Stelle steht. Außerdem wird das Ergebnis paginiert, indem wir ein Limit (die Anzahl der zurückzugebenden Elemente) und einen Offset (den Ausgangspunkt, ab dem der nächste Stapel Bücher zurückgegeben werden soll), definieren.

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.

Zum Schluss wollen wir die Erläuterungen zu diesem Endpunkt anhand einer bewährten Methode abschließen: Verwenden Sie den Link-Header, um URI-Links zur ersten, vorherigen, nächsten oder letzten Datenseite zu definieren. In unserem Fall werden nur die vorherigen und nächsten Daten angegeben.

        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 auf den ersten Blick ein wenig komplex erscheinen, aber wir fügen einen vorherigen Link hinzu, falls wir uns nicht auf der ersten Datenseite befinden. Und wir fügen einen Weiter-Link hinzu, wenn die Datenseite voll ist, d. h. die maximale Anzahl von Büchern gemäß der Konstante PAGE_SIZE enthält, vorausgesetzt, es gibt einen weiteren Link, der mehr Daten bereitstellt. Wir verwenden dann die resource#links()-Funktion von Express, um den richtigen Header mit der richtigen Syntax zu erstellen.

Zu Ihrer Information sieht die Link-Kopfzeile 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. Das eine übergibt den ISBN-Code in der Buchnutzlast, während das andere den ISBN-Code als Pfadparameter übergibt. In beiden Fällen rufen beide die Funktion createBook() auf:

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 von der Funktion zurückgegeben und der Statuscode 406 wird festgelegt. Die Buchfelder werden aus der Nutzlast abgerufen, die im Text der Anfrage übergeben wurde. Dann speichern wir die Buchdetails in Firestore. Wird 201 bei Erfolg und 400 bei Fehler zurückgegeben.

Bei erfolgreicher Rückgabe legen wir auch den Standort-Header fest, um dem Client der API Hinweise zu geben, in denen sich die neu erstellte Ressource befindet. Der Header sieht so aus:

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

Rufen Sie nun 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 senden eine Abfrage an Firestore, um das Buch abzurufen. Die Eigenschaft snapshot.exists ist nützlich, um festzustellen, ob tatsächlich ein Buch gefunden wurde. Andernfalls senden wir einen Fehler und den Statuscode 404 Not Found 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 für Datum und Uhrzeit in updated, um das Datum und die Uhrzeit der letzten Aktualisierung des Eintrags zu speichern. Wir verwenden die Strategie {merge:true}, 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, wodurch vorhandene Felder aus der vorherigen Aktualisierung oder der ursprünglichen Erstellung gelöscht werden.

Außerdem wird der Location-Header so festgelegt, dass er auf den URI des Buchs verweist.

  • DELETE /books/:isbn

Es ist ziemlich einfach, Bücher zu löschen. Wir rufen einfach die Methode delete() für die Dokumentreferenz auf. Der Statuscode 204 wird zurückgegeben, da kein Content zurückgegeben wird.

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 und überwachen standardmäßig Port 8080:

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 Sie zuerst die Abhängigkeiten mit:

$ npm install

Dann können wir mit Folgendem beginnen:

$ npm start

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

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

$ docker build -t crud-web-api .

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

Durch die Ausführung in Docker können Sie außerdem prüfen, ob die Containerisierung unserer Anwendung während der Erstellung in der Cloud mit Cloud Build problemlos läuft.

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 dafür ausführen.

  • Erstellen Sie ein neues Buch (ISBN in der Nutzdaten):
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books
  • Erstellen Sie ein neues Buch (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
  • Ein von uns erstelltes Buch löschen:
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • Bücher 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
  • Rufen Sie die Liste der Bücher (die ersten zehn) ab:
$ curl http://localhost:8080/books
  • Nach Büchern eines bestimmten Autors suchen:
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • Listen Sie die auf Englisch verfassten Bücher auf:
$ curl http://localhost:8080/books?language=English
  • Laden Sie die vierte Seite der Bücher:
$ curl http://localhost:8080/books?page=3

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

Container-REST API erstellen und bereitstellen

Da wir der Meinung sind, dass die REST API wie geplant funktioniert, ist jetzt der richtige Moment, sie in der Cloud in Cloud Run bereitzustellen.

Dazu führen wir zwei Schritte aus:

  • Erstellen Sie zuerst das Container-Image mit Cloud Build und dem folgenden Befehl:
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • Stellen Sie dann den Dienst 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 und in der Cloud-Region bereitgestellt.

Wir können in der Cloud Console-UI prüfen, ob unser Cloud Run-Dienst jetzt in der Liste angezeigt wird:

f62fbca02a8127c0.png

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

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

Im nächsten Abschnitt benötigen wir die URL unserer Cloud Run REST API, da der App Engine-Frontend-Code mit der API interagiert.

9. Web-App zum Durchsuchen der Bibliothek hosten

Der letzte Teil des Puzzles, um diesem Projekt etwas Glitzer hinzuzufügen, ist die Bereitstellung eines Web-Front-Ends, das mit unserer REST API interagiert. Zu diesem Zweck verwenden wir Google App Engine mit einigem Client-JavaScript-Code, der die API über AJAX-Anfragen (mithilfe der clientseitigen Fetch API) aufruft.

Unsere Anwendung wird zwar in der Node.JS-App Engine-Laufzeit bereitgestellt, besteht jedoch hauptsächlich aus statischen Ressourcen. Es ist nicht viel Back-End-Code vorhanden, da der Großteil der Nutzerinteraktion im Browser über clientseitiges JavaScript stattfindet. Wir verwenden kein Frontend-JavaScript-Framework, sondern lediglich Vanilla-JavaScript und einige Webkomponenten für die Benutzeroberfläche, die die Shoelace-Webkomponentenbibliothek verwenden:

  • ein Auswahlfeld, um die Sprache des Buchs auszuwählen:

6fb9f741000a2dc1.png

  • Kartenkomponente zur Anzeige der Details zu einem bestimmten Buch (einschließlich eines Barcodes zur Angabe der ISBN des Buchs unter Verwendung der JsBarcode-Bibliothek):

3aa21a9e16e3244e.png

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

3925ad81c91bbac9.png

Wenn alle diese visuellen Komponenten kombiniert werden, sieht die Webseite zum Durchsuchen unserer Bibliothek wie folgt aus:

18a5117150977d6.png

Die Konfigurationsdatei app.yaml

Beginnen wir mit der Codebasis dieser App Engine-Anwendung, indem wir uns die Konfigurationsdatei app.yaml ansehen. Dies ist eine Datei, die für App Engine spezifisch ist und mit der Sie Elemente wie Umgebungsvariablen und die verschiedenen "Handler" der Anwendung konfigurieren oder angeben können, 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-Datei ist und dass wir Version 14 verwenden möchten.

Dann definieren wir eine Umgebungsvariable, die auf unsere Cloud Run-Dienst-URL verweist. Wir müssen den Platzhalter CHANGE_ME mit der korrekten URL aktualisieren (siehe unten, wie Sie dies ändern können).

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

Aktualisieren wir 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

Oder ändern Sie den String CHANGE_ME in app.yaml manuell mit der richtigen URL:

env_variables:
    RUN_CRUD_SERVICE_URL: CHANGE_ME

Die Node.JS-Datei package.json

{
    "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 betonen noch einmal, dass wir diese Anwendung mit Node.JS 14 ausführen möchten. Wir nutzen das Express-Framework sowie das isbn3 NPM-Modul zur Validierung der Bücher ISBN-Codes.

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

$ npm run dev

Der Node.JS-Code index.js

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

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

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

Das Express-Web-Framework ist erforderlich. Wir geben an, dass das öffentliche Verzeichnis statische Inhalte enthält, die von der Middleware static bereitgestellt werden können (zumindest bei einer lokalen Ausführung im Entwicklungsmodus). Zum Schluss benötigen wir noch body-parser, um die JSON-Nutzlasten zu parsen.

Werfen wir einen Blick auf die von uns definierten Routen:

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 mit / übereinstimmende Datei leitet zu index.html in unserem public/html-Verzeichnis weiter. Da wir im Entwicklungsmodus nicht innerhalb der App Engine-Laufzeit ausgeführt werden, findet die URL-Weiterleitung von App Engine nicht statt. Stattdessen leiten wir hier einfach die Stamm-URL an die HTML-Datei weiter.

Der zweite Endpunkt, den wir definieren, /webapi gibt die URL unserer Cloud RUN REST API zurück. Auf diese Weise weiß der clientseitige JavaScript-Code, wo er die Bücherliste abrufen muss.

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 Abschluss führen wir die Express-Webanwendung aus und überwachen standardmäßig Port 8080.

Die Seite index.html

Wir werden uns nicht jede Zeile dieser langen HTML-Seite ansehen. Wir heben stattdessen einige wichtige Zeilen hervor.

<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">

Die ersten beiden Zeilen importieren die Shoelace-Webkomponentenbibliothek (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.

Über die letzten Zeilen importieren wir unseren eigenen JavaScript-Code und unser CSS-Stylesheet, die sich in unseren public/-Unterverzeichnissen befinden.

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

<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>
...

Und wir verwenden auch HTML-Vorlagen und ihre Slot-Füllfunktion, um ein Buch darzustellen. Wir erstellen Kopien dieser Vorlage, um die Liste der Bücher zu füllen, und ersetzen die Werte in den Flächen durch die Details der Bücher:

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

Ausreichend HTML: Wir sind mit der Überprüfung des Codes fast fertig. Ein letzter Teil ist noch übrig: der clientseitige app.js-JavaScript-Code, der mit unserer REST API interagiert.

Clientseitiger JavaScript-Code für app.js

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

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

Anschließend 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 mithilfe unseres App Engine-Knotencodes ab, der die Umgebungsvariable zurückgibt, die wir ursprünglich in app.yaml festgelegt haben. Dank der Umgebungsvariable, dem Endpunkt /webapi, der aus dem clientseitigen JavaScript-Code aufgerufen wird, mussten wir die REST API-URL nicht im Front-End-Code hartcodieren.

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

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

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

Wir fügen der Schaltfläche einen Ereignis-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 wie beim Auswahlfeld fügen wir einen Ereignis-Handler hinzu, um über Änderungen bei der Sprachauswahl informiert zu werden. Und wie bei der Schaltfläche rufen wir auch die appendMoreBooks()-Funktion 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 exakte URL, die zum Aufrufen der REST API verwendet werden soll. Es gibt drei Abfrageparameter, die wir normalerweise angeben können, 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 zum Filtern nach geschriebener Sprache.

Anschließend verwenden wir die Fetch API, um das JSON-Array abzurufen, das die Buchdetails enthält.

    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 Link-Header in der Antwort vorhanden ist, blenden wir die [More books...]-Schaltfläche ein oder aus, da der Link-Header einen Hinweis gibt, ob noch weitere Bücher geladen werden müssen. In der Link-Kopfzeile befindet sich eine next-URL.

    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();

Um den ISBN-Code ein wenig schöner zu machen, verwenden wir die JsBarcode-Bibliothek, um einen schönen Barcode zu erstellen, ähnlich wie auf der Rückseite echter Bücher.

Anwendung lokal ausführen und testen

Genug Code. Jetzt können Sie die Anwendung in Aktion sehen. Zuerst machen wir das lokal in Cloud Shell, bevor wir die Bereitstellung durchführen.

Wir installieren die für unsere Anwendung benötigten npm-Module mit:

$ npm install

Wir führen die App dann wie gewohnt aus:

$ npm start

Oder durch automatisches Neuladen von Änderungen mithilfe von nodemon, mit:

$ npm run dev

Die Anwendung wird lokal ausgeführt und wir können über den Browser unter http://localhost:8080 darauf zugreifen.

App Engine-Anwendung bereitstellen

Da wir nun sicher sind, dass unsere Anwendung lokal problemlos ausgeführt werden kann, ist es an der Zeit, sie in App Engine bereitzustellen.

Zum Bereitstellen der Anwendung starten wir den folgenden Befehl:

$ gcloud app deploy -q

Nach etwa einer Minute sollte die Anwendung bereitgestellt werden.

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

Die Benutzeroberfläche unserer App Engine-Webanwendung erkunden

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 anzuzeigen.
  • Sie können die Auswahl mit dem kleinen Kreuz im Auswahlfeld löschen, um zur Liste aller Bücher zurückzukehren.

10. Bereinigen (optional)

Wenn Sie die Anwendung nicht behalten möchten, können Sie Ressourcen bereinigen, um Kosten zu sparen und insgesamt ein fairer Cloud-Bürger zu sein. Löschen Sie dazu das gesamte Projekt:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. Glückwunsch!

Dank Cloud Functions, App Engine und Cloud Run haben wir eine Reihe von Diensten erstellt, um verschiedene Web-API-Endpunkte und ein Web-Front-End verfügbar zu machen, um eine Bücherbibliothek zu speichern, zu aktualisieren und zu durchsuchen. Dabei haben wir einige gute Designmuster für die REST API-Entwicklung angewandt.

Behandelte Themen

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

Der nächste Schritt

Wenn Sie sich dieses konkrete Beispiel genauer ansehen und es erweitern möchten, finden Sie im Folgenden eine Liste mit Dingen, die Sie untersuchen könnten:

  • Mithilfe von API Gateway können Sie eine gemeinsame API-Fassade für die Datenimportfunktion und den REST API-Container bereitstellen, Funktionen wie die Verarbeitung von API-Schlüsseln für den Zugriff auf die API hinzufügen oder Ratenbegrenzungen für API-Nutzer definieren.
  • Stellen Sie das Knotenmodul Swagger-UI in der App Engine-Anwendung bereit, um die REST API zu dokumentieren und zu testen.
  • Fügen Sie im Front-End über die vorhandenen Suchfunktionen hinaus zusätzliche Bildschirme hinzu, um die Daten zu bearbeiten und neue Bucheinträge zu erstellen. Da wir die Cloud Firestore-Datenbank verwenden, nutzen Sie deren Echtzeitfunktion, um die Buchdaten zu aktualisieren, die angezeigt werden, wenn Änderungen vorgenommen werden.