با Node.js و Cloud Run یک افزونه Google Workspace بسازید

۱. مقدمه

افزونه‌های Google Workspace برنامه‌های سفارشی‌سازی‌شده‌ای هستند که با برنامه‌های Google Workspace مانند Gmail، Docs، Sheets و Slides ادغام می‌شوند. آن‌ها به توسعه‌دهندگان امکان می‌دهند رابط‌های کاربری سفارشی‌سازی‌شده‌ای ایجاد کنند که مستقیماً در Google Workspace ادغام می‌شوند. افزونه‌ها به کاربران کمک می‌کنند تا با تغییر کمتر زمینه، کارآمدتر کار کنند.

در این آزمایشگاه کد، یاد خواهید گرفت که چگونه یک افزونه ساده برای فهرست وظایف با استفاده از Node.js، Cloud Run و Datastore بسازید و مستقر کنید.

آنچه یاد خواهید گرفت

  • از پوسته ابری استفاده کنید
  • استقرار در Cloud Run
  • یک توصیفگر استقرار افزونه ایجاد و مستقر کنید
  • ایجاد رابط‌های کاربری افزونه با چارچوب کارت
  • پاسخ به تعاملات کاربران
  • استفاده از زمینه کاربر در یک افزونه

۲. تنظیمات و الزامات

برای ایجاد یک پروژه Google Cloud و فعال کردن APIها و سرویس‌هایی که افزونه از آنها استفاده خواهد کرد، دستورالعمل‌های راه‌اندازی را دنبال کنید.

تنظیم محیط خودتنظیم

  1. کنسول ابری را باز کنید و یک پروژه جدید ایجاد کنید. (اگر از قبل حساب Gmail یا Google Workspace ندارید، یکی ایجاد کنید .)

منوی انتخاب پروژه

دکمه پروژه جدید

شناسه پروژه

شناسه پروژه را به خاطر بسپارید، یک نام منحصر به فرد در تمام پروژه‌های Google Cloud (نام بالا قبلاً گرفته شده و برای شما کار نخواهد کرد، متاسفیم!). بعداً در این آزمایشگاه کد به آن PROJECT_ID گفته خواهد شد.

  1. در مرحله بعد، برای استفاده از منابع گوگل کلود، صورتحساب را در کنسول کلود فعال کنید .

اجرای این codelab نباید هزینه زیادی داشته باشد، اگر اصلاً هزینه‌ای داشته باشد. حتماً دستورالعمل‌های بخش «پاکسازی» در انتهای codelab را که به شما توصیه می‌کند چگونه منابع را خاموش کنید تا پس از این آموزش متحمل هزینه نشوید، دنبال کنید. کاربران جدید Google Cloud واجد شرایط برنامه آزمایشی رایگان ۳۰۰ دلاری هستند.

پوسته ابری گوگل

اگرچه می‌توان از راه دور و از طریق لپ‌تاپ، گوگل کلود را مدیریت کرد، اما در این آزمایشگاه کد، ما از گوگل کلود شل ، یک محیط خط فرمان که در فضای ابری اجرا می‌شود، استفاده خواهیم کرد.

فعال کردن پوسته ابری

  1. از کنسول ابری، روی فعال کردن پوسته ابری کلیک کنید آیکون پوسته ابری .

آیکون Cloud Shell در نوار منو

اولین باری که Cloud Shell را باز می‌کنید، یک پیام خوشامدگویی توصیفی به شما نمایش داده می‌شود. اگر پیام خوشامدگویی را مشاهده کردید، روی ادامه کلیک کنید. پیام خوشامدگویی دیگر نمایش داده نمی‌شود. پیام خوشامدگویی به شرح زیر است:

پیام خوشامدگویی کلود شل

آماده‌سازی و اتصال به Cloud Shell فقط چند لحظه طول می‌کشد. پس از اتصال، ترمینال Cloud Shell را مشاهده خواهید کرد:

ترمینال کلود شل

این ماشین مجازی با تمام ابزارهای توسعه مورد نیاز شما بارگذاری شده است. این ماشین یک دایرکتوری خانگی ۵ گیگابایتی پایدار ارائه می‌دهد و در فضای ابری گوگل اجرا می‌شود که عملکرد شبکه و احراز هویت را تا حد زیادی افزایش می‌دهد. تمام کارهای شما در این آزمایشگاه کد را می‌توان با یک مرورگر یا کروم‌بوک انجام داد.

