Warsztaty na temat bezserwerowych interfejsów internetowych

1. Przegląd

Celem tego ćwiczenia jest zdobycie doświadczenia w korzystaniu z usług „bezserwerowych” oferowanych przez Google Cloud Platform:

  • Cloud Functions – do wdrażania małych jednostek logiki biznesowej w postaci funkcji, które reagują na różne zdarzenia (wiadomości Pub/Sub, nowe pliki w Cloud Storage, żądania HTTP itp.).
  • App Engine – do wdrażania i obsługi aplikacji internetowych, internetowych interfejsów API, backendów mobilnych i statycznych zasobów z możliwością szybkiego skalowania w górę i w dół.
  • Cloud Run – do wdrażania i skalowania kontenerów, które mogą zawierać dowolny język, środowisko wykonawcze lub bibliotekę.

Dowiesz się też, jak korzystać z tych usług bezserwerowych do wdrażania i skalowania interfejsów API REST i internetowych, a także poznasz kilka dobrych zasad projektowania interfejsów API REST.

Podczas tych warsztatów utworzymy eksplorator półek na książki, który będzie się składać z:

  • Funkcja w Cloud Functions: do zaimportowania początkowego zbioru danych książek dostępnych w naszej bibliotece w bazie danych dokumentów Cloud Firestore.
  • kontener Cloud Run, który udostępni interfejs REST API dotyczący zawartości naszej bazy danych;
  • Interfejs internetowy App Engine: umożliwia przeglądanie listy książek przez wywoływanie naszego interfejsu API REST.

Oto jak będzie wyglądać frontend internetowy na końcu tego ćwiczenia:

705e014da0ca5e90.png

Czego się nauczysz

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

2. Konfiguracja i wymagania

Samodzielne konfigurowanie środowiska

  1. Zaloguj się w konsoli Google Cloud i utwórz nowy projekt lub użyj istniejącego. Jeśli nie masz jeszcze konta Gmail ani Google Workspace, musisz je utworzyć.

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • Nazwa projektu to wyświetlana nazwa uczestników tego projektu. Jest to ciąg znaków, który nie jest używany przez interfejsy API Google. Zawsze możesz ją zaktualizować.
  • Identyfikator projektu jest unikalny we wszystkich projektach Google Cloud i nie można go zmienić po ustawieniu. Konsola Cloud automatycznie generuje unikalny ciąg znaków. Zwykle nie musisz się tym przejmować. W większości ćwiczeń z programowania musisz odwoływać się do identyfikatora projektu (zwykle oznaczanego jako PROJECT_ID). Jeśli wygenerowany identyfikator Ci się nie podoba, możesz wygenerować inny losowy identyfikator. Możesz też spróbować własnej nazwy i sprawdzić, czy jest dostępna. Po tym kroku nie można go zmienić i pozostaje on taki przez cały czas trwania projektu.
  • Warto wiedzieć, że istnieje trzecia wartość, numer projektu, której używają niektóre interfejsy API. Więcej informacji o tych 3 wartościach znajdziesz w dokumentacji.
  1. Następnie musisz włączyć płatności w konsoli Cloud, aby korzystać z zasobów i interfejsów API Google Cloud. Wykonanie tego ćwiczenia nie będzie kosztować dużo, a może nawet nic. Aby wyłączyć zasoby i uniknąć naliczania opłat po zakończeniu tego samouczka, możesz usunąć utworzone zasoby lub projekt. Nowi użytkownicy Google Cloud mogą skorzystać z bezpłatnego okresu próbnego, w którym mają do dyspozycji środki w wysokości 300 USD.

Uruchamianie Cloud Shell

Z Google Cloud można korzystać zdalnie na laptopie, ale w tym ćwiczeniu użyjesz Google Cloud Shell, czyli środowiska wiersza poleceń działającego w chmurze.

W konsoli Google Cloud kliknij ikonę Cloud Shell na pasku narzędzi w prawym górnym rogu:

84688aa223b1c3a2.png

Uzyskanie dostępu do środowiska i połączenie się z nim powinno zająć tylko kilka chwil. Po zakończeniu powinno wyświetlić się coś takiego:

320e18fedb7fbe0.png

Ta maszyna wirtualna zawiera wszystkie potrzebne narzędzia dla programistów. Zawiera również stały katalog domowy o pojemności 5 GB i działa w Google Cloud, co znacznie zwiększa wydajność sieci i usprawnia proces uwierzytelniania. Wszystkie zadania w tym ćwiczeniu w Codelabs możesz wykonać w przeglądarce. Nie musisz niczego instalować.

3. Przygotowywanie środowiska i włączanie interfejsów API w chmurze

Aby korzystać z różnych usług, których będziemy potrzebować w tym projekcie, włączymy kilka interfejsów API. Aby to zrobić, uruchom w Cloud Shell to polecenie:

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

Po pewnym czasie operacja powinna się zakończyć:

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

