Déployer une application Angular full stack sur Cloud Run avec Firestore à l'aide du SDK administrateur Node.js

1. Présentation

Cloud Run est une plate-forme entièrement gérée qui vous permet d'exécuter votre code directement sur l'infrastructure évolutive de Google. Dans cet atelier de programmation, vous allez apprendre à connecter une application Angular sur Cloud Run à une base de données Firestore à l'aide du SDK Admin Node.js.

Dans cet atelier, vous allez apprendre à effectuer les tâches suivantes :

  • Créer une base de données Firestore
  • Déployer une application sur Cloud Run qui se connecte à votre base de données Firestore

2. Prérequis

  1. Si vous n'avez pas encore de compte Google, vous devez en créer un.
    • Utilisez un compte personnel au lieu d'un compte professionnel ou scolaire. Il est possible que des restrictions s'appliquent aux comptes professionnels et scolaires, ce qui vous empêche d'activer les API nécessaires pour cet atelier.

3. Configuration du projet

  1. Connectez-vous à la console Google Cloud.
  2. Activez la facturation dans la console Cloud.
    • Cet atelier devrait vous coûter moins de 1 USD en ressources Cloud.
    • Vous pouvez suivre les étapes à la fin de cet atelier pour supprimer les ressources et éviter ainsi des frais supplémentaires.
    • Les nouveaux utilisateurs peuvent bénéficier d'un essai sans frais pour bénéficier d'un crédit de 300$.
  3. Créez un projet ou réutilisez-en un existant.

4. Ouvrir l'éditeur Cloud Shell

  1. Accédez à l'éditeur Cloud Shell.
  2. Si le terminal ne s'affiche pas en bas de l'écran, ouvrez-le :
    • Cliquez sur le menu hamburger Icône du menu hamburger.
    • Cliquez sur Terminal
    • Cliquez sur Nouveau terminalOuvrir un nouveau terminal dans l'éditeur Cloud Shell
  3. Dans le terminal, définissez votre projet à l'aide de la commande suivante :
    • Format :
      gcloud config set project [PROJECT_ID]
      
    • Exemple :
      gcloud config set project lab-project-id-example
      
    • Si vous ne vous souvenez pas de l'ID de votre projet :
      • Vous pouvez lister tous vos ID de projet avec la commande suivante :
        gcloud projects list | awk '/PROJECT_ID/{print $2}'
        
      Définir l'ID du projet dans le terminal de l'éditeur Cloud Shell
  4. Si vous êtes invité à autoriser l'accès, cliquez sur Autoriser pour continuer. Cliquez pour autoriser Cloud Shell.
  5. Le message suivant doit s'afficher :
    Updated property [core/project].
    
    Si le message WARNING s'affiche et que vous êtes invité à Do you want to continue (Y/N)?, cela signifie probablement que vous avez saisi l'ID de projet de manière incorrecte. Appuyez sur N, puis sur Enter, et réessayez d'exécuter la commande gcloud config set project.

5. Activer les API

Dans le terminal, activez les API :

gcloud services enable \
  firestore.googleapis.com \
  run.googleapis.com \
  artifactregistry.googleapis.com \
  cloudbuild.googleapis.com

Si vous êtes invité à autoriser l'accès, cliquez sur Autoriser pour continuer. Cliquez pour autoriser Cloud Shell.

L'exécution de cette commande peut prendre quelques minutes, mais un message semblable à celui qui suit devrait s'afficher pour vous indiquer que l'opération s'est correctement déroulée :

Operation "operations/acf.p2-73d90d00-47ee-447a-b600" finished successfully.

6. Créer une base de données Firestore

  1. Exécutez la commande gcloud firestore databases create pour créer une base de données Firestore.
    gcloud firestore databases create --location=nam5
    

7. Préparer l'application