پس از اتصال به Cloud Shell، باید ببینید که از قبل احراز هویت شده‌اید و پروژه از قبل روی شناسه پروژه شما تنظیم شده است.

  1. برای تأیید احراز هویت، دستور زیر را در Cloud Shell اجرا کنید:
gcloud auth list

اگر از شما خواسته شد که به Cloud Shell اجازه دهید تا یک فراخوانی GCP API برقرار کند، روی Authorize کلیک کنید.

خروجی دستور

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

برای تنظیم حساب فعال، دستور زیر را اجرا کنید:

gcloud config set account <ACCOUNT>

برای تأیید اینکه پروژه صحیح را انتخاب کرده‌اید، در Cloud Shell، دستور زیر را اجرا کنید:

gcloud config list project

خروجی دستور

[core]
project = <PROJECT_ID>

اگر پروژه صحیح برگردانده نشد، می‌توانید آن را با این دستور تنظیم کنید:

gcloud config set project <PROJECT_ID>

خروجی دستور

Updated property [core/project].

Codelab از ترکیبی از عملیات خط فرمان و همچنین ویرایش فایل استفاده می‌کند. برای ویرایش فایل، می‌توانید با کلیک بر روی دکمه Open Editor در سمت راست نوار ابزار Cloud Shell، از ویرایشگر کد داخلی در Cloud Shell استفاده کنید. همچنین ویرایشگرهای محبوبی مانند vim و emacs را در Cloud Shell خواهید یافت.

۳. فعال کردن Cloud Run، Datastore و APIهای افزونه

فعال کردن API های ابری

از Cloud Shell، APIهای Cloud را برای اجزایی که استفاده خواهند شد فعال کنید:

gcloud services enable \
  run.googleapis.com \
  cloudbuild.googleapis.com \
  cloudresourcemanager.googleapis.com \
  datastore.googleapis.com \
  gsuiteaddons.googleapis.com

تکمیل این عملیات ممکن است چند لحظه طول بکشد.

پس از اتمام، پیام موفقیت‌آمیزی مشابه این ظاهر می‌شود:

Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.

ایجاد یک نمونه از فروشگاه داده

سپس، App Engine را فعال کنید و یک پایگاه داده Datastore ایجاد کنید. فعال کردن App Engine پیش‌نیاز استفاده از Datastore است، اما ما از App Engine برای هیچ کار دیگری استفاده نخواهیم کرد.

gcloud app create --region=us-central
gcloud firestore databases create --type=datastore-mode --region=us-central

این افزونه برای اجرا و انجام اقدامات روی داده‌های کاربر، به اجازه او نیاز دارد. صفحه رضایت پروژه را برای فعال کردن این قابلیت پیکربندی کنید. برای codelab، صفحه رضایت را به عنوان یک برنامه داخلی پیکربندی خواهید کرد، به این معنی که برای شروع، برای توزیع عمومی نیست.

  1. کنسول گوگل کلود را در یک تب یا پنجره جدید باز کنید.
  2. در کنار «کنسول ابری گوگل»، روی پیکان رو به پایین کلیک کنید فلش رو به پایین و پروژه خود را انتخاب کنید.
  3. در گوشه بالا سمت چپ، روی «منو» کلیک کنید آیکون منو .
  4. روی APIها و خدمات > اعتبارنامه‌ها کلیک کنید. صفحه اعتبارنامه پروژه شما ظاهر می‌شود.
  5. روی صفحه رضایت OAuth کلیک کنید. صفحه "صفحه رضایت OAuth" ظاهر می‌شود.
  6. در قسمت «نوع کاربر»، گزینه «داخلی » را انتخاب کنید. اگر از حساب @gmail.com استفاده می‌کنید، گزینه «خارجی» را انتخاب کنید.
  7. روی ایجاد کلیک کنید. صفحه «ویرایش ثبت برنامه» ظاهر می‌شود.
  8. فرم را پر کنید:
    • در قسمت نام برنامه ، عبارت «افزونه انجام کار» (Todo Add-on) را وارد کنید.
    • در ایمیل پشتیبانی کاربر ، آدرس ایمیل شخصی خود را وارد کنید.
    • در قسمت اطلاعات تماس توسعه‌دهنده ، آدرس ایمیل شخصی خود را وارد کنید.
  9. روی ذخیره و ادامه کلیک کنید. یک فرم Scopes ظاهر می‌شود.
  10. از فرم Scopes، روی ذخیره و ادامه کلیک کنید. خلاصه‌ای ظاهر می‌شود.
  11. روی بازگشت به داشبورد کلیک کنید.

۴. افزونه اولیه را ایجاد کنید