Skonfigurujemy też zmienną środowiskową, która będzie nam potrzebna w dalszej części procesu: region chmury, w którym wdrożymy funkcję, aplikację i kontener:

$ export REGION=europe-west3

Dane będziemy przechowywać w bazie danych Cloud Firestore, więc musimy ją utworzyć:

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

W dalszej części tego ćwiczenia, podczas implementacji interfejsu API (typu) REST, będziemy musieli sortować i filtrować dane. W tym celu utworzymy 3 indeksy:

$ 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 

Te 3 indeksy odpowiadają wyszukiwaniom, które będziemy przeprowadzać według autora lub języka, zachowując jednocześnie kolejność w kolekcji za pomocą zaktualizowanego pola.

4. Pobierz kod

Pobierz kod z tego repozytorium GitHub:

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

Kod aplikacji jest napisany w Node.JS.

Będziesz mieć następującą strukturę folderów, która jest istotna w tym module:

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

Są to odpowiednie foldery:

  • data – ten folder zawiera przykładowe dane z listą 100 książek.
  • function-import – ta funkcja udostępni punkt końcowy do importowania przykładowych danych.
  • run-crud – ten kontener udostępni interfejs Web API do uzyskiwania dostępu do danych książek przechowywanych w Cloud Firestore.
  • appengine-frontend – ta aplikacja internetowa App Engine będzie wyświetlać prosty interfejs tylko do odczytu, który umożliwia przeglądanie listy książek.

5. Przykładowe dane biblioteki książek

W folderze danych mamy plik books.json, który zawiera listę 100 książek, które prawdopodobnie warto przeczytać. Ten dokument JSON to tablica zawierająca obiekty JSON. Przyjrzyjmy się strukturze danych, które będziemy pozyskiwać za pomocą funkcji w Cloud Functions:

[
  {
    "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
  },
  ...
]

Wszystkie wpisy dotyczące książek w tej tablicy zawierają te informacje:

  • isbn – kod ISBN-13 identyfikujący książkę.
  • author – imię i nazwisko autora książki.
  • language – język, w którym napisana jest książka.
  • pages – liczba stron w książce.
  • title – tytuł książki.
  • year – rok publikacji książki.

6. Punkt końcowy funkcji do importowania przykładowych danych o książkach

W tej pierwszej sekcji zaimplementujemy punkt końcowy, który będzie używany do importowania przykładowych danych o książkach. W tym celu użyjemy Cloud Functions.

Poznaj kod

Zacznijmy od pliku package.json:

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

W zależnościach środowiska wykonawczego potrzebujemy tylko modułu @google-cloud/firestore NPM, aby uzyskać dostęp do bazy danych i przechowywać dane książek. Środowisko wykonawcze Cloud Functions udostępnia też platformę internetową Express, więc nie musimy deklarować jej jako zależności.

W zależnościach programistycznych deklarujemy Functions Framework (@google-cloud/functions-framework), czyli środowisko wykonawcze używane do wywoływania funkcji. Jest to platforma open source, której możesz też używać lokalnie na swoim komputerze (w naszym przypadku w Cloud Shell), aby uruchamiać funkcje bez konieczności wdrażania ich za każdym razem, gdy wprowadzisz zmianę. Dzięki temu możesz szybciej uzyskiwać informacje zwrotne na etapie programowania.

Aby zainstalować zależności, użyj polecenia install:

$ npm install

Skrypt start korzysta z Functions Framework, aby udostępnić polecenie, za pomocą którego możesz uruchomić funkcję lokalnie, wykonując te instrukcje:

$ npm start

Do interakcji z funkcją możesz używać cURL lub podglądu w przeglądarce Cloud Shell w przypadku żądań HTTP GET.

Przyjrzyjmy się teraz plikowi index.js, który zawiera logikę naszej funkcji importu danych o książkach:

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

Tworzymy instancję modułu Firestore i wskazujemy kolekcję książek (podobną do tabeli w relacyjnych bazach danych).

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;
    }
    ... 
})

Eksportujemy funkcję JavaScript parseBooks. To funkcja, którą zadeklarujemy podczas późniejszego wdrażania.

Kolejne instrukcje sprawdzają, czy:

  • Akceptujemy tylko żądania HTTP POST, a w innych przypadkach zwracamy kod stanu 405, aby wskazać, że inne metody HTTP są niedozwolone.
  • Akceptujemy tylko ładunki application/json, a w innych przypadkach wysyłamy kod stanu 406, aby wskazać, że nie jest to akceptowalny format ładunku.
    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()
        });
    }

Następnie możemy pobrać ładunek JSON za pomocą body żądania. Przygotowujemy operację wsadową Firestore, aby zbiorczo zapisać wszystkie książki. Iterujemy po tablicy JSON zawierającej szczegóły książki, przechodząc przez pola isbn, title, author, language, pagesyear. Kod ISBN książki będzie jej kluczem podstawowym lub identyfikatorem.

    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"});