Préparez une application Next.js qui répond aux requêtes HTTP.

  1. Pour créer un projet Next.js nommé task-app, utilisez la commande suivante :
    npx --yes @angular/cli@19.2.5 new task-app \
        --minimal \
        --inline-template \
        --inline-style \
        --ssr \
        --server-routing \
        --defaults
    
  2. Remplacez le répertoire par task-app:
    cd task-app
    
  1. Installez firebase-admin pour interagir avec la base de données Firestore.
    npm install firebase-admin
    
  1. Ouvrez le fichier server.ts dans l'éditeur Cloud Shell :
    cloudshell edit src/server.ts
    
    Un fichier devrait maintenant s'afficher en haut de l'écran. C'est ici que vous pouvez modifier ce fichier server.ts. Montre que le code se trouve dans la partie supérieure de l'écran
  2. Supprimez le contenu existant du fichier server.ts.
  3. Copiez le code suivant et collez-le dans le fichier server.ts ouvert :
    import {
      AngularNodeAppEngine,
      createNodeRequestHandler,
      isMainModule,
      writeResponseToNodeResponse,
    } from '@angular/ssr/node';
    import express from 'express';
    import { dirname, resolve } from 'node:path';
    import { fileURLToPath } from 'node:url';
    import { initializeApp, applicationDefault, getApps } from 'firebase-admin/app';
    import { getFirestore } from 'firebase-admin/firestore';
    
    type Task = {
      id: string;
      title: string;
      status: 'IN_PROGRESS' | 'COMPLETE';
      createdAt: number;
    };
    
    const credential = applicationDefault();
    
    // Only initialize app if it does not already exist
    if (getApps().length === 0) {
      initializeApp({ credential });
    }
    
    const db = getFirestore();
    const tasksRef = db.collection('tasks');
    
    const serverDistFolder = dirname(fileURLToPath(import.meta.url));
    const browserDistFolder = resolve(serverDistFolder, '../browser');
    
    const app = express();
    const angularApp = new AngularNodeAppEngine();
    
    app.use(express.json());
    
    app.get('/api/tasks', async (req, res) => {
      const snapshot = await tasksRef.orderBy('createdAt', 'desc').limit(100).get();
      const tasks: Task[] = snapshot.docs.map(doc => ({
        id: doc.id,
        title: doc.data()['title'],
        status: doc.data()['status'],
        createdAt: doc.data()['createdAt'],
      }));
      res.send(tasks);
    });
    
    app.post('/api/tasks', async (req, res) => {
      const newTaskTitle = req.body.title;
      if(!newTaskTitle){
        res.status(400).send("Title is required");
        return;
      }
      await tasksRef.doc().create({
        title: newTaskTitle,
        status: 'IN_PROGRESS',
        createdAt: Date.now(),
      });
      res.sendStatus(200);
    });
    
    app.put('/api/tasks', async (req, res) => {
      const task: Task = req.body;
      if (!task || !task.id || !task.title || !task.status) {
        res.status(400).send("Invalid task data");
        return;
      }
      await tasksRef.doc(task.id).set(task);
      res.sendStatus(200);
    });
    
    app.delete('/api/tasks', async (req, res) => {
      const task: Task = req.body;
      if(!task || !task.id){
        res.status(400).send("Task ID is required");
        return;
      }
      await tasksRef.doc(task.id).delete();
      res.sendStatus(200);
    });
    
    /**
    * Serve static files from /browser
    */
    app.use(
      express.static(browserDistFolder, {
        maxAge: '1y',
        index: false,
        redirect: false,
      }),
    );
    
    /**
    * Handle all other requests by rendering the Angular application.
    */
    app.use('/**', (req, res, next) => {
      angularApp
        .handle(req)
        .then((response) =>
          response ? writeResponseToNodeResponse(response, res) : next(),
        )
        .catch(next);
    });
    
    /**
    * Start the server if this module is the main entry point.
    * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
    */
    if (isMainModule(import.meta.url)) {
      const port = process.env['PORT'] || 4000;
      app.listen(port, () => {
        console.log(`Node Express server listening on http://localhost:${port}`);
      });
    }
    
    /**
    * Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
    */
    export const reqHandler = createNodeRequestHandler(app);
    
  4. Ouvrez le fichier angular.json dans l'éditeur Cloud Shell :
    cloudshell edit angular.json
    
    Nous allons maintenant ajouter la ligne "externalDependencies": ["firebase-admin"] au fichier angular.json.
  5. Supprimez le contenu existant du fichier angular.json.
  6. Copiez le code suivant et collez-le dans le fichier angular.json ouvert :
    {
      "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
      "version": 1,
      "newProjectRoot": "projects",
      "projects": {
        "task-app": {
          "projectType": "application",
          "schematics": {
            "@schematics/angular:component": {
              "inlineTemplate": true,
              "inlineStyle": true,
              "skipTests": true
            },
            "@schematics/angular:class": {
              "skipTests": true
            },
            "@schematics/angular:directive": {
              "skipTests": true
            },
            "@schematics/angular:guard": {
              "skipTests": true
            },
            "@schematics/angular:interceptor": {
              "skipTests": true
            },
            "@schematics/angular:pipe": {
              "skipTests": true
            },
            "@schematics/angular:resolver": {
              "skipTests": true
            },
            "@schematics/angular:service": {
              "skipTests": true
            }
          },
          "root": "",
          "sourceRoot": "src",
          "prefix": "app",
          "architect": {
            "build": {
              "builder": "@angular-devkit/build-angular:application",
              "options": {
                "outputPath": "dist/task-app",
                "index": "src/index.html",
                "browser": "src/main.ts",
                "polyfills": [
                  "zone.js"
                ],
                "tsConfig": "tsconfig.app.json",
                "assets": [
                  {
                    "glob": "**/*",
                    "input": "public"
                  }
                ],
                "styles": [
                  "src/styles.css"
                ],
                "scripts": [],
                "server": "src/main.server.ts",
                "outputMode": "server",
                "ssr": {
                  "entry": "src/server.ts"
                },
                "externalDependencies": ["firebase-admin"]
              },
              "configurations": {
                "production": {
                  "budgets": [
                    {
                      "type": "initial",
                      "maximumWarning": "500kB",
                      "maximumError": "1MB"
                    },
                    {
                      "type": "anyComponentStyle",
                      "maximumWarning": "4kB",
                      "maximumError": "8kB"
                    }
                  ],
                  "outputHashing": "all"
                },
                "development": {
                  "optimization": false,
                  "extractLicenses": false,
                  "sourceMap": true
                }
              },
              "defaultConfiguration": "production"
            },
            "serve": {
              "builder": "@angular-devkit/build-angular:dev-server",
              "configurations": {
                "production": {
                  "buildTarget": "task-app:build:production"
                },
                "development": {
                  "buildTarget": "task-app:build:development"
                }
              },
              "defaultConfiguration": "development"
            },
            "extract-i18n": {
              "builder": "@angular-devkit/build-angular:extract-i18n"
            }
          }
        }
      }
    }
    