پروژه را اولیه کنید

برای شروع، یک افزونه ساده "Hello world" ایجاد و آن را مستقر خواهید کرد. افزونه‌ها سرویس‌های وب هستند که به درخواست‌های https پاسخ می‌دهند و با یک JSON payload که رابط کاربری و اقدامات لازم را توصیف می‌کند، پاسخ می‌دهند. در این افزونه، از Node.js و چارچوب Express استفاده خواهید کرد.

برای ایجاد این پروژه الگو، با استفاده از Cloud Shell یک دایرکتوری جدید به نام todo-add-on ایجاد کنید و به آن بروید:

mkdir ~/todo-add-on
cd ~/todo-add-on

شما تمام کارهای مربوط به codelab را در این دایرکتوری انجام خواهید داد.

پروژه Node.js را مقداردهی اولیه کنید:

npm init

NPM چندین سوال در مورد پیکربندی پروژه، مانند نام و نسخه، می‌پرسد. برای هر سوال، ENTER را فشار دهید تا مقادیر پیش‌فرض پذیرفته شوند. نقطه ورود پیش‌فرض فایلی به نام index.js است که در ادامه ایجاد خواهیم کرد.

سپس، چارچوب وب اکسپرس را نصب کنید:

npm install --save express express-async-handler

ایجاد بخش پشتیبانی افزونه

وقت آن است که شروع به ایجاد برنامه کنیم.

فایلی با نام index.js ایجاد کنید. برای ایجاد فایل‌ها، می‌توانید با کلیک روی دکمه‌ی Open Editor در نوار ابزار پنجره‌ی Cloud Shell، از ویرایشگر Cloud Shell استفاده کنید. همچنین می‌توانید با استفاده از vim یا emacs، فایل‌ها را در Cloud Shell ویرایش و مدیریت کنید.

بعد از ایجاد فایل index.js ، محتوای زیر را اضافه کنید:

