لمحة عن هذا الدرس التطبيقي حول الترميز
1. نظرة عامة
Cloud Run هي منصّة مُدارة بالكامل تتيح لك تشغيل الرمز البرمجي مباشرةً على بنية Google الأساسية القابلة للتوسّع. سيوضّح هذا الدرس التطبيقي كيفية ربط تطبيق Angular على Cloud Run بقاعدة بيانات Firestore باستخدام حزمة تطوير البرامج (SDK) الخاصة بالمشرفين في Node.js.
في هذه الميزة الاختبارية، ستتعرّف على كيفية تنفيذ ما يلي:
- إنشاء قاعدة بيانات في Firestore
- نشر تطبيق على Cloud Run يتصل بقاعدة بيانات Firestore
2. المتطلبات الأساسية
- إذا لم يكن لديك حساب على Google، عليك إنشاء حساب على Google.
- استخدام حساب شخصي بدلاً من حساب عمل أو حساب تديره مؤسسة تعليمية قد تفرض حسابات العمل والحسابات التي تديرها المؤسسات التعليمية قيودًا تمنعك من تفعيل واجهات برمجة التطبيقات اللازمة لهذا الدرس التطبيقي.
3. إعداد المشروع
- سجِّل الدخول إلى Google Cloud Console.
- فعِّل الفوترة في Cloud Console.
- من المفترض أن تبلغ تكلفة إكمال هذا البرنامج التدريبي أقل من دولار أمريكي واحد في موارد Cloud.
- يمكنك اتّباع الخطوات الواردة في نهاية هذا البرنامج التدريبي لحذف الموارد لتجنُّب تحصيل المزيد من الرسوم.
- يكون المستخدمون الجدد مؤهّلين للاستفادة من فترة تجريبية مجانية بقيمة 300 دولار أمريكي.
- أنشئ مشروعًا جديدًا أو اختَر إعادة استخدام مشروع حالي.
4. فتح محرِّر Cloud Shell
- انتقِل إلى محرِّر Cloud Shell.
- إذا لم تظهر المحطة الطرفية في أسفل الشاشة، افتح المحطة الطرفية باتّباع الخطوات التالية:
- انقر على قائمة الخطوط الثلاثة
.
- انقر على Terminal (الوحدة الطرفية).
- انقر على وحدة تحكّم جديدة
.
- انقر على قائمة الخطوط الثلاثة
- في الوحدة الطرفية، اضبط مشروعك باستخدام الأمر التالي:
- طبيعة الحضور:
gcloud config set project [PROJECT_ID]
- مثال:
gcloud config set project lab-project-id-example
- إذا لم تتذكر رقم تعريف المشروع:
- يمكنك إدراج جميع أرقام تعريف مشاريعك باستخدام:
gcloud projects list | awk '/PROJECT_ID/{print $2}'
- يمكنك إدراج جميع أرقام تعريف مشاريعك باستخدام:
- طبيعة الحضور:
- إذا طُلب منك تفويض، انقر على تفويض للمتابعة.
- من المفترض أن تظهر لك هذه الرسالة:
إذا ظهر لك رمزUpdated property [core/project].
WARNING
وتلقّيت رسالةDo you want to continue (Y/N)?
، هذا يعني على الأرجح أنّك أدخلت رقم تعريف المشروع بشكل غير صحيح. اضغط علىN
، ثم اضغط علىEnter
، وحاول تنفيذ الأمرgcloud config set project
مرة أخرى.
5. تفعيل واجهات برمجة التطبيقات
في الوحدة الطرفية، فعِّل واجهات برمجة التطبيقات:
gcloud services enable \
firestore.googleapis.com \
run.googleapis.com \
artifactregistry.googleapis.com \
cloudbuild.googleapis.com
إذا طُلب منك تفويض، انقر على تفويض للمتابعة.
قد يستغرق تنفيذ هذا الأمر بضع دقائق، ولكن من المفترض أن يؤدي في النهاية إلى ظهور رسالة ناجحة مشابهة لهذه الرسالة:
Operation "operations/acf.p2-73d90d00-47ee-447a-b600" finished successfully.
6. إنشاء قاعدة بيانات Firestore
- تنفيذ الأمر
gcloud firestore databases create
لإنشاء قاعدة بيانات firestoregcloud firestore databases create --location=nam5
7. تحضير الطلب
حضِّر تطبيق Next.js يستجيب لطلبات HTTP.
- لإنشاء مشروع Next.js جديد باسم
task-app
، استخدِم الأمر التالي:npx --yes @angular/cli@19.2.5 new task-app \
--minimal \
--inline-template \
--inline-style \
--ssr \
--server-routing \
--defaults - تغيير الدليل إلى
task-app
:cd task-app
- ثبِّت
firebase-admin
للتفاعل مع قاعدة بيانات Firestore.npm install firebase-admin
- افتح ملف
server.ts
في محرِّر Cloud Shell: من المفترض أن يظهر ملف الآن في أعلى الشاشة. يمكنك هنا تعديل ملفcloudshell edit src/server.ts
server.ts
هذا. - احذف المحتوى الحالي من ملف
server.ts
. - انسخ الرمز التالي والصقه في ملف
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); - افتح ملف
angular.json
في محرِّر Cloud Shell: سنضيف الآن السطرcloudshell edit angular.json
"externalDependencies": ["firebase-admin"]
إلى ملفangular.json
. - احذف المحتوى الحالي من ملف
angular.json
. - انسخ الرمز التالي والصقه في ملف
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"]
- افتح ملف
app.component.ts
في محرِّر Cloud Shell: من المفترض أن يظهر الآن ملف حالي في الجزء العلوي من الشاشة. يمكنك هنا تعديل ملفcloudshell edit src/app/app.component.ts
app.component.ts
هذا. - احذف المحتوى الحالي من ملف
app.component.ts
. - انسخ الرمز التالي والصقه في ملف
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
- نفِّذ الأمر أدناه لنشر تطبيقك على Cloud Run:
gcloud run deploy helloworld \
--region=us-central1 \
--source=. - اضغط على
Y
وEnter
لتأكيد رغبتك في المتابعة إذا طُلب منك ذلك:Do you want to continue (Y/n)? Y
بعد بضع دقائق، من المفترض أن يقدّم التطبيق عنوان URL يمكنك الانتقال إليه.
انتقِل إلى عنوان URL للاطّلاع على تطبيقك أثناء تنفيذه. سيظهر لك تطبيق المهام في كل مرة تزور فيها عنوان URL أو تُعيد فيها تحميل الصفحة.
9. تهانينا
في هذه الميزة الاختبارية، تعلمت كيفية تنفيذ ما يلي:
- إنشاء مثيل Cloud SQL for PostgreSQL
- نشر تطبيق على Cloud Run يتصل بقاعدة بيانات Cloud SQL
تَنظيم
لا تتوفّر فئة مجانية في Cloud SQL، وسيتم تحصيل رسوم منك في حال مواصلة استخدامها. يمكنك حذف مشروعك على Cloud لتجنُّب تحصيل رسوم إضافية.
على الرغم من أنّ Cloud Run لا تحصّل رسومًا عندما تكون الخدمة غير مستخدَمة، قد يتم تحصيل رسوم منك مقابل تخزين صورة الحاوية في Artifact Registry. يؤدي حذف مشروعك على Cloud إلى إيقاف الفوترة لجميع الموارد المستخدَمة في ذلك المشروع.
إذا أردت حذف المشروع، اتّبِع الخطوات التالية:
gcloud projects delete $GOOGLE_CLOUD_PROJECT
يمكنك أيضًا حذف الموارد غير الضرورية من قرص Cloudshell. يمكنك إجراء ما يلي:
- احذف دليل مشروع Codelab:
rm -rf ~/task-app
- تحذير! لا يمكن التراجع عن هذا الإجراء التالي. إذا أردت حذف كل المحتوى على Cloud Shell لإخلاء بعض المساحة، يمكنك حذف الدليل الرئيسي كاملاً. احرص على حفظ كل ما تريد الاحتفاظ به في مكان آخر.
sudo rm -rf $HOME