פריסת אפליקציית Angular מלאה ב-Cloud Run עם Firestore באמצעות Node.js Admin SDK

1. סקירה כללית

Cloud Run היא פלטפורמה מנוהלת באופן מלא שמאפשרת להריץ את הקוד ישירות על גבי התשתית הניתנת להתאמה של Google. Codelab זה ידגים כיצד לחבר אפליקציית Angular ב-Cloud Run למסד נתונים של Firestore באמצעות Node.js Admin SDK.

בשיעור ה-Lab הזה תלמדו איך:

  • יצירה של מסד נתונים ב-Firestore
  • פריסת אפליקציה ב-Cloud Run שמתחברת למסד הנתונים שלכם ב-Firestore

2. דרישות מוקדמות

  1. אם אין לכם חשבון Google, אתם צריכים ליצור חשבון Google.
    • משתמשים בחשבון לשימוש אישי במקום בחשבון לצורכי עבודה או בחשבון בית ספרי. יכול להיות שבחשבונות לצורכי עבודה או בחשבונות בית ספריים יש הגבלות שימנעו מכם להפעיל את ממשקי ה-API שנדרשים למעבדה הזו.

3. הגדרת הפרויקט

  1. נכנסים ל-מסוף Google Cloud.
  2. מפעילים את החיוב במסוף Cloud.
    • העלות של השלמת ה-Lab הזה במשאבי Cloud צריכה להיות פחות מ-1$.
    • כדי למחוק משאבים ולמנוע חיובים נוספים, אפשר לבצע את השלבים בסוף ה-Lab הזה.
    • משתמשים חדשים זכאים לתקופת ניסיון בחינם בשווי 300$.
  3. יוצרים פרויקט חדש או בוחרים להשתמש מחדש בפרויקט קיים.

4. פתיחת Cloud Shell Editor

  1. עוברים אל Cloud Shell Editor.
  2. אם הטרמינל לא מופיע בתחתית המסך, פותחים אותו:
    • לוחצים על סמל האפשרויות הנוספות (3 קווים) סמל של תפריט האפשרויות הנוספות (3 קווים).
    • לוחצים על Terminal (מסוף).
    • לוחצים על New Terminal (טרמינל חדש)פתיחת טרמינל חדש ב-Cloud Shell Editor.
  3. בטרמינל, מגדירים את הפרויקט באמצעות הפקודה הבאה:
    • פורמט:
      gcloud config set project [PROJECT_ID]
      
    • דוגמה:
      gcloud config set project lab-project-id-example
      
    • אם אתם לא זוכרים את מזהה הפרויקט:
      • כדי לראות את כל מזהי הפרויקטים, מריצים את הפקודה:
        gcloud projects list | awk '/PROJECT_ID/{print $2}'
        
      הגדרת מזהה הפרויקט בטרמינל של Cloud Shell Editor
  4. אם מתבקשים לאשר, לוחצים על אישור כדי להמשיך. לוחצים כדי לתת הרשאה ל-Cloud Shell
  5. תוצג ההודעה הבאה:
    Updated property [core/project].
    
    אם מופיע WARNING ומוצגת השאלה Do you want to continue (Y/N)?, כנראה שהזנתם את מזהה הפרויקט בצורה שגויה. לוחצים על N, לוחצים על Enter ומנסים להריץ שוב את הפקודה gcloud config set project.

5. הפעלת ממשקי ה-API

בטרמינל, מפעילים את ממשקי ה-API:

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

אם מתבקשים לאשר, לוחצים על אישור כדי להמשיך. לוחצים כדי לתת הרשאה ל-Cloud Shell

השלמת הפקודה עשויה להימשך כמה דקות, אבל בסופו של דבר אמורה להתקבל הודעה על הצלחה שדומה להודעה הבאה:

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

6. יצירת מסד נתונים ב-Firestore

  1. מריצים את הפקודה gcloud firestore databases create כדי ליצור מסד נתונים של Firestore
    gcloud firestore databases create --location=nam5
    

7. הכנת הבקשה