const express = require('express');
const asyncHandler = require('express-async-handler');

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post("/", asyncHandler(async (req, res) => {
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello world!`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

سرور کار خاصی جز نمایش پیام «سلام دنیا» انجام نمی‌دهد و مشکلی نیست. بعداً قابلیت‌های بیشتری اضافه خواهید کرد.

استقرار در Cloud Run

برای استقرار در Cloud Run، برنامه باید کانتینریزه شود.

کانتینر را ایجاد کنید

یک فایل داکر با نام Dockerfile ایجاد کنید که شامل موارد زیر باشد:

FROM node:12-slim

# Create and change to the app directory.
WORKDIR /usr/src/app

# Copy application dependency manifests to the container image.
# A wildcard is used to ensure copying both package.json AND package-lock.json (when available).
# Copying this first prevents re-running npm install on every code change.
COPY package*.json ./

# Install production dependencies.
# If you add a package-lock.json, speed your build by switching to 'npm ci'.
# RUN npm ci --only=production
RUN npm install --only=production

# Copy local code to the container image.
COPY . ./

# Run the web service on container startup.
CMD [ "node", "index.js" ]

فایل‌های ناخواسته را از کانتینر دور نگه دارید

برای کمک به سبک نگه داشتن کانتینر، یک فایل .dockerignore ایجاد کنید که شامل موارد زیر باشد:

Dockerfile
.dockerignore
node_modules
npm-debug.log

فعال کردن ساخت ابری

در این آزمایشگاه کد، شما افزونه را چندین بار با اضافه شدن قابلیت‌های جدید، ساخته و مستقر خواهید کرد. به جای اجرای دستورات جداگانه برای ساخت کانتینر، ارسال آن به رجیستری کانتینر و استقرار آن در Cloud Build، از Cloud Build برای هماهنگ‌سازی این فرآیند استفاده کنید. یک فایل cloudbuild.yaml با دستورالعمل‌هایی در مورد نحوه ساخت و استقرار برنامه ایجاد کنید:

steps:
 # Build the container image
 - name: 'gcr.io/cloud-builders/docker'
   args: ['build', '-t', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME', '.']
 # Push the container image to Container Registry
 - name: 'gcr.io/cloud-builders/docker'
   args: ['push', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME']
 # Deploy container image to Cloud Run
 - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
   entrypoint: gcloud
   args:
   - 'run'
   - 'deploy'
   - '$_SERVICE_NAME'
   - '--image'
   - 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
   - '--region'
   - '$_REGION'
   - '--platform'
   - 'managed'
images:
 - 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
substitutions:
   _SERVICE_NAME: todo-add-on
   _REGION: us-central1

دستورات زیر را برای اعطای مجوز Cloud Build جهت استقرار برنامه اجرا کنید:

PROJECT_ID=$(gcloud config list --format='value(core.project)')
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
gcloud projects add-iam-policy-binding $PROJECT_ID \
    --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
    --role=roles/run.admin
gcloud iam service-accounts add-iam-policy-binding \
    $PROJECT_NUMBER-compute@developer.gserviceaccount.com \
    --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
    --role=roles/iam.serviceAccountUser

ساخت و استقرار بخش پشتیبانی افزونه

برای شروع ساخت، در Cloud Shell، دستور زیر را اجرا کنید:

gcloud builds submit

ساخت و استقرار کامل ممکن است چند دقیقه طول بکشد، مخصوصاً برای اولین بار.

پس از اتمام ساخت، تأیید کنید که سرویس مستقر شده است و URL را پیدا کنید. دستور زیر را اجرا کنید:

gcloud run services list --platform managed

این URL را کپی کنید، برای مرحله بعدی به آن نیاز خواهید داشت - اینکه به Google Workspace بگویید چگونه افزونه را فراخوانی کند.

افزونه را ثبت کنید

حالا که سرور راه‌اندازی و اجرا شده است، افزونه را شرح دهید تا Google Workspace بداند چگونه آن را نمایش داده و فراخوانی کند.

ایجاد یک توصیفگر استقرار

فایل deployment.json با محتوای زیر ایجاد کنید. حتماً از URL برنامه‌ی مستقر شده به جای URL placeholder استفاده کنید.

{
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.execute",
    "https://www.googleapis.com/auth/calendar.addons.execute"
  ],
  "addOns": {
    "common": {
      "name": "Todo Codelab",
      "logoUrl": "https://raw.githubusercontent.com/webdog/octicons-png/main/black/check.png",
      "homepageTrigger": {
        "runFunction": "URL"
      }
    },
    "gmail": {},
    "drive": {},
    "calendar": {},
    "docs": {},
    "sheets": {},
    "slides": {}
  }
}

با اجرای دستور زیر، توصیفگر استقرار را آپلود کنید:

gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json

دسترسی به بخش مدیریت افزونه را مجاز کنید

چارچوب افزونه‌ها همچنین برای فراخوانی سرویس به مجوز نیاز دارد. دستورات زیر را برای به‌روزرسانی سیاست IAM برای Cloud Run اجرا کنید تا به Google Workspace اجازه دهید افزونه را فراخوانی کند:

SERVICE_ACCOUNT_EMAIL=$(gcloud workspace-add-ons get-authorization --format="value(serviceAccountEmail)")
gcloud run services add-iam-policy-binding todo-add-on --platform managed --region us-central1 --role roles/run.invoker --member "serviceAccount:$SERVICE_ACCOUNT_EMAIL"

افزونه را برای آزمایش نصب کنید

برای نصب افزونه در حالت توسعه برای حساب کاربری خود، در Cloud Shell، دستور زیر را اجرا کنید:

gcloud workspace-add-ons deployments install todo-add-on

(Gmail)[https://mail.google.com/] را در یک تب یا پنجره جدید باز کنید. در سمت راست، افزونه‌ای را که دارای علامت تیک است، پیدا کنید.

نماد افزونه نصب شده

برای باز کردن افزونه، روی آیکون تیک کلیک کنید. پیامی برای تأیید افزونه ظاهر می‌شود.

درخواست مجوز

روی «مجاز کردن دسترسی» کلیک کنید و دستورالعمل‌های جریان مجوز را در پنجره بازشو دنبال کنید. پس از تکمیل، افزونه به‌طور خودکار بارگیری مجدد می‌شود و پیام «سلام دنیا!» را نمایش می‌دهد.

تبریک! شما اکنون یک افزونه ساده را مستقر و نصب کرده‌اید. وقت آن است که آن را به یک برنامه فهرست وظایف تبدیل کنید!

۵. دسترسی به هویت کاربر

افزونه‌ها معمولاً توسط بسیاری از کاربران برای کار با اطلاعاتی که برای آنها یا سازمان‌هایشان خصوصی است، استفاده می‌شوند. در این آزمایشگاه کد، افزونه فقط باید وظایف کاربر فعلی را نشان دهد. هویت کاربر از طریق یک توکن هویت که باید رمزگشایی شود، به افزونه ارسال می‌شود.

اضافه کردن محدوده‌ها به توصیف‌گر استقرار

هویت کاربر به طور پیش‌فرض ارسال نمی‌شود. این اطلاعات، داده‌های کاربر هستند و افزونه برای دسترسی به آنها به مجوز نیاز دارد. برای دریافت این مجوز، deployment.json را به‌روزرسانی کنید و دامنه‌های openid و email OAuth را به لیست دامنه‌هایی که افزونه نیاز دارد اضافه کنید. پس از افزودن دامنه‌های OAuth، افزونه از کاربران می‌خواهد که دفعه بعد که از افزونه استفاده می‌کنند، اجازه دسترسی بدهند.

"oauthScopes": [
      "https://www.googleapis.com/auth/gmail.addons.execute",
      "https://www.googleapis.com/auth/calendar.addons.execute",
      "openid",
      "email"
],

سپس، در Cloud Shell، این دستور را برای به‌روزرسانی توصیف‌گر استقرار اجرا کنید:

gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json

سرور افزونه را به‌روزرسانی کنید

اگرچه افزونه برای درخواست هویت کاربر پیکربندی شده است، اما پیاده‌سازی آن هنوز نیاز به به‌روزرسانی دارد.

تجزیه توکن هویت

با اضافه کردن کتابخانه Google auth به پروژه شروع کنید:

npm install --save google-auth-library

سپس index.js را ویرایش کنید تا OAuth2Client را درخواست کند:

const { OAuth2Client } = require('google-auth-library');

سپس یک متد کمکی برای تجزیه توکن شناسه اضافه کنید:

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

نمایش هویت کاربر

الان زمان مناسبی برای یک چک‌پوینت قبل از اضافه کردن تمام قابلیت‌های لیست وظایف است. مسیر برنامه را به‌روزرسانی کنید تا آدرس ایمیل و شناسه منحصر به فرد کاربر را به جای «سلام دنیا» چاپ کند.

app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello ${user.email} ${user.sub}`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

پس از این تغییرات، فایل index.js حاصل باید به شکل زیر باشد:

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello ${user.email} ${user.sub}`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

استقرار مجدد و آزمایش

افزونه را بازسازی و مجدداً مستقر کنید. از Cloud Shell، دستور زیر را اجرا کنید:

gcloud builds submit

پس از استقرار مجدد سرور، جیمیل را باز یا بارگذاری مجدد کنید و افزونه را دوباره باز کنید. از آنجایی که محدوده‌ها تغییر کرده‌اند، افزونه درخواست مجوز مجدد می‌کند. افزونه را دوباره مجاز کنید و پس از اتمام، افزونه آدرس ایمیل و شناسه کاربری شما را نمایش می‌دهد.

حالا که افزونه کاربر را می‌شناسد، می‌توانید قابلیت فهرست وظایف را اضافه کنید.

۶. فهرست وظایف را اجرا کنید

مدل داده اولیه برای codelab ساده است: فهرستی از موجودیت‌های Task ، که هر کدام دارای ویژگی‌هایی برای متن توصیفی وظیفه و یک مهر زمانی هستند.

ایجاد فهرست پایگاه داده

انبار داده (Datastore) قبلاً برای پروژه در codelab فعال شده بود. نیازی به طرحواره (schema) ندارد، اگرچه نیاز به ایجاد صریح ایندکس‌ها برای کوئری‌های ترکیبی دارد. ایجاد ایندکس می‌تواند چند دقیقه طول بکشد، بنابراین ابتدا این کار را انجام خواهید داد.

فایلی با نام index.yaml با محتوای زیر ایجاد کنید:

indexes:
- kind: Task
  ancestor: yes
  properties:
  - name: created

سپس ایندکس‌های Datastore را به‌روزرسانی کنید:

gcloud datastore indexes create index.yaml

وقتی از شما خواسته شد ادامه دهید، ENTER را روی صفحه کلید خود فشار دهید. ایجاد فهرست در پس زمینه اتفاق می‌افتد. در حالی که این اتفاق می‌افتد، شروع به به‌روزرسانی کد افزونه برای پیاده‌سازی "todos" کنید.

به‌روزرسانی بخش پشتیبانی افزونه

کتابخانه Datastore را روی پروژه نصب کنید:

npm install --save @google-cloud/datastore

خواندن و نوشتن در Datastore

index.js را به‌روزرسانی کنید تا «todos» را با وارد کردن کتابخانه datastore و ایجاد کلاینت پیاده‌سازی کند:

const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

اضافه کردن متدهایی برای خواندن و نوشتن وظایف از Datastore:

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

پیاده‌سازی رندرینگ رابط کاربری

بیشتر تغییرات مربوط به رابط کاربری افزونه است. پیش از این، تمام کارت‌های برگردانده شده توسط رابط کاربری ایستا بودند - آنها بسته به داده‌های موجود تغییر نمی‌کردند. در اینجا، کارت باید به صورت پویا بر اساس لیست وظایف فعلی کاربر ساخته شود.

رابط کاربری codelab شامل یک ورودی متن به همراه لیستی از وظایف با کادرهای انتخاب برای علامت‌گذاری تکمیل آنها است. هر یک از این موارد همچنین دارای یک ویژگی onChangeAction هستند که در صورت اضافه یا حذف یک وظیفه توسط کاربر، منجر به فراخوانی مجدد به سرور افزونه می‌شود. در هر یک از این موارد، رابط کاربری باید با لیست وظایف به‌روز شده، رندر مجدد شود. برای مدیریت این موضوع، بیایید یک روش جدید برای ساخت رابط کاربری کارت معرفی کنیم.

به ویرایش index.js ادامه دهید و متد زیر را اضافه کنید:

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    // Input for adding a new task
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        // Create text & checkbox for each task
        tasks.forEach(task => taskListSection.widgets.push({
            decoratedText: {
                text: task.text,
                wrapText: true,
                switchControl: {
                    controlType: 'CHECKBOX',
                    name: 'completedTasks',
                    value: task[datastore.KEY].id,
                    selected: false,
                    onChangeAction: {
                        function: `${baseUrl}/complete`,
                    }
                }
            }
        }));
    } else {
        // Placeholder for empty task list
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    }
    return card;
}

به‌روزرسانی مسیرها

حالا که متدهای کمکی برای خواندن و نوشتن در Datastore و ساخت رابط کاربری وجود دارد، بیایید آنها را در مسیرهای برنامه به هم متصل کنیم. مسیر موجود را جایگزین کنید و دو مورد دیگر اضافه کنید: یکی برای اضافه کردن وظایف و دیگری برای حذف آنها.

app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date()
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

فایل index.js نهایی و کاملاً کاربردی به صورت زیر است:

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date()
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    // Input for adding a new task
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        // Create text & checkbox for each task
        tasks.forEach(task => taskListSection.widgets.push({
            decoratedText: {
                text: task.text,
                wrapText: true,
                switchControl: {
                    controlType: 'CHECKBOX',
                    name: 'completedTasks',
                    value: task[datastore.KEY].id,
                    selected: false,
                    onChangeAction: {
                        function: `${baseUrl}/complete`,
                    }
                }
            }
        }));
    } else {
        // Placeholder for empty task list
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    }
    return card;
}

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

استقرار مجدد و آزمایش

برای بازسازی و استقرار مجدد افزونه، ساخت را شروع کنید. در Cloud Shell، دستور زیر را اجرا کنید:

gcloud builds submit

در جیمیل، افزونه را دوباره بارگذاری کنید تا رابط کاربری جدید ظاهر شود. یک دقیقه وقت بگذارید و افزونه را بررسی کنید. با وارد کردن متن در ورودی و فشار دادن کلید ENTER روی صفحه کلید، چند وظیفه اضافه کنید، سپس روی کادر انتخاب کلیک کنید تا آنها را حذف کنید.

افزونه‌ای با وظایف

اگر مایل باشید، می‌توانید از مرحله آخر این آزمایشگاه کد عبور کنید و پروژه خود را اصلاح کنید. یا اگر مایلید به یادگیری بیشتر در مورد افزونه‌ها ادامه دهید، یک مرحله دیگر وجود دارد که می‌توانید آن را انجام دهید.

۷. (اختیاری) افزودن زمینه

یکی از قدرتمندترین ویژگی‌های افزونه‌ها، آگاهی از زمینه است. افزونه‌ها می‌توانند با اجازه کاربر، به زمینه‌های Google Workspace مانند ایمیلی که کاربر در حال مشاهده آن است، یک رویداد تقویم و یک سند دسترسی پیدا کنند. افزونه‌ها همچنین می‌توانند اقداماتی مانند درج محتوا انجام دهند. در این آزمایشگاه کد، پشتیبانی زمینه را برای ویرایشگرهای Workspace (Docs، Sheets و Slides) اضافه خواهید کرد تا سند فعلی را به هر وظیفه‌ای که در ویرایشگرها ایجاد شده است، پیوست کنید. وقتی وظیفه نمایش داده می‌شود، کلیک بر روی آن، سند را در یک برگه جدید باز می‌کند تا کاربر برای اتمام وظیفه خود به سند بازگردد.

به‌روزرسانی بخش پشتیبانی افزونه

مسیر newTask را به‌روزرسانی کنید

ابتدا، مسیر /newTask را به‌روزرسانی کنید تا در صورت وجود، شناسه سند را در یک وظیفه لحاظ کند:

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    // Get the current document if it is present
    const editorInfo = event.docs || event.sheets || event.slides;
    let document = null;
    if (editorInfo && editorInfo.id) {
        document = {
            id: editorInfo.id,
        }
    }
    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date(),
        document,
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

وظایف جدید ایجاد شده اکنون شامل شناسه سند فعلی هستند. با این حال، زمینه در ویرایشگرها به طور پیش‌فرض به اشتراک گذاشته نمی‌شود. مانند سایر داده‌های کاربر، کاربر باید به افزونه اجازه دسترسی به داده‌ها را بدهد. برای جلوگیری از اشتراک‌گذاری بیش از حد اطلاعات، رویکرد ترجیحی درخواست و اعطای مجوز بر اساس هر فایل است.

رابط کاربری را به‌روزرسانی کنید

در index.js ، buildCard به‌روزرسانی کنید تا دو تغییر ایجاد شود. اولین تغییر، به‌روزرسانی رندر وظایف برای افزودن لینک به سند در صورت وجود است. دومین تغییر، نمایش یک اعلان مجوز اختیاری در صورتی است که افزونه در یک ویرایشگر رندر شده باشد و دسترسی به فایل هنوز اعطا نشده باشد.

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        tasks.forEach(task => {
            const widget = {
                decoratedText: {
                    text: task.text,
                    wrapText: true,
                    switchControl: {
                        controlType: 'CHECKBOX',
                        name: 'completedTasks',
                        value: task[datastore.KEY].id,
                        selected: false,
                        onChangeAction: {
                            function: `${baseUrl}/complete`,
                        }
                    }
                }
            };
            // Make item clickable and open attached doc if present
            if (task.document) {
                widget.decoratedText.bottomLabel = 'Click to open  document.';
                const id = task.document.id;
                const url = `https://drive.google.com/open?id=${id}`
                widget.decoratedText.onClick = {
                    openLink: {
                        openAs: 'FULL_SIZE',
                        onClose: 'NOTHING',
                        url: url,
                    }
                }
            }
            taskListSection.widgets.push(widget)
        });
    } else {
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    };

    // Display file authorization prompt if the host is an editor
    // and no doc ID present
    const event = req.body;
    const editorInfo = event.docs || event.sheets || event.slides;
    const showFileAuth = editorInfo && editorInfo.id === undefined;
    if (showFileAuth) {
        card.fixedFooter = {
            primaryButton: {
                text: 'Authorize file access',
                onClick: {
                    action: {
                        function: `${baseUrl}/authorizeFile`,
                    }
                }
            }
        }
    }
    return card;
}