"externalDependencies": ["firebase-admin"]

  1. Ouvrez le fichier app.component.ts dans l'éditeur Cloud Shell :
    cloudshell edit src/app/app.component.ts
    
    Un fichier existant devrait maintenant s'afficher en haut de l'écran. C'est ici que vous pouvez modifier ce fichier app.component.ts. Montre que le code se trouve dans la partie supérieure de l'écran
  2. Supprimez le contenu existant du fichier app.component.ts.
  3. Copiez le code suivant et collez-le dans le fichier app.component.ts ouvert :
    import { afterNextRender, Component, signal } from '@angular/core';
    import { FormsModule } from '@angular/forms';
    
    type Task = {
      id: string;
      title: string;
      status: 'IN_PROGRESS' | 'COMPLETE';
      createdAt: number;
    };
    
    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [FormsModule],
      template: `
        <section>
          <input
            type="text"
            placeholder="New Task Title"
            [(ngModel)]="newTaskTitle"
            class="text-black border-2 p-2 m-2 rounded"
          />
          <button (click)="addTask()">Add new task</button>
          <table>
            <tbody>
              @for (task of tasks(); track task) {
                @let isComplete = task.status === 'COMPLETE';
                <tr>
                  <td>
                    <input
                      (click)="updateTask(task, { status: isComplete ? 'IN_PROGRESS' : 'COMPLETE' })"
                      type="checkbox"
                      [checked]="isComplete"
                    />
                  </td>
                  <td>{{ task.title }}</td>
                  <td>{{ task.status }}</td>
                  <td>
                    <button (click)="deleteTask(task)">Delete</button>
                  </td>
                </tr>
              }
            </tbody>
          </table>
        </section>
      `,
      styles: '',
    })
    export class AppComponent {
      newTaskTitle = '';
      tasks = signal<Task[]>([]);
    
      constructor() {
        afterNextRender({
          earlyRead: () => this.getTasks()
        });
      }
    
      async getTasks() {
        const response = await fetch(`/api/tasks`);
        const tasks = await response.json();
        this.tasks.set(tasks);
      }
    
      async addTask() {
        await fetch(`/api/tasks`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            title: this.newTaskTitle,
            status: 'IN_PROGRESS',
            createdAt: Date.now(),
          }),
        });
        this.newTaskTitle = '';
        await this.getTasks();
      }
    
      async updateTask(task: Task, newTaskValues: Partial<Task>) {
        await fetch(`/api/tasks`, {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ ...task, ...newTaskValues }),
        });
        await this.getTasks();
      }
    
      async deleteTask(task: any) {
        await fetch('/api/tasks', {
          method: 'DELETE',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(task),
        });
        await this.getTasks();
      }
    }
    

