Wdrażanie pełnego rozwiązania aplikacji Angular w Cloud Run z Firestore za pomocą pakietu Admin SDK Node.js

1. Przegląd

Cloud Run to usługa w pełni zarządzana, która umożliwia uruchamianie kodu bezpośrednio w skalowalnej infrastrukturze Google. W tym samouczku dowiesz się, jak połączyć aplikację Angular w Cloud Run z bazą danych Firestore za pomocą pakietu Node.js Admin SDK.

W tym module nauczysz się, jak:

  • Utwórz bazę danych Firestore
  • Wdrażanie w Cloud Run aplikacji, która łączy się z bazą danych Firestore

2. Wymagania wstępne

  1. Jeśli nie masz jeszcze konta Google, musisz je utworzyć.
    • Używaj konta osobistego zamiast konta służbowego lub szkolnego. Konta służbowe i szkolne mogą mieć ograniczenia, które uniemożliwiają włączenie interfejsów API potrzebnych do tego ćwiczenia.

3. Konfigurowanie projektu

  1. Zaloguj się w konsoli Google Cloud.
  2. Włącz płatności w konsoli Google Cloud.
  3. Utwórz nowy projekt lub użyj już istniejącego.

4. Otwórz edytor Cloud Shell

  1. Otwórz edytor Cloud Shell.
  2. Jeśli terminal nie pojawi się u dołu ekranu, otwórz go:
    • Kliknij menu Ikona menu z 3 kreskami.
    • Kliknij Terminal.
    • Kliknij Nowy terminalOtwieranie nowego terminala w edytorze Cloud Shell.
  3. W terminalu ustaw projekt za pomocą tego polecenia:
    • Format:
      gcloud config set project [PROJECT_ID]
      
    • Przykład:
      gcloud config set project lab-project-id-example
      
    • Jeśli nie pamiętasz identyfikatora projektu:
      • Aby wyświetlić listę wszystkich identyfikatorów projektów, użyj tego polecenia:
        gcloud projects list | awk '/PROJECT_ID/{print $2}'
        
      Ustawianie identyfikatora projektu w terminalu edytora Cloud Shell
  4. Jeśli pojawi się prośba o autoryzację, kliknij Autoryzuj, aby przejść dalej. Kliknij, aby uwierzytelnić się w Cloud Shell
  5. Powinien wyświetlić się ten komunikat:
    Updated property [core/project].
    
    Jeśli widzisz symbol WARNING i pojawia się pytanie Do you want to continue (Y/N)?, prawdopodobnie identyfikator projektu został wpisany nieprawidłowo. Naciśnij N, a następnie Enter i spróbuj ponownie uruchomić polecenie gcloud config set project.

5. Włącz interfejsy API

W terminalu włącz interfejsy API:

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

Jeśli pojawi się prośba o autoryzację, kliknij Autoryzuj, aby przejść dalej. Kliknij, aby uwierzytelnić się w Cloud Shell

Wykonanie tego polecenia może potrwać kilka minut, ale powinno ostatecznie wyświetlić komunikat o sukcesie podobny do tego:

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

6. Tworzenie bazy danych Firestore

  1. Uruchom polecenie gcloud firestore databases create, aby utworzyć bazę danych Firestore.
    gcloud firestore databases create --location=nam5
    

7. Przygotowywanie aplikacji

Przygotuj aplikację Next.js, która odpowiada na żądania HTTP.

  1. Aby utworzyć nowy projekt Next.js o nazwie task-app, użyj tego polecenia:
    npx --yes @angular/cli@19.2.5 new task-app \
        --minimal \
        --inline-template \
        --inline-style \
        --ssr \
        --server-routing \
        --defaults
    
  2. Przejdź do katalogu task-app:
    cd task-app
    
  1. Zainstaluj firebase-admin, aby korzystać z bazy danych Firestore.
    npm install firebase-admin
    
  1. Otwórz plik server.ts w edytorze Cloud Shell:
    cloudshell edit src/server.ts
    
    U góry ekranu powinien pojawić się plik. W tym miejscu możesz edytować plik server.ts. Pokaż, że kod należy wpisać w górnej części ekranu
  2. Usuń dotychczasową zawartość pliku server.ts.
  3. Skopiuj ten kod i wklej go do otwartego pliku server.ts:
    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. Otwórz plik angular.json w edytorze Cloud Shell:
    cloudshell edit angular.json
    
    Teraz dodamy wiersz "externalDependencies": ["firebase-admin"] do pliku angular.json.
  5. Usuń dotychczasową zawartość pliku angular.json.
  6. Skopiuj ten kod i wklej go do otwartego pliku angular.json:
    {
      "$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. Otwórz plik app.component.ts w edytorze Cloud Shell:
    cloudshell edit src/app/app.component.ts
    
    U góry ekranu powinien pojawić się istniejący plik. W tym miejscu możesz edytować plik app.component.ts. Pokaż, że kod należy wpisać w górnej części ekranu
  2. Usuń dotychczasową zawartość pliku app.component.ts.
  3. Skopiuj ten kod i wklej go do otwartego pliku app.component.ts:
    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();
      }
    }
    

Aplikacja jest teraz gotowa do wdrożenia.

8. Wdrażanie aplikacji w Cloud Run

  1. Aby wdrożyć aplikację w Cloud Run, uruchom to polecenie:
    gcloud run deploy helloworld \
      --region=us-central1 \
      --source=.
    
  2. Jeśli pojawi się prośba, naciśnij YEnter, aby potwierdzić, że chcesz kontynuować:
    Do you want to continue (Y/n)? Y
    

Po kilku minutach aplikacja powinna podać adres URL, który możesz otworzyć.

Otwórz adres URL, aby zobaczyć działanie aplikacji. Za każdym razem, gdy otworzysz adres URL lub odświeżysz stronę, zobaczysz aplikację do zarządzania zadaniami.

9. Gratulacje

Z tego modułu dowiedziałeś(-aś) się, jak:

  • Utwórz instancję Cloud SQL for PostgreSQL
  • Wdrażanie aplikacji w Cloud Run, która łączy się z bazą danych Cloud SQL

Czyszczenie danych

Cloud SQL nie ma bezpłatnego poziomu i jeśli będziesz z niego korzystać, będziemy naliczać opłaty. Aby uniknąć dodatkowych opłat, możesz usunąć projekt w chmurze.

Cloud Run nie nalicza opłat, gdy usługa nie jest używana, ale może zostać pobrana należność za przechowywanie obrazu kontenera w Artifact Registry. Usunięcie projektu w chmurze spowoduje zaprzestanie naliczania opłat za wszelkie zasoby wykorzystywane w ramach tego projektu.

Jeśli chcesz, usuń projekt:

gcloud projects delete $GOOGLE_CLOUD_PROJECT

Możesz też usunąć niepotrzebne zasoby z dysku Cloud Shell. Możesz:

  1. Usuń katalog projektu ćwiczeń z programowania:
    rm -rf ~/task-app
    
  2. Ostrzeżenie! Tej czynności nie można cofnąć. Jeśli chcesz usunąć wszystko z Cloud Shell, aby zwolnić miejsce, możesz usunąć cały katalog domowy. Upewnij się, że wszystko, co chcesz zachować, jest zapisane w innym miejscu.
    sudo rm -rf $HOME
    

Ucz się dalej