پیاده سازی مسیر مجوزدهی فایل

دکمه‌ی احراز هویت یک مسیر جدید به برنامه اضافه می‌کند، پس بیایید آن را پیاده‌سازی کنیم. این مسیر مفهوم جدیدی به نام اقدامات برنامه‌ی میزبان را معرفی می‌کند. اینها دستورالعمل‌های ویژه‌ای برای تعامل با برنامه‌ی میزبان افزونه هستند. در این مورد، برای درخواست دسترسی به فایل ویرایشگر فعلی.

در index.js ، مسیر /authorizeFile را اضافه کنید:

app.post('/authorizeFile', asyncHandler(async (req, res) => {
    const responsePayload = {
        renderActions: {
            hostAppAction: {
                editorAction: {
                    requestFileScopeForActiveDocument: {}
                }
            },
        }
    };
    res.json(responsePayload);
}));

فایل index.js نهایی و کاملاً کاربردی به صورت زیر است:

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    // Get the current document if it is present
    const editorInfo = event.docs || event.sheets || event.slides;
    let document = null;
    if (editorInfo && editorInfo.id) {
        document = {
            id: editorInfo.id,
        }
    }
    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date(),
        document,
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/authorizeFile', asyncHandler(async (req, res) => {
    const responsePayload = {
        renderActions: {
            hostAppAction: {
                editorAction: {
                    requestFileScopeForActiveDocument: {}
                }
            },
        }
    };
    res.json(responsePayload);
}));

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        tasks.forEach(task => {
            const widget = {
                decoratedText: {
                    text: task.text,
                    wrapText: true,
                    switchControl: {
                        controlType: 'CHECKBOX',
                        name: 'completedTasks',
                        value: task[datastore.KEY].id,
                        selected: false,
                        onChangeAction: {
                            function: `${baseUrl}/complete`,
                        }
                    }
                }
            };
            // Make item clickable and open attached doc if present
            if (task.document) {
                widget.decoratedText.bottomLabel = 'Click to open  document.';
                const id = task.document.id;
                const url = `https://drive.google.com/open?id=${id}`
                widget.decoratedText.onClick = {
                    openLink: {
                        openAs: 'FULL_SIZE',
                        onClose: 'NOTHING',
                        url: url,
                    }
                }
            }
            taskListSection.widgets.push(widget)
        });
    } else {
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    };

    // Display file authorization prompt if the host is an editor
    // and no doc ID present
    const event = req.body;
    const editorInfo = event.docs || event.sheets || event.slides;
    const showFileAuth = editorInfo && editorInfo.id === undefined;
    if (showFileAuth) {
        card.fixedFooter = {
            primaryButton: {
                text: 'Authorize file access',
                onClick: {
                    action: {
                        function: `${baseUrl}/authorizeFile`,
                    }
                }
            }
        }
    }
    return card;
}

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