Gdy większość danych będzie gotowa, możemy zatwierdzić operację. Jeśli operacja zapisu się nie powiedzie, zwrócimy kod stanu 400, aby poinformować o niepowodzeniu. W przeciwnym razie możemy zwrócić odpowiedź OK z kodem stanu 202 wskazującym, że żądanie zapisu zbiorczego zostało zaakceptowane.

Uruchamianie i testowanie funkcji importu

Przed uruchomieniem kodu zainstalujemy zależności za pomocą tego polecenia:

$ npm install

Aby uruchomić funkcję lokalnie dzięki Functions Framework, użyjemy polecenia skryptu start zdefiniowanego w pliku package.json:

$ npm start

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

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

Aby wysłać żądanie HTTP POST do lokalnej funkcji, możesz uruchomić to polecenie:

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

Po uruchomieniu tego polecenia zobaczysz te dane wyjściowe, które potwierdzają, że funkcja działa lokalnie:

{"status":"OK"}

Możesz też przejść do interfejsu konsoli Cloud, aby sprawdzić, czy dane są rzeczywiście przechowywane w Firestore:

409982568cebdbf8.png

Na zrzucie ekranu powyżej widać utworzoną kolekcję books, listę dokumentów książek zidentyfikowanych za pomocą kodu ISBN książki oraz szczegóły tego konkretnego wpisu książki po prawej stronie.

Wdrażanie funkcji w chmurze

Aby wdrożyć funkcję w Cloud Functions, użyjemy tego polecenia w katalogu function-import:

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

Wdrażamy funkcję o symbolicznej nazwie bulk-import. Ta funkcja jest aktywowana przez żądania HTTP. Używamy środowiska wykonawczego Node.JS 20. Wdrażamy funkcję publicznie (najlepiej zabezpieczyć ten punkt końcowy). Określamy region, w którym ma się znajdować funkcja. Wskazujemy źródła w katalogu lokalnym i używamy parseBooks (eksportowanej funkcji JavaScript) jako punktu wejścia.

Po kilku minutach lub krócej funkcja zostanie wdrożona w chmurze. W interfejsie Cloud Console powinna pojawić się funkcja:

c910875d4dc0aaa8.png

W danych wyjściowych wdrożenia powinien być widoczny adres URL funkcji, który jest zgodny z określoną konwencją nazewnictwa (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}). Ten adres URL aktywatora HTTP można też znaleźć w interfejsie Cloud Console na karcie aktywatora:

380ffc46eb56441e.png

Adres URL możesz też pobrać za pomocą wiersza poleceń, używając polecenia gcloud:

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

Zapiszmy go w zmiennej środowiskowej BULK_IMPORT_URL, aby można było go użyć do testowania wdrożonej funkcji.

Testowanie wdrożonej funkcji

Za pomocą podobnego polecenia curl, którego użyliśmy wcześniej do testowania funkcji działającej lokalnie, przetestujemy wdrożoną funkcję. Jedyną zmianą będzie adres URL:

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

Jeśli operacja się uda, powinna zwrócić te dane:

{"status":"OK"}

Funkcja importu jest już wdrożona i gotowa, a przykładowe dane zostały przesłane. Czas opracować interfejs REST API, który będzie udostępniać ten zbiór danych.

7. Umowa dotycząca interfejsu API REST

Chociaż nie definiujemy kontraktu interfejsu API, np. za pomocą specyfikacji Open API, przyjrzymy się różnym punktom końcowym naszego interfejsu API REST.

Interfejs API wymienia obiekty JSON książek, które składają się z:

  • isbn (opcjonalny) – 13-znakowy String reprezentujący prawidłowy kod ISBN,
  • author – niepusty ciąg znaków String reprezentujący imię i nazwisko autora książki;
  • language – niepusty element String zawierający język, w którym napisano książkę;
  • pages – dodatnia liczba Integer oznaczająca liczbę stron książki,
  • title – niepusty element String z tytułem książki,
  • year – wartość Integer dla roku publikacji książki.

Przykładowy ładunek książki:

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

GET /books

Pobierz listę wszystkich książek, które można filtrować według autora lub języka, a także podzielić na strony po 10 wyników.

Ładunek: brak.

Parametry zapytania:

  • author (opcjonalnie) – filtruje listę książek według autora,
  • language (opcjonalnie) – filtruje listę książek według języka,
  • page (opcjonalnie, domyślnie = 0) – wskazuje pozycję strony z wynikami, która ma zostać zwrócona.

Zwraca: tablicę JSON obiektów książek.

Kody stanu:

  • 200 – gdy żądanie pobrania listy książek zakończy się powodzeniem;
  • 400 – jeśli wystąpi błąd.

POST /books i POST /books/{isbn}

Opublikuj nowy ładunek książki z parametrem ścieżki isbn (w takim przypadku kod isbn nie jest potrzebny w ładunku książki) lub bez niego (w takim przypadku kod isbn musi być obecny w ładunku książki).

