1. Panoramica
L'obiettivo di questo codelab è acquisire esperienza con il "serverless" servizi offerti dalla piattaforma Google Cloud:
- Cloud Functions: per eseguire il deployment di piccole unità di logica di business sotto forma di funzioni, che reagiscono a vari eventi (messaggi Pub/Sub, nuovi file in Cloud Storage, richieste HTTP e altro ancora),
- App Engine: per eseguire il deployment e la pubblicazione di app web, API web, backend per dispositivi mobili, asset statici, con funzionalità di scale up e scale down rapide.
- Cloud Run: per il deployment e la scalabilità di container che possono contenere qualsiasi linguaggio, runtime o libreria.
Scoprire come sfruttare questi servizi serverless per eseguire il deployment e scalare le API web e REST, scoprendo al contempo alcuni principi di progettazione RESTful.
In questo workshop creeremo un esploratore per scaffali composto da:
- Una funzione Cloud Functions: per importare il set di dati iniziale dei libri disponibili nella nostra libreria, nel database di documenti Cloud Firestore,
- Un container Cloud Run: che esporrà un'API REST sul contenuto del nostro database,
- Un frontend web di App Engine: per sfogliare l'elenco dei libri, chiamando la nostra API REST.
Ecco come sarà il frontend web alla fine di questo codelab:
Cosa imparerai a fare
- Cloud Functions
- Cloud Firestore
- Cloud Run
- App Engine
2. Configurazione e requisiti
Configurazione dell'ambiente da seguire in modo autonomo
- Accedi alla console Google Cloud e crea un nuovo progetto o riutilizzane uno esistente. Se non hai ancora un account Gmail o Google Workspace, devi crearne uno.
- Il Nome progetto è il nome visualizzato dei partecipanti del progetto. Si tratta di una stringa di caratteri non utilizzata dalle API di Google. Puoi sempre aggiornarla.
- L'ID progetto è univoco in tutti i progetti Google Cloud ed è immutabile (non può essere modificato dopo essere stato impostato). La console Cloud genera automaticamente una stringa univoca. di solito non ti importa cosa sia. Nella maggior parte dei codelab, dovrai fare riferimento al tuo ID progetto (in genere identificato come
PROJECT_ID
). Se l'ID generato non ti soddisfa, potresti generarne un altro casuale. In alternativa, puoi provarne una personalizzata per verificare se è disponibile. Non può essere modificato dopo questo passaggio e rimane per tutta la durata del progetto. - Per informazione, c'è un terzo valore, un numero di progetto, utilizzato da alcune API. Scopri di più su tutti e tre questi valori nella documentazione.
- Successivamente, dovrai abilitare la fatturazione nella console Cloud per utilizzare risorse/API Cloud. L'esecuzione di questo codelab non ha alcun costo. Per arrestare le risorse ed evitare di incorrere in fatturazione dopo questo tutorial, puoi eliminare le risorse che hai creato o eliminare il progetto. I nuovi utenti di Google Cloud sono idonei al programma prova senza costi di 300$.
Avvia Cloud Shell
Anche se Google Cloud può essere utilizzato da remoto dal tuo laptop, in questo codelab utilizzerai Google Cloud Shell, un ambiente a riga di comando in esecuzione nel cloud.
Dalla console Google Cloud, fai clic sull'icona di Cloud Shell nella barra degli strumenti in alto a destra:
Dovrebbe richiedere solo qualche istante per eseguire il provisioning e connettersi all'ambiente. Al termine, dovresti vedere una schermata simile al seguente:
Questa macchina virtuale viene caricata con tutti gli strumenti di sviluppo necessari. Offre una home directory permanente da 5 GB e viene eseguita su Google Cloud, migliorando notevolmente le prestazioni di rete e l'autenticazione. Tutto il lavoro in questo codelab può essere svolto all'interno di un browser. Non occorre installare nulla.
3. Prepara l'ambiente e abilita le API cloud
Per utilizzare i vari servizi di cui avremo bisogno nel corso di questo progetto, abiliteremo alcune API. Per farlo, avvia il comando seguente in Cloud Shell:
$ gcloud services enable \ appengine.googleapis.com \ cloudbuild.googleapis.com \ cloudfunctions.googleapis.com \ compute.googleapis.com \ firestore.googleapis.com \ run.googleapis.com
Dopo un po' di tempo, l'operazione dovrebbe essere completata correttamente:
Operation "operations/acf.5c5ef4f6-f734-455d-b2f0-ee70b5a17322" finished successfully.
Configureremo anche una variabile di ambiente di cui avremo bisogno lungo il percorso: la regione cloud in cui eseguiremo il deployment della funzione, dell'app e del container:
$ export REGION=europe-west3
Dato che archivieremo i dati nel database Cloud Firestore, dovremo creare il database:
$ gcloud app create --region=${REGION} $ gcloud firestore databases create --location=${REGION}
Più avanti in questo codelab, durante l'implementazione dell'API REST, dovremo ordinare e filtrare i dati. A questo scopo, creeremo tre indici:
$ 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
Questi tre indici corrispondono alle ricerche che effettueremo per autore o lingua, mantenendo l'ordinamento nella raccolta tramite un campo aggiornato.
4. Ottieni il codice
Recupera il codice dal seguente repository GitHub:
$ git clone https://github.com/glaforge/serverless-web-apis
Il codice dell'applicazione è scritto utilizzando Node.JS.
Troverai la seguente struttura di cartelle pertinente per questo lab:
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
Le cartelle pertinenti:
data
: questa cartella contiene i dati di esempio di un elenco di 100 libri.function-import
: questa funzione offrirà un endpoint per importare i dati di esempio.run-crud
: questo contenitore esporrà un'API web per accedere ai dati dei libri archiviati in Cloud Firestore.appengine-frontend
: questa applicazione web di App Engine mostrerà un semplice frontend di sola lettura per sfogliare l'elenco di libri.
5. Dati della raccolta di libri di esempio
Nella cartella dei dati è presente un file books.json
che contiene un elenco di cento libri, che probabilmente vale la pena leggere. Questo documento JSON è un array contenente oggetti JSON. Diamo un'occhiata alla forma dei dati che verranno importati tramite una funzione 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
},
...
]
Tutte le voci dei libri in questo array contengono le seguenti informazioni:
isbn
: il codice ISBN-13 che identifica il libro.author
: il nome dell'autore del libro.language
: la lingua parlata in cui è scritto il libro.pages
: il numero di pagine del libro.title
: il titolo del libro.year
: l'anno di pubblicazione del libro.
6. Un endpoint della funzione per importare i dati dei libri di esempio
In questa prima sezione, implementeremo l'endpoint utilizzato per importare i dati dei libri di esempio. Utilizzeremo Cloud Functions a questo scopo.
Esplora il codice
Iniziamo osservando il file 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"
}
}
Nelle dipendenze del runtime, abbiamo bisogno solo del modulo di Gestione dei partner di rete @google-cloud/firestore
per accedere al database e archiviare i dati dei nostri libri. Di base, il runtime di Cloud Functions fornisce anche il framework web Express, quindi non è necessario dichiararlo come dipendenza.
Nelle dipendenze di sviluppo, dichiariamo il framework delle funzioni (@google-cloud/functions-framework
), che è il framework di runtime utilizzato per richiamare le funzioni. È un framework open source che puoi utilizzare anche localmente sulla tua macchina (in questo caso, all'interno di Cloud Shell) per eseguire funzioni senza eseguire il deployment ogni volta che apporti una modifica, migliorando così il ciclo di feedback dello sviluppo.
Per installare le dipendenze, usa il comando install
:
$ npm install
Lo script start
utilizza il framework di Functions per darti un comando che puoi usare per eseguire la funzione in locale con la seguente istruzione:
$ npm start
Puoi utilizzare curl o, potenzialmente, l'anteprima web di Cloud Shell per le richieste HTTP GET per interagire con la funzione.
Ora diamo un'occhiata al file index.js
che contiene la logica della funzione di importazione dei dati dei libri:
const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');
Creiamo un'istanza per il modulo Firestore e puntiamo alla raccolta di libri (simile a una tabella in database relazionali).
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;
}
...
})
Stiamo esportando la funzione JavaScript parseBooks
. Questa è la funzione che dichiariamo quando ne eseguiremo il deployment in seguito.
Ecco le istruzioni seguenti:
- Accettiamo solo richieste
POST
HTTP e restituiamo un codice di stato405
per indicare che gli altri metodi HTTP non sono consentiti. - Accettiamo solo
application/json
payload, altrimenti inviamo un codice di stato406
per indicare che questo non è un formato di payload accettabile.
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()
});
}
Possiamo quindi recuperare il payload JSON tramite il body
della richiesta. Stiamo preparando un'operazione batch Firestore, per archiviare tutti i libri in blocco. Eseguiamo l'iterazione sull'array JSON costituito dai dettagli dei libri, esaminando i campi isbn
, title
, author
, language
, pages
e year
. Il codice ISBN del libro fungerà da chiave principale o identificativo.
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"});
Ora che la maggior parte dei dati è pronta, possiamo eseguire il commit dell'operazione. Se l'operazione di archiviazione non va a buon fine, viene restituito un codice di stato 400
che ne indica l'esito. In caso contrario, possiamo restituire una risposta corretta, con un codice di stato 202
che indica che la richiesta di salvataggio collettivo è stata accettata.
Esecuzione e test della funzione di importazione
Prima di eseguire il codice, installeremo le dipendenze con:
$ npm install
Per eseguire la funzione in locale, grazie al framework di Functions, utilizzeremo il comando di script start
definito in package.json
:
$ npm start > start > npx @google-cloud/functions-framework --target=parseBooks Serving function... Function: parseBooks URL: http://localhost:8080/
Per inviare una richiesta POST
HTTP alla tua funzione locale, puoi eseguire:
$ curl -d "@../data/books.json" \ -H "Content-Type: application/json" \ http://localhost:8080/
All'avvio di questo comando, viene visualizzato l'output seguente, a conferma che la funzione è in esecuzione localmente:
{"status":"OK"}
Puoi anche andare all'interfaccia utente della console Cloud per verificare che i dati siano effettivamente archiviati in Firestore:
Nello screenshot riportato sopra, possiamo vedere la raccolta books
creata, l'elenco di documenti dei libri identificati dal codice ISBN del libro e i dettagli della voce in questione a destra.
Deployment della funzione nel cloud
Per eseguire il deployment della funzione in Cloud Functions, utilizzeremo questo comando nella directory function-import
:
$ gcloud functions deploy bulk-import \ --gen2 \ --trigger-http \ --runtime=nodejs20 \ --allow-unauthenticated \ --max-instances=30 --region=${REGION} \ --source=. \ --entry-point=parseBooks
Eseguiamo il deployment della funzione con il nome simbolico bulk-import
. Questa funzione viene attivata tramite richieste HTTP. Utilizziamo il runtime Node.JS 20. Eseguiamo il deployment della funzione pubblicamente (idealmente dovremmo proteggere questo endpoint). Specifichiamo la regione in cui vogliamo risiedere la funzione. Inoltre, puntiamo alle sorgenti nella directory locale e usiamo parseBooks
(la funzione JavaScript esportata) come punto di ingresso.
Dopo un paio di minuti o meno, il deployment della funzione viene eseguito nel cloud. Nell'interfaccia utente di Cloud Console, dovresti vedere questa funzione:
Nell'output del deployment, dovresti essere in grado di vedere l'URL della funzione, che segue una determinata convenzione di denominazione (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}
) e, naturalmente, puoi trovare questo URL del trigger HTTP anche nell'interfaccia utente di Cloud Console, nella scheda del trigger:
Puoi recuperare l'URL anche tramite la riga di comando con gcloud
:
$ export BULK_IMPORT_URL=$(gcloud functions describe bulk-import \ --region=$REGION \ --format 'value(httpsTrigger.url)') $ echo $BULK_IMPORT_URL
Archiviamo la funzione nella variabile di ambiente BULK_IMPORT_URL
, in modo da poterla riutilizzare per testare la funzione di cui è stato eseguito il deployment.
Test della funzione di cui è stato eseguito il deployment
Con un comando curl simile a quello che abbiamo utilizzato in precedenza per testare la funzione eseguita localmente, testeremo la funzione di cui è stato eseguito il deployment. L'unica modifica sarà l'URL:
$ curl -d "@../data/books.json" \ -H "Content-Type: application/json" \ $BULK_IMPORT_URL
Anche in questo caso, se l'operazione ha esito positivo, dovrebbe essere restituito il seguente output:
{"status":"OK"}
Ora che il deployment della funzione di importazione è stato eseguito ed è pronto, che abbiamo caricato i dati di esempio, è il momento di sviluppare l'API REST che espone questo set di dati.
7. Il contratto dell'API REST
Anche se non definiremo un contratto API usando, ad esempio, la specifica Open API, daremo un'occhiata ai vari endpoint della nostra API REST.
L'API scambia oggetti JSON dei libri, costituiti da:
isbn
(facoltativo): unString
di 13 caratteri che rappresenta un codice ISBN valido,author
: un valoreString
non vuoto che rappresenta il nome dell'autore del libro,language
: un valoreString
non vuoto contenente la lingua in cui è stato scritto il libro,pages
:Integer
positivo per il numero di pagine del libro,title
: un valoreString
non vuoto con il titolo del libro,year
: un valoreInteger
per l'anno di pubblicazione del libro.
Payload del libro di esempio:
{
"isbn": "9780435272463",
"author": "Chinua Achebe",
"language": "English",
"pages": 209,
"title": "Things Fall Apart",
"year": 1958
}
SCARICA /books
Visualizza l'elenco di tutti i libri, potenzialmente filtrato per autore e/o lingua e impaginato in base a finestre di 10 risultati alla volta.
Payload del corpo: nessuno.
Parametri di ricerca:
author
(facoltativo): filtra l'elenco dei libri per autore,language
(facoltativo): filtra l'elenco libri per lingua,page
(facoltativo, predefinito = 0): indica il ranking della pagina dei risultati da restituire.
Restituisce: un array JSON di oggetti libro.
Codici di stato:
200
: quando la richiesta riesce a recuperare l'elenco di libri,400
: se si verifica un errore.
POST /books e POST /books/{isbn}
Pubblica un nuovo payload del libro, con un parametro di percorso isbn
(in questo caso il codice isbn
non è necessario nel payload del libro) o senza (in questo caso il codice isbn
deve essere presente nel payload del libro)
Payload del corpo: un oggetto libro.
Parametri di ricerca: nessuno.
Restituisce: nulla.
Codici di stato:
201
: quando il libro viene memorizzato correttamente,406
: se il codiceisbn
non è valido,400
: se si verifica un errore.
SCARICA /books/{isbn}
Recupera un libro dalla raccolta, identificato dal relativo codice isbn
, passato come parametro del percorso.
Payload del corpo: nessuno.
Parametri di ricerca: nessuno.
Restituisce: un oggetto JSON libro o un oggetto errore se il libro non esiste.
Codici di stato:
200
: se il libro si trova nel database,400
: se si verifica un errore,404
: se non è stato possibile trovare il libro,406
: se il codiceisbn
non è valido.
PUT /books/{isbn}
Aggiorna un libro esistente, identificato dal relativo isbn
trasmesso come parametro del percorso.
Payload del corpo: un oggetto libro. Puoi passare solo i campi che richiedono un aggiornamento, gli altri sono facoltativi.
Parametri di ricerca: nessuno.
Restituisce: il libro aggiornato.
Codici di stato:
200
: quando il libro viene aggiornato correttamente,400
: se si verifica un errore,406
: se il codiceisbn
non è valido.
ELIMINA /books/{isbn}
Elimina un libro esistente, identificato dal relativo isbn
trasmesso come parametro del percorso.
Payload del corpo: nessuno.
Parametri di ricerca: nessuno.
Restituisce: nulla.
Codici di stato:
204
: quando il libro viene eliminato correttamente,400
: se si verifica un errore.
8. Esegui il deployment e l'esposizione di un'API REST in un container
Esplora il codice
Dockerfile
Iniziamo esaminando Dockerfile
, che sarà responsabile della containerizzazione del codice della nostra applicazione:
FROM node:20-slim
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . ./
CMD [ "node", "index.js" ]
Stiamo utilizzando un'immagine Node.JS 20 "slim". Ci stiamo occupando della directory /usr/src/app
. Tra le altre cose, stiamo copiando il file package.json
(vedi i dettagli di seguito) che definisce le nostre dipendenze. Installiamo le dipendenze con npm install
, copiando il codice sorgente. Infine, indichiamo come deve essere eseguita questa applicazione con il comando node index.js
.
package.json
Ora possiamo esaminare il file 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"
}
}
Specifichiamo che vogliamo utilizzare Node.JS 14, come nel caso di Dockerfile
.
La nostra applicazione API web dipende da:
- Il modulo Gestione dei partner di rete di Firestore per accedere ai dati dei libri nel database.
- La libreria
cors
per gestire le richieste CORS (condivisione delle risorse tra origini), poiché la nostra API REST viene richiamata dal codice client del frontend dell'applicazione web di App Engine. - Il framework Express, che sarà il nostro framework web per la progettazione dell'API.
- Poi il modulo
isbn3
, che consente di convalidare i codici ISBN dei libri.
Specifica inoltre lo script start
, che ti sarà utile per avviare l'applicazione localmente, a scopo di sviluppo e test.
index.js
Passiamo all'uso generale del codice, con uno sguardo approfondito a index.js
:
const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');
Abbiamo bisogno del modulo Firestore e facciamo riferimento alla raccolta books
, in cui sono archiviati i dati dei nostri libri.
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'],
}));
Per implementare l'API REST, utilizziamo Express come framework web. Stiamo utilizzando il modulo body-parser
per analizzare i payload JSON scambiati con la nostra API.
Il modulo querystring
è utile per manipolare gli URL. Questo vale quando creiamo intestazioni Link
per l'impaginazione (ulteriori informazioni in merito più avanti).
Quindi configuriamo il modulo cors
. Esprimiamo le intestazioni che vogliamo passare tramite CORS, poiché la maggior parte vengono generalmente eliminate, ma qui vogliamo mantenere la lunghezza e il tipo di contenuti abituali, oltre all'intestazione Link
che specificheremo per l'impaginazione.
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;
}
Utilizzeremo il modulo di Gestione dei partner di rete di isbn3
per analizzare e convalidare i codici ISBN. Inoltre, sviluppiamo una piccola funzione di utilità che analizzeremo i codici ISBN e risponderemo con un codice di stato 406
nella risposta, se i codici ISBN non sono validi.
GET /books
Diamo un'occhiata all'endpoint GET /books
, un punto alla volta:
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}`});
}
});
Ci stiamo preparando a eseguire una query sul database, preparando una query. Questa query dipenderà dai parametri facoltativi della query, per filtrare per autore e/o per lingua. Restituiamo anche l'elenco di libri per frammenti di 10 libri.
Se si verifica un errore durante il recupero dei libri, viene restituito un errore con il codice di stato 400.
Esaminiamo la parte ritagliata dell'endpoint:
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);
});
}
Nella sezione precedente abbiamo applicato i filtri author
e language
, ma in questa sezione ordiniamo l'elenco dei libri in base alla data dell'ultimo aggiornamento (l'ultimo aggiornamento si verifica per primo). Inoltre, impaginaremo il risultato definendo un limite (il numero di elementi da restituire) e un offset (il punto di partenza da cui restituire il gruppo successivo di libri).
Eseguiamo la query, otteniamo lo snapshot dei dati e inseriamo questi risultati in un array JavaScript che verrà restituito alla fine della funzione.
Concludiamo le spiegazioni di questo endpoint osservando una buona pratica: utilizzare l'intestazione Link
per definire i link URI alla prima, alla precedente, alla successiva o all'ultima pagina di dati (nel nostro caso, forniremo solo la precedente e la successiva).
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);
La logica in questo caso può sembrare un po' complessa in questo caso, ma quello che stiamo facendo è aggiungere un link precedente se non siamo nella prima pagina di dati. Inoltre, aggiungiamo un link next se la pagina di dati è piena (ovvero contiene il numero massimo di libri come definito dalla costante PAGE_SIZE
, supponendo che ne venga creato un altro con più dati). Dopodiché usiamo la funzione resource#links()
di Express per creare l'intestazione corretta con la sintassi corretta.
A scopo informativo, l'intestazione del link sarà simile a questa:
link: </books?page=1>; rel="prev", </books?page=3>; rel="next"
POST /books
ePOST /books/:isbn
Entrambi gli endpoint servono a creare un nuovo libro. Uno trasmette il codice ISBN nel payload del libro, mentre l'altro lo passa come parametro del percorso. In entrambi i casi, entrambi richiamano la nostra funzione 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}`});
}
}
Verifichiamo che il codice isbn
sia valido, altrimenti ritorniamo dalla funzione (e imposti un codice di stato 406
). Recuperiamo i campi del libro dal payload passato nel corpo della richiesta. Quindi memorizzeremo i dettagli del libro in Firestore. Restituire 201
in caso di esito positivo e 400
in caso di errore.
Quando il sistema viene restituito correttamente, impostiamo anche l'intestazione della località, in modo da fornire segnali al client dell'API in cui si trova la risorsa appena creata. L'intestazione sarà così:
Location: /books/9781234567898
GET /books/:isbn
Recupera un libro, identificato tramite il codice ISBN, da Firestore.
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}`});
}
});
Come sempre, controlliamo che il codice ISBN sia valido. Facciamo una query a Firestore per recuperare il libro. La proprietà snapshot.exists
è utile per sapere se è stato effettivamente trovato un libro. In caso contrario, ti invieremo un errore e il codice di stato 404
Non trovato. Recuperiamo i dati del libro e creiamo un oggetto JSON che rappresenta il libro da restituire.
PUT /books/:isbn
Utilizziamo il metodo PUT per aggiornare un libro esistente.
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}`});
}
});
Aggiorniamo il campo data/ora di updated
per ricordare l'ultimo aggiornamento dei record. Utilizziamo la strategia {merge:true}
che sostituisce i campi esistenti con i nuovi valori (altrimenti, tutti i campi vengono rimossi e verranno salvati solo i nuovi campi nel payload, cancellando i campi esistenti dall'aggiornamento precedente o dalla creazione iniziale).
Inoltre, abbiamo impostato l'intestazione Location
in modo che punti all'URI del libro.
DELETE /books/:isbn
Eliminare libri è piuttosto semplice. Chiamiamo semplicemente il metodo delete()
sul riferimento del documento. Restituiamo un codice di stato 204, in quanto non restituiamo contenuti.
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}`});
}
});
Avvia il server Express / Nodo
Ultimo, ma non meno importante, avviamo il server, ascoltando sulla porta 8080
per impostazione predefinita:
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Books Web API service: listening on port ${port}`);
console.log(`Node ${process.version}`);
});
Esecuzione dell'applicazione in locale
Per eseguire l'applicazione in locale, installeremo prima le dipendenze con:
$ npm install
Possiamo quindi iniziare con:
$ npm start
Per impostazione predefinita, il server si avvia su localhost
e rimane in ascolto sulla porta 8080.
È anche possibile creare un container Docker ed eseguire anche l'immagine container, con i seguenti comandi:
$ docker build -t crud-web-api . $ docker run --rm -p 8080:8080 -it crud-web-api
Anche l'esecuzione in Docker è un ottimo modo per verificare che la containerizzazione della nostra applicazione funzioni correttamente mentre la creiamo nel cloud con Cloud Build.
Test dell'API
Indipendentemente da come viene eseguito il codice dell'API REST (direttamente tramite nodo o tramite un'immagine container Docker), ora siamo in grado di eseguire alcune query sull'API.
- Crea un nuovo libro (ISBN nel payload del corpo):
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \ -H "Content-Type: application/json" \ http://localhost:8080/books
- Crea un nuovo libro (ISBN in un parametro del percorso):
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \ -H "Content-Type: application/json" \ http://localhost:8080/books/9782070368228
- Eliminare un libro (quello che abbiamo creato):
$ curl -XDELETE http://localhost:8080/books/9782070368228
- Recupera un libro per codice ISBN:
$ curl http://localhost:8080/books/9780140449136 $ curl http://localhost:8080/books/9782070360536
- Aggiorna un libro esistente modificando solo il titolo:
$ curl -XPUT \ -d '{"title":"Book"}' \ -H "Content-Type: application/json" \ http://localhost:8080/books/9780003701203
- Recupera l'elenco di libri (i primi 10):
$ curl http://localhost:8080/books
- Trova i libri scritti da un determinato autore:
$ curl http://localhost:8080/books?author=Virginia+Woolf
- Elenca i libri scritti in inglese:
$ curl http://localhost:8080/books?language=English
- Carica la quarta pagina di libri:
$ curl http://localhost:8080/books?page=3
Per perfezionare la ricerca, possiamo anche combinare i parametri di query author
, language
e books
.
Creazione ed deployment dell'API REST containerizzata
Poiché siamo felici che l'API REST funzioni secondo i piani, è il momento giusto per eseguirne il deployment nel cloud, su Cloud Run.
Lo faremo in due passaggi:
- Innanzitutto, creando l'immagine container con Cloud Build, con il seguente comando:
$ gcloud builds submit \ --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
- Quindi, eseguendo il deployment del servizio con questo secondo comando:
$ gcloud run deploy run-crud \ --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \ --allow-unauthenticated \ --region=${REGION} \ --platform=managed
Con il primo comando, Cloud Build crea l'immagine container e la ospita in Container Registry. Il comando successivo esegue il deployment dell'immagine container dal registry e nella regione cloud.
Possiamo verificare nell'interfaccia utente della console Cloud che il nostro servizio Cloud Run ora appare nell'elenco:
Un ultimo passaggio che eseguiremo qui consiste nel recuperare l'URL del servizio Cloud Run di cui è stato appena eseguito il deployment, grazie al comando seguente:
$ export RUN_CRUD_SERVICE_URL=$(gcloud run services describe run-crud \ --region=${REGION} \ --platform=managed \ --format='value(status.url)')
Nella sezione successiva avremo bisogno dell'URL dell'API REST Cloud Run, perché il codice frontend di App Engine interagirà con l'API.
9. Ospita un'app web per sfogliare la raccolta
L'ultimo pezzo del puzzle per aggiungere un tocco di brillantini al progetto è quello di fornire un frontend web che interagisca con la nostra API REST. A questo scopo, utilizzeremo Google App Engine con un codice JavaScript client che chiama l'API tramite richieste AJAX (utilizzando l'API Fetch lato client).
La nostra applicazione, sebbene di cui è stato eseguito il deployment nel runtime di Node.JS App Engine, è composta per lo più da risorse statiche. Il codice di backend non è molto elevato, poiché la maggior parte dell'interazione dell'utente avverrà nel browser tramite JavaScript lato client. Non useremo alcun framework JavaScript frontend fantasioso, useremo solo codice JavaScript "vanilla", con alcuni componenti web per l'interfaccia utente che utilizzano la libreria di componenti web Shoelace:
- una casella per selezionare la lingua del libro:
- un componente di una scheda per visualizzare i dettagli di un determinato libro (incluso un codice a barre che rappresenta il codice ISBN del libro, utilizzando la libreria JsBarcode):
- e un pulsante per caricare altri libri dal database:
Quando combini tutti questi componenti visivi, la pagina web risultante per sfogliare la nostra libreria sarà il seguente:
Il file di configurazione app.yaml
Iniziamo ad analizzare il codebase di questa applicazione App Engine osservando il file di configurazione app.yaml
. Si tratta di un file specifico di App Engine che consente di configurare elementi come le variabili di ambiente, i vari "handler" dell'applicazione o la specifica che alcune risorse sono risorse statiche che verranno gestite dalla rete CDN integrata di 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
Specifichiamo che la nostra applicazione è Node.JS e che vogliamo utilizzare la versione 14.
Quindi definiamo una variabile di ambiente che punta all'URL del nostro servizio Cloud Run. Dovremo aggiornare il segnaposto CHANGE_ME con l'URL corretto (vedi sotto per informazioni su come modificarlo).
Dopodiché definiamo vari gestori. I primi tre puntano alla posizione del codice lato client HTML, CSS e JavaScript, nella cartella public/
e nelle relative sottocartelle. Il quarto indica che l'URL principale dell'applicazione App Engine deve puntare alla pagina index.html
. In questo modo, non vedremo il suffisso index.html
nell'URL quando accedi alla directory principale del sito web. L'ultima è quella predefinita che instraderà tutti gli altri URL (/.*
) alla nostra applicazione Node.JS (ovvero la parte "dinamica" dell'applicazione, a differenza degli asset statici che abbiamo descritto).
Aggiorniamo ora l'URL dell'API web del servizio Cloud Run.
Nella directory appengine-frontend/
, esegui questo comando per aggiornare la variabile di ambiente che punta all'URL della nostra API REST basata su Cloud Run:
$ sed -i -e "s|CHANGE_ME|${RUN_CRUD_SERVICE_URL}|" app.yaml
In alternativa, modifica manualmente la stringa CHANGE_ME
in app.yaml
con l'URL corretto:
env_variables:
RUN_CRUD_SERVICE_URL: CHANGE_ME
Il file package.json
di 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"
}
}
Sottolineiamo di nuovo che vogliamo eseguire questa applicazione utilizzando Node.JS 14. Dipendiamo dal framework Express e dal modulo isbn3
Gestione dei partner di rete per la convalida dei libri Codici ISBN.
Nelle dipendenze di sviluppo, utilizzeremo il modulo nodemon
per monitorare le modifiche ai file. Anche se possiamo eseguire l'applicazione in locale con npm start
, apporta alcune modifiche al codice, interrompi l'app con ^C
e poi riavviala. L'operazione è un po' noiosa. Possiamo invece utilizzare il seguente comando per fare in modo che l'applicazione venga ricaricata / riavviata automaticamente in caso di modifiche:
$ npm run dev
Il codice Node.JS index.js
const express = require('express');
const app = express();
app.use(express.static('public'));
const bodyParser = require('body-parser');
app.use(bodyParser.json());
È necessario il framework web Express. Specifica che la directory pubblica contiene asset statici che possono essere pubblicati (almeno quando viene eseguita localmente in modalità di sviluppo) dal middleware static
. Infine, richiediamo body-parser
per analizzare i nostri payload JSON.
Diamo un'occhiata ai due percorsi che abbiamo definito:
app.get('/', async (req, res) => {
res.redirect('/html/index.html');
});
app.get('/webapi', async (req, res) => {
res.send(process.env.RUN_CRUD_SERVICE_URL);
});
La prima corrispondenza che corrisponde a /
reindirizzerà a index.html
nella nostra directory public/html
. Poiché in modalità di sviluppo non è eseguita l'esecuzione nel runtime di App Engine, il routing degli URL di App Engine non avviene. In questo caso stiamo semplicemente reindirizzando l'URL principale al file HTML.
Il secondo endpoint che definiamo /webapi
restituirà l'URL della nostra API REST Cloud RUN. In questo modo, il codice JavaScript lato client saprà dove chiamare per ottenere l'elenco di libri.
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}`);
});
Per completare l'operazione, l'app web Express è in esecuzione e la funzionalità è in ascolto sulla porta 8080 per impostazione predefinita.
La pagina index.html
Non esamineremo ogni riga di questa lunga pagina HTML. Evidenziamo invece alcune linee chiave.
<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">
Le prime due righe importano la libreria dei componenti web Shoelace (uno script e un foglio di stile).
Nella riga successiva viene importata la libreria JsBarcode, per creare i codici a barre dei codici ISBN dei libri.
Le ultime righe importano il nostro codice JavaScript e il foglio di stile CSS, che si trovano nelle sottodirectory public/
.
Nel body
della pagina HTML, utilizziamo i componenti Laccio per scarpe con i relativi tag degli elementi personalizzati, come:
<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>
...
Inoltre, utilizziamo modelli HTML e la loro capacità di riempimento degli slot per rappresentare un libro. Creeremo copie di quel modello per compilare l'elenco dei libri e sostituiremo i valori negli spazi con i dettagli dei libri:
<template id="book-card">
<sl-card class="card-overview">
...
<slot name="author">Author</slot>
...
</sl-card>
</template>
Se hai bisogno di HTML sufficiente, la revisione del codice è quasi terminata. Un'ultima parte essenziale rimanente: il codice JavaScript lato client app.js
che interagisce con la nostra API REST.
Il codice JavaScript lato client app.js
Iniziamo con un listener di eventi di primo livello che attende il caricamento dei contenuti del DOM:
document.addEventListener("DOMContentLoaded", async function(event) {
...
}
Quando è pronta, possiamo impostare alcune costanti e variabili chiave:
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 = '';
Innanzitutto, recuperiamo l'URL della nostra API REST, grazie al codice del nodo App Engine che restituisce la variabile di ambiente impostata inizialmente in app.yaml
. Grazie alla variabile di ambiente, l'endpoint /webapi
, chiamato dal codice lato client JavaScript, non abbiamo dovuto impostare come hardcoded l'URL dell'API REST nel codice del frontend.
Definiamo anche le variabili page
e language
, che utilizzeremo per tenere traccia dell'impaginazione e del filtro per lingua.
const moreButton = document.getElementById('more-button');
moreButton.addEventListener('sl-focus', event => {
console.log('Button clicked');
moreButton.blur();
appendMoreBooks(server, page++, language);
});
Aggiungiamo un gestore di eventi sul pulsante per caricare i libri. Quando viene fatto clic, viene chiamata la funzione 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);
});
Analogamente alla casella di selezione, aggiungiamo un gestore di eventi per ricevere notifiche in caso di modifiche alla selezione della lingua. Come per il pulsante, chiamiamo anche la funzione appendMoreBooks()
, passando l'URL dell'API REST, la pagina corrente e la selezione della lingua.
Vediamo quindi la funzione che recupera e aggiunge i libri:
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();
...
}
Sopra, stiamo creando l'URL esatto da utilizzare per chiamare l'API REST. Normalmente, possiamo specificare tre parametri di query, ma in questa UI ne specifichiamo solo due:
page
: un numero intero che indica la pagina corrente per l'impaginazione dei libri,language
: una stringa di lingua per filtrare in base alla lingua scritta.
Dopodiché usiamo l'API Fetch per recuperare l'array JSON contenente i dettagli dei nostri libri.
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';
}
A seconda della presenza o meno dell'intestazione Link
nella risposta, mostreremo o nasconderemo il pulsante [More books...]
, poiché l'intestazione Link
è un suggerimento che ci indica se ci sono ancora altri libri da caricare (è presente un URL next
nell'intestazione Link
).
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);
...
}
}
Nella sezione precedente della funzione, per ogni libro restituito dall'API REST, cloneremo il modello con alcuni componenti web che rappresentano un libro e riempiamo le aree del modello con i relativi dettagli.
JsBarcode('.img-barcode-' + book.isbn).EAN13(book.isbn, {fontSize: 18, textMargin: 0, height: 60}).render();
Per rendere il codice ISBN un po' più carino, utilizziamo la libreria JsBarcode per creare un codice a barre interessante come sulla copertina posteriore di libri veri.
Esecuzione e test dell'applicazione in locale
Ne hai abbastanza di codice per ora, è il momento di vedere l'applicazione in azione. Lo faremo innanzitutto localmente, all'interno di Cloud Shell, prima di eseguire il deployment reale.
Installiamo i moduli di Gestione dei partner di rete necessari dall'applicazione con:
$ npm install
Eseguiamo l'app con il solito:
$ npm start
Oppure con il ricaricamento automatico delle modifiche grazie a nodemon
, con:
$ npm run dev
L'applicazione viene eseguita in locale e possiamo accedervi dal browser all'indirizzo http://localhost:8080
.
Deployment dell'applicazione App Engine
Ora che abbiamo la certezza che la nostra applicazione funziona correttamente in locale, è il momento di eseguirne il deployment su App Engine.
Per eseguire il deployment dell'applicazione, lanciamo il seguente comando:
$ gcloud app deploy -q
Dopo circa un minuto è stato completato il deployment dell'applicazione.
L'applicazione sarà disponibile a un URL della forma https://${GOOGLE_CLOUD_PROJECT}.appspot.com
.
Esplorazione dell'interfaccia utente dell'applicazione web di App Engine
Ora puoi:
- Fai clic sul pulsante
[More books...]
per caricare altri libri. - Seleziona una lingua specifica per visualizzare soltanto i libri in quella lingua.
- Puoi cancellare la selezione con la piccola croce nella casella per tornare all'elenco di tutti i libri.
10. (Facoltativo) Eseguire la pulizia
Se non intendi conservare l'app, puoi eseguire la pulizia delle risorse per risparmiare sui costi ed essere nel complesso un buon cittadino del cloud eliminando l'intero progetto:
gcloud projects delete ${GOOGLE_CLOUD_PROJECT}
11. Complimenti!
Abbiamo creato un insieme di servizi, grazie a Cloud Functions, App Engine e Cloud Run, per esporre vari endpoint API web e frontend web, per archiviare, aggiornare ed esplorare una libreria di libri, seguendo alcuni buoni pattern di progettazione per lo sviluppo di API REST lungo il percorso.
Argomenti trattati
- Cloud Functions
- Cloud Firestore
- Cloud Run
- App Engine
Un passo avanti
Se vuoi approfondire questo esempio concreto ed espanderlo, ecco un elenco di aspetti che ti consigliamo di approfondire:
- Utilizza API Gateway per fornire una facciata API comune alla funzione di importazione dei dati e al container dell'API REST, per aggiungere funzionalità come la gestione delle chiavi API per accedere all'API o definire limitazioni di frequenza per i consumer dell'API.
- Esegui il deployment del modulo di nodo Swagger-UI nell'applicazione App Engine per documentare e offrire un'area di test per l'API REST.
- Sul frontend, oltre alle funzionalità di navigazione esistenti, aggiungi altre schermate per modificare i dati e crea nuove voci di libro. Inoltre, poiché stiamo utilizzando il database Cloud Firestore, sfrutta la sua funzionalità in tempo reale per aggiornare i dati dei libri visualizzati man mano che vengono apportate modifiche.