اضافه کردن محدوده‌ها به توصیف‌گر استقرار

قبل از بازسازی سرور، توصیفگر استقرار افزونه را به‌روزرسانی کنید تا دامنه OAuth مربوط به https://www.googleapis.com/auth/drive.file را شامل شود. deployment.json را به‌روزرسانی کنید تا https://www.googleapis.com/auth/drive.file را به لیست دامنه‌های OAuth اضافه کنید:

"oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.execute",
    "https://www.googleapis.com/auth/calendar.addons.execute",
    "https://www.googleapis.com/auth/drive.file",
    "openid",
    "email"
]

با اجرای این دستور Cloud Shell، نسخه جدید را آپلود کنید:

gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json

استقرار مجدد و آزمایش

در نهایت، سرور را بازسازی کنید. از Cloud Shell، دستور زیر را اجرا کنید:

gcloud builds submit

پس از اتمام کار، به جای باز کردن جیمیل، یک سند گوگل موجود را باز کنید یا با باز کردن doc.new یک سند جدید ایجاد کنید. در صورت ایجاد یک سند جدید، حتماً متنی را وارد کنید یا نامی برای فایل انتخاب کنید.

افزونه را باز کنید. افزونه دکمه‌ی «مجاز کردن دسترسی به فایل» را در پایین افزونه نمایش می‌دهد. روی دکمه کلیک کنید، سپس دسترسی به فایل را مجاز کنید.