Ładunek treści: obiekt książki.

Parametry zapytania: brak.

Zwraca: nic.

Kody stanu:

  • 201 – gdy książka zostanie zapisana pomyślnie,
  • 406 – jeśli kod isbn jest nieprawidłowy;
  • 400 – jeśli wystąpi błąd.

GET /books/{isbn}

Pobiera książkę z biblioteki zidentyfikowaną za pomocą isbn kodu przekazanego jako parametr ścieżki.

Ładunek: brak.

Parametry zapytania: brak.

Zwraca obiekt JSON książki lub obiekt błędu, jeśli książka nie istnieje.

Kody stanu:

  • 200 – jeśli książka zostanie znaleziona w bazie danych;
  • 400 – jeśli wystąpi błąd,
  • 404 – jeśli nie udało się znaleźć książki;
  • 406 – jeśli kod isbn jest nieprawidłowy.

PUT /books/{isbn}

Aktualizuje istniejącą książkę, która jest identyfikowana przez parametr ścieżki isbn.

Ładunek treści: obiekt książki. Możesz przekazać tylko pola, które wymagają aktualizacji. Pozostałe są opcjonalne.

Parametry zapytania: brak.

Zwraca: zaktualizowaną książkę.

Kody stanu:

  • 200 – gdy książka zostanie zaktualizowana,
  • 400 – jeśli wystąpi błąd,
  • 406 – jeśli kod isbn jest nieprawidłowy.

DELETE /books/{isbn}

Usuwa istniejącą książkę, której isbn jest przekazywany jako parametr ścieżki.

Ładunek: brak.

Parametry zapytania: brak.

Zwraca: nic.

Kody stanu:

  • 204 – gdy książka zostanie usunięta,
  • 400 – jeśli wystąpi błąd.

8. Wdrażanie i udostępnianie interfejsu REST API w kontenerze

Poznaj kod

Dockerfile

Zacznijmy od przyjrzenia się plikowi Dockerfile, który będzie odpowiedzialny za konteneryzację kodu aplikacji:

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

Używamy obrazu Node.JS 20 „slim”. Pracujemy w katalogu /usr/src/app. Kopiujemy plik package.json (szczegóły poniżej), który określa m.in. nasze zależności. Zależności instalujemy za pomocą polecenia npm install, kopiując kod źródłowy. Na koniec podajemy sposób uruchomienia aplikacji za pomocą polecenia node index.js.

package.json

Teraz możemy przyjrzeć się plikowi package.json:

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

Określamy, że chcemy używać Node.JS w wersji 14, tak jak w przypadku Dockerfile.

Nasza aplikacja interfejsu API sieciowego zależy od:

  • moduł Firestore NPM do uzyskiwania dostępu do danych książki w bazie danych;
  • Biblioteka cors do obsługi żądań CORS (Cross Origin Resource Sharing), ponieważ nasz interfejs REST API będzie wywoływany z kodu klienta frontendu aplikacji internetowej App Engine.
  • Platforma Express, która będzie naszą platformą internetową do projektowania interfejsu API.
  • Następnie isbn3 moduł, który pomaga w weryfikacji kodów ISBN książek.

Określamy też skrypt start, który przyda się do lokalnego uruchamiania aplikacji na potrzeby programowania i testowania.

index.js

Przejdźmy teraz do najważniejszej części kodu, czyli szczegółowego omówienia funkcji index.js:

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

Wymagamy modułu Firestore i odwołujemy się do kolekcji books, w której są przechowywane dane książek.

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'],
}));

Do wdrożenia interfejsu API REST używamy platformy internetowej Express. Do analizowania ładunków JSON wymienianych z naszym interfejsem API używamy modułu body-parser.

Moduł querystring jest przydatny do manipulowania adresami URL. Będzie to miało miejsce, gdy będziemy tworzyć Link nagłówki na potrzeby podziału na strony (więcej informacji na ten temat znajdziesz w dalszej części artykułu).

Następnie konfigurujemy moduł cors. Określamy nagłówki, które chcemy przekazywać za pomocą CORS, ponieważ większość z nich jest zwykle usuwana. W tym przypadku chcemy zachować zwykłą długość i typ treści, a także nagłówek Link, który określimy na potrzeby paginacji.

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;
}

Do analizowania i weryfikowania kodów ISBN użyjemy modułu isbn3 NPM. Opracujemy małą funkcję użytkową, która będzie analizować kody ISBN i w przypadku nieprawidłowych kodów ISBN zwracać w odpowiedzi kod stanu 406.

  • GET /books

