ติดตั้งใช้งานแอปพลิเคชัน Angular แบบ Full Stack ไปยัง Cloud Run ด้วย Firestore โดยใช้ Node.js Admin SDK

ติดตั้งใช้งานแอปพลิเคชัน Angular แบบ Full Stack ไปยัง Cloud Run ด้วย Firestore โดยใช้ Node.js Admin SDK

เกี่ยวกับ Codelab นี้

subjectอัปเดตล่าสุดเมื่อ เม.ย. 2, 2025
account_circleเขียนโดย Luke Schlangen

1 ภาพรวม

Cloud Run เป็นแพลตฟอร์มที่มีการจัดการโดยสมบูรณ์ซึ่งช่วยให้คุณเรียกใช้โค้ดบนโครงสร้างพื้นฐานที่ปรับขนาดได้ของ Google ได้โดยตรง Codelab นี้จะสาธิตวิธีเชื่อมต่อแอปพลิเคชัน Angular ใน Cloud Run กับฐานข้อมูล Firestore โดยใช้ Node.js Admin SDK

ในบทแนะนำนี้ คุณจะได้เรียนรู้วิธีต่อไปนี้

  • สร้างฐานข้อมูล Firestore
  • ติดตั้งใช้งานแอปพลิเคชันใน Cloud Run ที่เชื่อมต่อกับฐานข้อมูล Firestore

2 ข้อกำหนดเบื้องต้น

  1. หากยังไม่มีบัญชี Google คุณต้องสร้างบัญชี Google
    • ใช้บัญชีส่วนตัวแทนบัญชีงานหรือบัญชีโรงเรียน บัญชีงานและบัญชีโรงเรียนอาจมีข้อจํากัดที่ทำให้คุณเปิดใช้ API ที่จําเป็นสําหรับห้องทดลองนี้ไม่ได้

3 การตั้งค่าโปรเจ็กต์

  1. ลงชื่อเข้าใช้คอนโซล Google Cloud
  2. เปิดใช้การเรียกเก็บเงินในคอนโซล Cloud
    • การทำแล็บนี้ให้เสร็จสมบูรณ์ควรใช้ทรัพยากรในระบบคลาวด์ไม่ถึง $1 USD
    • คุณสามารถทำตามขั้นตอนที่ส่วนท้ายของห้องทดลองนี้เพื่อลบทรัพยากรเพื่อหลีกเลี่ยงการเรียกเก็บเงินเพิ่มเติม
    • ผู้ใช้ใหม่มีสิทธิ์รับช่วงทดลองใช้ฟรีมูลค่า$300 USD
  3. สร้างโปรเจ็กต์ใหม่หรือเลือกนําโปรเจ็กต์ที่มีอยู่มาใช้ซ้ำ

4 เปิดเครื่องมือแก้ไข Cloud Shell

  1. ไปที่ Cloud Shell Editor
  2. หากเทอร์มินัลไม่ปรากฏที่ด้านล่างของหน้าจอ ให้เปิดเทอร์มินัลโดยทำดังนี้
    • คลิกเมนู 3 ขีด ไอคอนเมนู 3 ขีด
    • คลิก Terminal
    • คลิก New Terminalเปิดเทอร์มินัลใหม่ในเครื่องมือแก้ไข Cloud Shell
  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
  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
    

หลังจากผ่านไป 2-3 นาที แอปพลิเคชันควรแสดง URL ให้คุณเข้าชม

ไปที่ URL เพื่อดูการทำงานของแอปพลิเคชัน คุณจะเห็นแอปงานทุกครั้งที่เข้าชม URL หรือรีเฟรชหน้าเว็บ

9 ขอแสดงความยินดี

ในบทแนะนำนี้ คุณได้เรียนรู้วิธีดำเนินการต่อไปนี้

  • สร้างอินสแตนซ์ Cloud SQL สำหรับ PostgreSQL
  • ติดตั้งใช้งานแอปพลิเคชันใน Cloud Run ที่เชื่อมต่อกับฐานข้อมูล Cloud SQL

ล้างข้อมูล

Cloud SQL ไม่มีแพ็กเกจที่ไม่มีค่าใช้จ่ายและจะเรียกเก็บเงินจากคุณหากยังใช้งานต่อไป คุณลบโปรเจ็กต์ที่อยู่ในระบบคลาวด์เพื่อหลีกเลี่ยงการเรียกเก็บเงินเพิ่มเติมได้

แม้ว่า Cloud Run จะไม่เรียกเก็บเงินเมื่อไม่ได้ใช้งานบริการ แต่คุณอาจยังคงถูกเรียกเก็บเงินสำหรับการจัดเก็บอิมเมจคอนเทนเนอร์ใน Artifact Registry การลบโปรเจ็กต์ในระบบคลาวด์จะหยุดการเรียกเก็บเงินสำหรับทรัพยากรทั้งหมดที่ใช้ภายในโปรเจ็กต์นั้น

หากต้องการลบโปรเจ็กต์ ให้ทำดังนี้

gcloud projects delete $GOOGLE_CLOUD_PROJECT

คุณอาจต้องลบทรัพยากรที่ไม่จำเป็นออกจากดิสก์ CloudShell ด้วย ดังนี้

  1. ลบไดเรกทอรีโปรเจ็กต์ Codelab โดยทำดังนี้
    rm -rf ~/task-app
  2. คำเตือน! การดำเนินการถัดไปนี้จะยกเลิกไม่ได้ หากต้องการลบทุกอย่างใน Cloud Shell เพื่อเพิ่มพื้นที่ว่าง คุณสามารถลบไดเรกทอรีหน้าแรกทั้งหมด โปรดตรวจสอบว่าคุณได้บันทึกข้อมูลทั้งหมดที่ต้องการเก็บไว้ไว้ที่อื่นแล้ว
    sudo rm -rf $HOME