پس از تأیید، در حالی که در ویرایشگر هستید، یک وظیفه اضافه کنید. وظیفه دارای برچسبی است که نشان می‌دهد سند پیوست شده است. کلیک روی لینک، سند را در یک برگه جدید باز می‌کند. البته، باز کردن سندی که از قبل باز کرده‌اید کمی احمقانه است. اگر می‌خواهید رابط کاربری را برای فیلتر کردن لینک‌های سند فعلی بهینه کنید، این را امتیاز اضافی در نظر بگیرید!

۸. تبریک

تبریک! شما با موفقیت یک افزونه‌ی Google Workspace را با استفاده از Cloud Run ساختید و مستقر کردید. در حالی که codelab بسیاری از مفاهیم اصلی برای ساخت یک افزونه را پوشش داد، موارد بسیار بیشتری برای بررسی وجود دارد. به منابع زیر مراجعه کنید و فراموش نکنید که پروژه‌ی خود را تمیز کنید تا از هزینه‌های اضافی جلوگیری کنید.

تمیز کردن

برای حذف نصب افزونه از حساب کاربری خود، در Cloud Shell، این دستور را اجرا کنید:

gcloud workspace-add-ons deployments uninstall todo-add-on

برای جلوگیری از تحمیل هزینه به حساب پلتفرم گوگل کلود خود برای منابع استفاده شده در این آموزش:

  • در کنسول ابری، به صفحه مدیریت منابع بروید. در گوشه بالا سمت چپ، روی منو کلیک کنید. آیکون منو > مدیریت منابع و ادمین > مدیریت منابع .
  1. در لیست پروژه‌ها، پروژه خود را انتخاب کنید و سپس روی حذف کلیک کنید.
  2. در کادر محاوره‌ای، شناسه پروژه را تایپ کنید و سپس برای حذف پروژه، روی خاموش کردن (Shut down) کلیک کنید.

بیشتر بدانید