Przyjrzyjmy się punktowi końcowemu GET /books po kolei:

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}`});
    }
});

Przygotowujemy zapytanie, aby móc wysłać je do bazy danych. To zapytanie będzie zależeć od opcjonalnych parametrów zapytania, które umożliwiają filtrowanie według autora lub języka. Zwracamy też listę książek w grupach po 10 książek.

Jeśli podczas pobierania książek wystąpi błąd, zwracamy błąd z kodem stanu 400.

Przyjrzyjmy się bliżej fragmentowi tego punktu końcowego:

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

W poprzedniej sekcji filtrowaliśmy według author i language, ale w tej sekcji posortujemy listę książek według daty ostatniej aktualizacji (najpierw ostatnia aktualizacja). Wyniki podzielimy na strony, określając limit (liczbę elementów do zwrócenia) i przesunięcie (punkt początkowy, od którego ma być zwracana kolejna partia książek).

Wykonujemy zapytanie, pobieramy zrzut danych i umieszczamy te wyniki w tablicy JavaScript, która zostanie zwrócona na końcu funkcji.

Zakończmy wyjaśnienia dotyczące tego punktu końcowego, przyglądając się dobrej praktyce: używaniu nagłówka Link do definiowania linków URI do pierwszej, poprzedniej, następnej lub ostatniej strony danych (w naszym przypadku podamy tylko poprzednią i następną).

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

Logika może się tu na początku wydawać nieco skomplikowana, ale dodajemy link Poprzednia, jeśli nie jesteśmy na pierwszej stronie danych. Jeśli strona danych jest pełna (tzn. zawiera maksymalną liczbę książek określoną przez stałą PAGE_SIZE, zakładając, że pojawi się kolejna strona z większą ilością danych), dodajemy link next. Następnie używamy funkcji resource#links() Expressa, aby utworzyć odpowiedni nagłówek z właściwą składnią.

Nagłówek linku będzie wyglądać mniej więcej tak:

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

Oba punkty końcowe służą do tworzenia nowej książki. Jeden przekazuje kod ISBN w ładunku książki, a drugi – jako parametr ścieżki. W obu przypadkach wywoływana jest funkcja createBook():

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}`});
    }    
}

Sprawdzamy, czy kod isbn jest prawidłowy. Jeśli nie, funkcja zwraca wartość (i ustawia kod stanu 406). Pola książki pobieramy z ładunku przekazanego w treści żądania. Następnie zapiszemy szczegóły książki w Firestore. Zwraca 201 w przypadku powodzenia i 400 w przypadku niepowodzenia.

W przypadku pomyślnego zwrócenia ustawiamy też nagłówek lokalizacji, aby przekazać klientowi interfejsu API informacje o tym, gdzie znajduje się nowo utworzony zasób. Nagłówek będzie wyglądać tak:

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

Pobierzmy książkę z Firestore, identyfikowaną za pomocą numeru ISBN.

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}`});
    }
});

Jak zawsze sprawdzamy, czy numer ISBN jest prawidłowy. Wysyłamy zapytanie do Firestore, aby pobrać książkę. Właściwość snapshot.exists jest przydatna, aby sprawdzić, czy książka została znaleziona. W przeciwnym razie zwracamy błąd i kod stanu 404 Nie znaleziono. Pobieramy dane książki i tworzymy obiekt JSON reprezentujący książkę, który zostanie zwrócony.

  • PUT /books/:isbn

Używamy metody PUT do aktualizowania istniejącej książki.

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}`});
    }    
});

Aktualizujemy pole updated data/godzina, aby zapamiętać, kiedy ostatnio zaktualizowaliśmy ten rekord. Stosujemy strategię {merge:true}, która zastępuje istniejące pola nowymi wartościami (w przeciwnym razie wszystkie pola zostaną usunięte i zapisane zostaną tylko nowe pola w ładunku, co spowoduje wymazanie istniejących pól z poprzedniej aktualizacji lub początkowego utworzenia).

Ustawiamy też nagłówek Location, aby wskazywał identyfikator URI książki.

  • DELETE /books/:isbn

Usuwanie książek jest dość proste. Po prostu wywołujemy metodę delete() na odwołaniu do dokumentu. Zwracamy kod stanu 204, ponieważ nie zwracamy żadnych treści.

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}`});
    }
});

Uruchom serwer Express / Node

Na koniec uruchamiamy serwer, który domyślnie nasłuchuje na porcie 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}`);
});

Lokalne uruchamianie aplikacji

Aby uruchomić aplikację lokalnie, najpierw zainstalujemy zależności za pomocą tego polecenia:

$ npm install

Możemy zacząć od:

$ npm start

Serwer uruchomi się na localhost i domyślnie będzie nasłuchiwać na porcie 8080.

Możesz też utworzyć kontener Dockera i uruchomić obraz kontenera za pomocą tych poleceń:

$ docker build -t crud-web-api .

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

Uruchamianie w Dockerze to też świetny sposób na sprawdzenie, czy konteneryzacja aplikacji będzie działać prawidłowo podczas kompilowania jej w chmurze za pomocą Cloud Build.

Testowanie interfejsu API

