با استفاده از Node.js Admin SDK، یک برنامه Angular پشته کامل را در Cloud Run با Firestore اجرا کنید.

۱. مرور کلی

Cloud Run یک پلتفرم کاملاً مدیریت‌شده است که به شما امکان می‌دهد کد خود را مستقیماً روی زیرساخت مقیاس‌پذیر گوگل اجرا کنید. این Codelab نحوه اتصال یک برنامه Angular روی Cloud Run به پایگاه داده Firestore با استفاده از Node.js Admin SDK را نشان می‌دهد.

در این آزمایشگاه، شما یاد خواهید گرفت که چگونه:

  • ایجاد پایگاه داده فایراستور
  • یک برنامه را روی Cloud Run مستقر کنید که به پایگاه داده Firestore شما متصل شود

۲. پیش‌نیازها

  1. اگر از قبل حساب گوگل ندارید، باید یک حساب گوگل ایجاد کنید .
    • به جای حساب کاری یا تحصیلی از یک حساب شخصی استفاده کنید. حساب‌های کاری و تحصیلی ممکن است محدودیت‌هایی داشته باشند که مانع از فعال کردن APIهای مورد نیاز برای این آزمایشگاه توسط شما شود.

۳. راه‌اندازی پروژه

  1. وارد کنسول ابری گوگل شوید.
  2. فعال کردن پرداخت در کنسول ابری
    • تکمیل این آزمایشگاه باید کمتر از ۱ دلار آمریکا از طریق منابع ابری هزینه داشته باشد.
    • شما می‌توانید مراحل انتهای این آزمایش را برای حذف منابع دنبال کنید تا از هزینه‌های بیشتر جلوگیری شود.
    • کاربران جدید واجد شرایط استفاده از دوره آزمایشی رایگان ۳۰۰ دلاری هستند.
  3. یک پروژه جدید ایجاد کنید یا از یک پروژه موجود دوباره استفاده کنید.

۴. ویرایشگر Cloud Shell را باز کنید

  1. به ویرایشگر Cloud Shell بروید
  2. اگر ترمینال در پایین صفحه نمایش داده نشد، آن را باز کنید:
    • روی منوی همبرگری کلیک کنید آیکون منوی همبرگری
    • روی ترمینال کلیک کنید
    • روی ترمینال جدید کلیک کنید باز کردن ترمینال جدید در ویرایشگر 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 اجرا کنید.

۵. فعال کردن 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.

۶. ایجاد پایگاه داده فایراستور

  1. دستور gcloud firestore databases create اجرا کنید تا یک پایگاه داده firestore ایجاد شود.
    gcloud firestore databases create --location=nam5
    

۷. آماده‌سازی درخواست

یک برنامه 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. برای تعامل با پایگاه داده Firestore firebase-admin را نصب کنید.
    npm install firebase-admin
    
  1. فایل server.ts را در ویرایشگر Cloud Shell باز کنید:
    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 باز کنید:
    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"
            }
          }
        }
      }
    }
    

"وابستگی‌های خارجی": ["firebase-admin"]

  1. فایل app.component.ts را در ویرایشگر Cloud Shell باز کنید:
    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();
      }
    }
    

اکنون برنامه آماده‌ی استقرار است.

۸. برنامه را روی 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) بازدید می‌کنید یا صفحه را رفرش می‌کنید، برنامه‌ی وظیفه (task app) را مشاهده خواهید کرد.

۹. تبریک

در این آزمایشگاه، شما یاد گرفته‌اید که چگونه موارد زیر را انجام دهید:

  • ایجاد یک Cloud SQL برای نمونه PostgreSQL
  • یک برنامه را در Cloud Run مستقر کنید که به پایگاه داده Cloud SQL شما متصل شود

تمیز کردن

Cloud SQL نسخه رایگان ندارد و در صورت ادامه استفاده، از شما هزینه دریافت می‌شود. می‌توانید پروژه Cloud خود را حذف کنید تا از هزینه‌های اضافی جلوگیری شود.

اگرچه Cloud Run در صورت عدم استفاده از سرویس، هزینه‌ای دریافت نمی‌کند، اما ممکن است همچنان برای ذخیره تصویر کانتینر در Artifact Registry هزینه دریافت شود. حذف پروژه Cloud شما، پرداخت هزینه برای تمام منابع استفاده شده در آن پروژه را متوقف می‌کند.

اگر مایلید، پروژه را حذف کنید:

gcloud projects delete $GOOGLE_CLOUD_PROJECT

همچنین ممکن است بخواهید منابع غیرضروری را از دیسک cloudshell خود حذف کنید. می‌توانید:

  1. پوشه پروژه codelab را حذف کنید:
    rm -rf ~/task-app
    
  2. هشدار! اقدام بعدی قابل بازگشت نیست! اگر می‌خواهید همه چیز را در Cloud Shell خود حذف کنید تا فضا آزاد شود، می‌توانید کل دایرکتوری خانگی خود را حذف کنید . مراقب باشید که هر چیزی که می‌خواهید نگه دارید در جای دیگری ذخیره شده باشد.
    sudo rm -rf $HOME
    

به یادگیری ادامه دهید