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

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
    

เรียนรู้ต่อไป