Niezależnie od tego, jak uruchomimy kod interfejsu REST API (bezpośrednio za pomocą Node lub za pomocą obrazu kontenera Dockera), możemy teraz uruchomić kilka zapytań.

  • Utwórz nową książkę (ISBN w treści żądania):
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books
  • Utwórz nową książkę (ISBN w parametrze ścieżki):
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books/9782070368228
  • Usuń książkę (tę, którą utworzyliśmy):
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • Pobieranie książki według numeru ISBN:
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • Aby zaktualizować istniejącą książkę, zmieniając tylko jej tytuł:
$ curl -XPUT \
       -d '{"title":"Book"}' \
       -H "Content-Type: application/json" \      
       http://localhost:8080/books/9780003701203
  • Pobierz listę książek (pierwszych 10):
$ curl http://localhost:8080/books
  • Znajdź książki napisane przez konkretnego autora:
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • Wymień książki napisane w języku angielskim:
$ curl http://localhost:8080/books?language=English
  • Wczytaj 4 stronę książek:
$ curl http://localhost:8080/books?page=3

Możemy też połączyć parametry zapytania author, languagebooks, aby zawęzić wyszukiwanie.

Kompilowanie i wdrażanie interfejsu API typu REST w kontenerze

Cieszymy się, że interfejs REST API działa zgodnie z planem, więc nadszedł czas, aby wdrożyć go w chmurze, w Cloud Run.

Zrobimy to w 2 krokach:

  • Najpierw utwórz obraz kontenera za pomocą Cloud Build, używając tego polecenia:
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • Następnie wdróż usługę za pomocą tego drugiego polecenia:
$ gcloud run deploy run-crud \
         --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
         --allow-unauthenticated \
         --region=${REGION} \
         --platform=managed

Pierwsze polecenie powoduje, że Cloud Build tworzy obraz kontenera i hostuje go w Container Registry. Następne polecenie wdraża obraz kontenera z rejestru w regionie chmury.

W interfejsie konsoli Cloud możemy sprawdzić, czy usługa Cloud Run jest teraz widoczna na liście:

f62fbca02a8127c0.png

Ostatnim krokiem będzie pobranie adresu URL nowo wdrożonej usługi Cloud Run za pomocą tego polecenia:

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

W następnej sekcji będziemy potrzebować adresu URL interfejsu API REST Cloud Run, ponieważ kod frontendu App Engine będzie z nim wchodzić w interakcję.

9. Hostowanie aplikacji internetowej do przeglądania biblioteki

Ostatnim elementem układanki, który doda blasku temu projektowi, jest frontend internetowy, który będzie współpracować z naszym API (typu) REST. W tym celu użyjemy Google App Engine z kodem JavaScript po stronie klienta, który będzie wywoływać interfejs API za pomocą żądań AJAX (przy użyciu interfejsu Fetch po stronie klienta).

Nasza aplikacja, choć wdrożona w środowisku wykonawczym Node.JS App Engine, składa się głównie z zasobów statycznych. Nie ma zbyt wiele kodu backendu, ponieważ większość interakcji użytkownika będzie odbywać się w przeglądarce za pomocą JavaScriptu po stronie klienta. Nie będziemy używać żadnej zaawansowanej platformy JavaScript po stronie klienta, tylko „czysty” JavaScript z kilkoma komponentami internetowymi do interfejsu użytkownika, korzystając z biblioteki komponentów internetowych Shoelace:

  • pole wyboru, w którym można wybrać język książki;

6fb9f741000a2dc1.png

  • komponent karty do wyświetlania szczegółów dotyczących konkretnej książki (w tym kodu kreskowego reprezentującego numer ISBN książki, z użyciem biblioteki JsBarcode):

3aa21a9e16e3244e.png

  • i przycisk wczytywania większej liczby książek z bazy danych:

3925ad81c91bbac9.png

Po połączeniu wszystkich tych komponentów wizualnych wynikowa strona internetowa do przeglądania naszej biblioteki będzie wyglądać tak:

18a5117150977d6.png

app.yaml plik konfiguracji,

Zacznijmy od zagłębienia się w bazę kodu tej aplikacji App Engine, przyglądając się jej app.yaml plikowi konfiguracyjnemu. Jest to plik specyficzny dla App Engine, który umożliwia konfigurowanie takich elementów jak zmienne środowiskowe, różne „procedury obsługi” aplikacji czy określanie, że niektóre zasoby są statycznymi zasobami, które będą obsługiwane przez wbudowaną sieć CDN App Engine.

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

Określamy, że nasza aplikacja jest aplikacją Node.JS i że chcemy używać wersji 14.

Następnie definiujemy zmienną środowiskową, która wskazuje adres URL usługi Cloud Run. Musimy zastąpić symbol zastępczy CHANGE_ME prawidłowym adresem URL (instrukcje znajdziesz poniżej).