מכינים אפליקציית Next.js שמגיבה לבקשות HTTP.

  1. כדי ליצור פרויקט חדש של Next.js בשם task-app, משתמשים בפקודה:
    npx --yes @angular/cli@19.2.5 new task-app \
        --minimal \
        --inline-template \
        --inline-style \
        --ssr \
        --server-routing \
        --defaults
    
  2. שינוי הספרייה ל-task-app:
    cd task-app
    
  1. מתקינים את firebase-admin כדי לבצע פעולות במסד הנתונים של Firestore.
    npm install firebase-admin
    
  1. פותחים את הקובץ server.ts ב-Cloud Shell Editor:
    cloudshell edit src/server.ts
    
    קובץ אמור להופיע בחלק העליון של המסך. כאן אפשר לערוך את הקובץ server.ts. הצגת הקוד בחלק העליון של המסך
  2. מוחקים את התוכן הקיים בקובץ server.ts.
  3. מעתיקים את הקוד הבא ומדביקים אותו בקובץ 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. פותחים את הקובץ angular.json ב-Cloud Shell Editor:
    cloudshell edit angular.json
    
    עכשיו נוסיף את השורה "externalDependencies": ["firebase-admin"] לקובץ angular.json.
  5. מוחקים את התוכן הקיים בקובץ angular.json.
  6. מעתיקים את הקוד הבא ומדביקים אותו בקובץ 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. פותחים את הקובץ app.component.ts ב-Cloud Shell Editor:
    cloudshell edit src/app/app.component.ts
    
    קובץ קיים אמור להופיע בחלק העליון של המסך. כאן אפשר לערוך את הקובץ app.component.ts. הצגת הקוד בחלק העליון של המסך
  2. מוחקים את התוכן הקיים בקובץ app.component.ts.
  3. מעתיקים את הקוד הבא ומדביקים אותו בקובץ 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();
      }
    }
    

האפליקציה מוכנה עכשיו לפריסה.

8. פריסת האפליקציה ב-Cloud Run

  1. מריצים את הפקודה הבאה כדי לפרוס את האפליקציה ב-Cloud Run:
    gcloud run deploy helloworld \
      --region=us-central1 \
      --source=.
    
  2. אם מוצגת הנחיה, מקישים על Y ועל Enter כדי לאשר שרוצים להמשיך:
    Do you want to continue (Y/n)? Y
    

אחרי כמה דקות, האפליקציה אמורה לספק כתובת URL שאפשר להיכנס אליה.

עוברים לכתובת ה-URL כדי לראות את האפליקציה בפעולה. בכל פעם שתבקרו בכתובת ה-URL או תרעננו את הדף, תראו את אפליקציית המשימות.

9. מזל טוב

בשיעור ה-Lab הזה למדתם איך:

  • יצירת מכונות של Cloud SQL ל-PostgreSQL
  • פריסת אפליקציה ב-Cloud Run שמתחברת למסד הנתונים של Cloud SQL

הסרת המשאבים

ל-Cloud SQL אין רמת שירות בחינם, ותחויבו אם תמשיכו להשתמש בו. כדי להימנע מחיובים נוספים, אפשר למחוק את פרויקט בענן.

ב-Cloud Run לא מחויבים כשלא משתמשים בשירות, אבל יכול להיות שתחויבו על אחסון קובץ האימג' של הקונטיינר ב-Artifact Registry. כשמוחקים פרויקט בענן, החיוב על כל המשאבים שנעשה בהם שימוש באותו פרויקט נפסק.

אם רוצים, אפשר למחוק את הפרויקט:

gcloud projects delete $GOOGLE_CLOUD_PROJECT

אפשר גם למחוק משאבים מיותרים מהדיסק של Cloud Shell. אתם יכולים:

  1. מוחקים את ספריית הפרויקט של ה-codelab:
    rm -rf ~/task-app
    
  2. אזהרה! אי אפשר לבטל את הפעולה הבאה! אם רוצים למחוק את כל מה שיש ב-Cloud Shell כדי לפנות מקום, אפשר למחוק את כל ספריית הבית. חשוב לוודא שכל מה שרוצים לשמור נשמר במקום אחר.
    sudo rm -rf $HOME
    

לומדים בכיף