L'application est maintenant prête à être déployée.

8. Déployer l'application dans Cloud Run

  1. Exécutez la commande ci-dessous pour déployer votre application sur Cloud Run :
    gcloud run deploy helloworld \
      --region=us-central1 \
      --source=.
    
  2. Si vous y êtes invité, appuyez sur Y et Enter pour confirmer que vous souhaitez continuer :
    Do you want to continue (Y/n)? Y
    

Après quelques minutes, l'application devrait vous fournir une URL à consulter.

Accédez à l'URL pour voir votre application en action. Chaque fois que vous accédez à l'URL ou que vous actualisez la page, l'application Tasks s'affiche.

9. Félicitations

Dans cet atelier, vous avez appris à effectuer les tâches suivantes :

  • Créer une instance Cloud SQL pour PostgreSQL
  • Déployer une application sur Cloud Run qui se connecte à votre base de données Cloud SQL

Effectuer un nettoyage

Cloud SQL ne propose pas de niveau sans frais et vous sera facturé si vous continuez à l'utiliser. Vous pouvez supprimer votre projet Cloud pour éviter des frais supplémentaires.

Bien que Cloud Run ne facture pas lorsque le service n'est pas utilisé, il se peut que des frais vous soient facturés pour le stockage de l'image de conteneur dans Artifact Registry. La suppression de votre projet Cloud arrête la facturation de toutes les ressources utilisées dans ce projet.

Si vous le souhaitez, supprimez le projet :

gcloud projects delete $GOOGLE_CLOUD_PROJECT

Vous pouvez également supprimer les ressources inutiles de votre disque Cloud Shell. Vous pouvez :

  1. Supprimez le répertoire du projet de l'atelier de programmation :
    rm -rf ~/task-app
    
  2. Avertissement ! Cette prochaine action est irréversible. Si vous souhaitez supprimer tous les éléments de votre Cloud Shell pour libérer de l'espace, vous pouvez supprimer l'intégralité de votre répertoire d'accueil. Veillez à ce que tout ce que vous souhaitez conserver soit enregistré ailleurs.
    sudo rm -rf $HOME
    

Continuer à apprendre