Następnie definiujemy różne moduły obsługi. Pierwsze 3 wskazują lokalizację kodu po stronie klienta HTML, CSS i JavaScript w folderze public/ i jego podfolderach. Czwarta wskazuje, że główny adres URL aplikacji App Engine powinien wskazywać stronę index.html. Dzięki temu podczas uzyskiwania dostępu do głównego katalogu witryny nie będziemy widzieć sufiksu index.html w adresie URL. Ostatni z nich to domyślny, który będzie kierować wszystkie inne adresy URL (/.*) do naszej aplikacji Node.JS (czyli „dynamicznej” części aplikacji, w przeciwieństwie do opisanych przez nas statycznych komponentów).

Teraz zaktualizujmy adres URL interfejsu Web API usługi Cloud Run.

W katalogu appengine-frontend/ uruchom to polecenie, aby zaktualizować zmienną środowiskową wskazującą adres URL naszego interfejsu REST API opartego na Cloud Run:

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

Możesz też ręcznie zmienić ciąg CHANGE_MEapp.yaml na prawidłowy adres URL:

env_variables:
    RUN_CRUD_SERVICE_URL: CHANGE_ME

package.json Plik Node.JS

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

Jeszcze raz podkreślamy, że chcemy uruchomić tę aplikację za pomocą Node.JS 14. Korzystamy z platformy Express oraz modułu isbn3 NPM do weryfikacji kodów ISBN książek.

W zależnościach deweloperskich użyjemy modułu nodemon do monitorowania zmian w plikach. Chociaż możemy uruchomić aplikację lokalnie za pomocą npm start, wprowadzić zmiany w kodzie, zatrzymać aplikację za pomocą ^C, a następnie uruchomić ją ponownie, jest to nieco żmudne. Zamiast tego możemy użyć tego polecenia, aby aplikacja była automatycznie przeładowywana lub ponownie uruchamiana po wprowadzeniu zmian:

$ npm run dev

index.js Kod Node.JS

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

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

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

Wymagamy platformy internetowej Express. Określamy, że katalog publiczny zawiera statyczne zasoby, które mogą być obsługiwane (przynajmniej podczas uruchamiania lokalnego w trybie deweloperskim) przez oprogramowanie pośredniczące static. Do analizowania ładunków JSON wymagamy body-parser.

Przyjrzyjmy się zdefiniowanym przez nas trasom:

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

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

Pierwszy pasujący do / zostanie przekierowany do index.html w naszym katalogu public/html. W trybie programowania nie działamy w środowisku wykonawczym App Engine, więc nie korzystamy z routingu adresów URL App Engine. Dlatego przekierowujemy adres URL do pliku HTML.

Drugi zdefiniowany przez nas punkt końcowy /webapi zwróci adres URL naszego interfejsu API REST Cloud Run. Dzięki temu kod JavaScriptu po stronie klienta będzie wiedzieć, gdzie wywołać listę książek.

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}`);
});

Na koniec uruchamiamy aplikację internetową Express i domyślnie nasłuchujemy na porcie 8080.

index.html strona

Nie będziemy analizować każdej linii tego długiego kodu HTML. Zamiast tego wyróżnijmy kilka kluczowych linii.

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

Pierwsze 2 wiersze importują bibliotekę komponentów internetowych Shoelace (skrypt i arkusz stylów).

Następny wiersz importuje bibliotekę JsBarcode, aby utworzyć kody kreskowe kodów ISBN książek.

Ostatnie wiersze importują nasz własny kod JavaScript i arkusz stylów CSS, które znajdują się w naszych podkatalogach public/.

W sekcji body strony HTML używamy komponentów Shoelace z ich tagami elementów niestandardowych, np.:

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

Do reprezentowania książki używamy też szablonów HTML i ich funkcji wypełniania miejsc. Utworzymy kopie tego szablonu, aby wypełnić listę książek, i zastąpimy wartości w miejscach docelowych szczegółami książek:

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

Wystarczy już HTML. Prawie skończyliśmy sprawdzanie kodu. Pozostała jeszcze jedna ważna część: kod JavaScript po stronie klienta app.js, który wchodzi w interakcje z naszym interfejsem API REST.

Kod JavaScript po stronie klienta w pliku app.js

Zaczynamy od detektora zdarzeń najwyższego poziomu, który czeka na wczytanie treści DOM:

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

Gdy będzie gotowy, możemy skonfigurować kilka kluczowych stałych i zmiennych:

    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 = '';

Najpierw pobierzemy adres URL naszego interfejsu REST API dzięki kodowi węzła App Engine, który zwraca zmienną środowiskową ustawioną początkowo w app.yaml. Dzięki zmiennej środowiskowej /webapi, wywoływanej z kodu JavaScript po stronie klienta, nie musieliśmy na stałe kodować adresu URL interfejsu API (typu) REST w naszym kodzie frontendowym.

Definiujemy też zmienne pagelanguage, których będziemy używać do śledzenia paginacji i filtrowania języka.

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

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

Dodajemy do przycisku moduł obsługi zdarzeń, który wczytuje książki. Po kliknięciu wywoła funkcję appendMoreBooks().

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

Podobnie w przypadku pola wyboru dodajemy procedurę obsługi zdarzeń, aby otrzymywać powiadomienia o zmianach w wyborze języka. Podobnie jak w przypadku przycisku wywołujemy też funkcję appendMoreBooks(), przekazując adres URL interfejsu API REST, bieżącą stronę i wybrany język.

Przyjrzyjmy się funkcji, która pobiera i dołącza książki:

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

Powyżej tworzymy dokładny adres URL, który będzie używany do wywoływania interfejsu API REST. Zwykle możemy określić 3 parametry zapytania, ale w tym interfejsie podajemy tylko 2:

  • page – liczba całkowita wskazująca bieżącą stronę w przypadku paginacji książek;
  • language – ciąg znaków języka, według którego chcesz filtrować.

Następnie używamy interfejsu Fetch API, aby pobrać tablicę JSON zawierającą szczegóły naszych książek.

    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';
    }

W zależności od tego, czy w odpowiedzi występuje nagłówek Link, będziemy wyświetlać lub ukrywać przycisk [More books...], ponieważ nagłówek Link informuje nas, czy jest więcej książek do wczytania (w nagłówku Link będzie adres URL next).

    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);
        ... 
    }
}

W powyższej sekcji funkcji dla każdej książki zwróconej przez interfejs API typu REST sklonujemy szablon z komponentami internetowymi reprezentującymi książkę i wypełnimy miejsca w szablonie szczegółami książki.

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

Aby kod ISBN wyglądał lepiej, używamy biblioteki JsBarcode do utworzenia ładnego kodu kreskowego, takiego jak na tylnej okładce prawdziwych książek.

Uruchamianie i testowanie aplikacji lokalnie

Na razie wystarczy kodu. Czas zobaczyć aplikację w akcji. Najpierw zrobimy to lokalnie w Cloud Shell, a potem wdrożymy aplikację na serwerze.

Instalujemy moduły NPM potrzebne naszej aplikacji za pomocą tego polecenia:

$ npm install

Uruchamiamy aplikację w zwykły sposób:

$ npm start

lub z automatycznym ponownym wczytywaniem zmian dzięki nodemon:

$ npm run dev

Aplikacja działa lokalnie i możemy uzyskać do niej dostęp z przeglądarki pod adresem http://localhost:8080.

Wdrażanie aplikacji App Engine

Teraz, gdy mamy pewność, że aplikacja działa prawidłowo lokalnie, możemy ją wdrożyć w App Engine.

Aby wdrożyć aplikację, uruchom to polecenie:

$ gcloud app deploy -q

Po około minucie aplikacja powinna zostać wdrożona.

Aplikacja będzie dostępna pod adresem URL w formacie https://${GOOGLE_CLOUD_PROJECT}.appspot.com.

Poznawanie interfejsu aplikacji internetowej App Engine

Teraz możesz:

  • Aby wczytać więcej książek, kliknij przycisk [More books...].
  • Wybierz konkretny język, aby wyświetlić tylko książki w tym języku.
  • Aby wyczyścić wybór i wrócić do listy wszystkich książek, kliknij mały krzyżyk w polu wyboru.

10. Zwalnianie miejsca (opcjonalnie)

Jeśli nie zamierzasz zachować aplikacji, możesz zwalniać miejsce, aby zaoszczędzić koszty i być dobrym użytkownikiem chmury. W tym celu usuń cały projekt:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. Gratulacje!

Dzięki Cloud Functions, App Engine i Cloud Run utworzyliśmy zestaw usług, które udostępniają różne punkty końcowe interfejsu Web API i frontend internetowy, aby przechowywać, aktualizować i przeglądać bibliotekę książek. Przy okazji zastosowaliśmy kilka dobrych wzorców projektowych do tworzenia interfejsu API (typu) REST.

Omówione zagadnienia

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

Więcej informacji

Jeśli chcesz dokładniej przeanalizować ten konkretny przykład i go rozwinąć, możesz sprawdzić te kwestie:

  • Skorzystaj z API Gateway, aby zapewnić wspólny interfejs API dla funkcji importowania danych i kontenera interfejsu API REST, dodać funkcje takie jak obsługa kluczy API w celu uzyskania dostępu do interfejsu API lub zdefiniować ograniczenia szybkości dla użytkowników interfejsu API.
  • Wdróż moduł węzła Swagger-UI w aplikacji App Engine, aby udokumentować interfejs API REST i zaoferować środowisko testowe.
  • Po stronie klienta, oprócz istniejącej możliwości przeglądania, dodaj dodatkowe ekrany do edytowania danych i tworzenia nowych wpisów książek. Ponieważ korzystamy z bazy danych Cloud Firestore, wykorzystaj jej funkcję czasu rzeczywistego, aby aktualizować wyświetlane dane książki w miarę wprowadzania zmian.