1. Giới thiệu
Tiện ích bổ sung của Google Workspace là các ứng dụng tuỳ chỉnh được tích hợp với các ứng dụng của Google Workspace như Gmail, Tài liệu, Trang tính và Trang trình bày. Các tiện ích này cho phép nhà phát triển tạo giao diện người dùng tuỳ chỉnh được tích hợp trực tiếp vào Google Workspace. Tiện ích bổ sung giúp người dùng làm việc hiệu quả hơn mà không cần chuyển đổi ngữ cảnh.
Trong lớp học lập trình này, bạn sẽ tìm hiểu cách tạo và triển khai một tiện ích bổ sung danh sách việc cần làm đơn giản bằng Node.js, Cloud Run và Datastore.
Kiến thức bạn sẽ học được
- Sử dụng Cloud Shell
- Triển khai lên Cloud Run
- Tạo và triển khai một bộ mô tả triển khai Tiện ích bổ sung
- Tạo giao diện người dùng tiện ích bổ sung bằng khung thẻ
- Phản hồi các hoạt động tương tác của người dùng
- Tận dụng bối cảnh người dùng trong Tiện ích bổ sung
2. Thiết lập và yêu cầu
Làm theo hướng dẫn thiết lập để tạo một dự án Google Cloud và bật các API cũng như dịch vụ mà tiện ích bổ sung sẽ sử dụng.
Thiết lập môi trường theo tốc độ của riêng bạn
- Mở Cloud Console rồi tạo một dự án mới. (Nếu chưa có tài khoản Gmail hoặc Google Workspace, hãy tạo một tài khoản.)
Hãy nhớ mã dự án, một tên duy nhất trên tất cả các dự án trên Google Cloud (tên ở trên đã được sử dụng và sẽ không hoạt động đối với bạn, xin lỗi!). Sau này trong lớp học lập trình này, chúng ta sẽ gọi nó là PROJECT_ID.
- Tiếp theo, để sử dụng các tài nguyên của Google Cloud, hãy bật tính năng thanh toán trong Cloud Console.
Việc thực hiện lớp học lập trình này sẽ không tốn nhiều chi phí, nếu có. Hãy nhớ làm theo mọi hướng dẫn trong phần "Dọn dẹp" ở cuối lớp học lập trình để biết cách tắt các tài nguyên nhằm tránh bị tính phí ngoài phạm vi hướng dẫn này. Người dùng mới của Google Cloud đủ điều kiện tham gia chương trình Dùng thử miễn phí trị giá 300 USD.
Google Cloud Shell
Mặc dù có thể vận hành Google Cloud từ xa trên máy tính xách tay, nhưng trong lớp học lập trình này, chúng ta sẽ sử dụng Google Cloud Shell, một môi trường dòng lệnh chạy trên Cloud.
Kích hoạt Cloud Shell
- Trong Cloud Console, hãy nhấp vào Kích hoạt Cloud Shell
.
Lần đầu tiên mở Cloud Shell, bạn sẽ thấy một thông điệp chào mừng mô tả. Nếu bạn thấy thông điệp chào mừng, hãy nhấp vào Tiếp tục. Thông điệp chào mừng sẽ không xuất hiện lại. Sau đây là thông điệp chào mừng:
Quá trình cung cấp và kết nối với Cloud Shell chỉ mất vài giây. Sau khi kết nối, bạn sẽ thấy Terminal của Cloud Shell:
Máy ảo này được trang bị tất cả các công cụ phát triển mà bạn cần. Nền tảng này cung cấp một thư mục chính có dung lượng 5 GB và chạy trong Google Cloud, giúp tăng cường đáng kể hiệu suất mạng và hoạt động xác thực. Bạn có thể thực hiện mọi thao tác trong lớp học lập trình này bằng trình duyệt hoặc Chromebook.
Sau khi kết nối với Cloud Shell, bạn sẽ thấy rằng mình đã được xác thực và dự án đã được đặt thành mã dự án của bạn.
- Chạy lệnh sau trong Cloud Shell để xác nhận rằng bạn đã được xác thực:
gcloud auth list
Nếu bạn được nhắc cho phép Cloud Shell thực hiện lệnh gọi API GCP, hãy nhấp vào Uỷ quyền.
Đầu ra của lệnh
Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
Để đặt tài khoản đang hoạt động, hãy chạy lệnh:
gcloud config set account <ACCOUNT>
Để xác nhận rằng bạn đã chọn đúng dự án, hãy chạy lệnh sau trong Cloud Shell:
gcloud config list project
Đầu ra của lệnh
[core] project = <PROJECT_ID>
Nếu dự án chính xác không được trả về, bạn có thể đặt dự án đó bằng lệnh sau:
gcloud config set project <PROJECT_ID>
Đầu ra của lệnh
Updated property [core/project].
Lớp học lập trình này sử dụng kết hợp các thao tác dòng lệnh cũng như chỉnh sửa tệp. Để chỉnh sửa tệp, bạn có thể sử dụng trình soạn thảo mã tích hợp trong Cloud Shell bằng cách nhấp vào nút Mở trình chỉnh sửa ở bên phải thanh công cụ Cloud Shell. Bạn cũng sẽ thấy các trình chỉnh sửa phổ biến như vim và emacs có trong Cloud Shell.
3. Bật Cloud Run, Datastore và các API bổ trợ
Bật Cloud API
Từ Cloud Shell, hãy bật Cloud APIs cho các thành phần sẽ được dùng:
gcloud services enable \ run.googleapis.com \ cloudbuild.googleapis.com \ cloudresourcemanager.googleapis.com \ datastore.googleapis.com \ gsuiteaddons.googleapis.com
Thao tác này có thể mất vài phút để hoàn tất.
Sau khi hoàn tất, một thông báo thành công tương tự như thông báo này sẽ xuất hiện:
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
Tạo một thực thể kho dữ liệu
Tiếp theo, hãy bật App Engine và tạo một cơ sở dữ liệu Datastore. Việc bật App Engine là điều kiện tiên quyết để sử dụng Datastore, nhưng chúng ta sẽ không sử dụng App Engine cho bất kỳ mục đích nào khác.
gcloud app create --region=us-central gcloud firestore databases create --type=datastore-mode --region=us-central
Tạo màn hình xin phép bằng OAuth
Tiện ích bổ sung này yêu cầu người dùng cấp quyền để chạy và thực hiện hành động trên dữ liệu của họ. Định cấu hình màn hình xin phép của dự án để bật tính năng này. Để bắt đầu, bạn sẽ định cấu hình màn hình xin phép dưới dạng một ứng dụng nội bộ (tức là không phân phối công khai) cho lớp học lập trình này.
- Mở Google Cloud Console trong một thẻ hoặc cửa sổ mới.
- Bên cạnh "Google Cloud Console", hãy nhấp vào biểu tượng Mũi tên xuống
rồi chọn dự án của bạn. - Ở góc trên cùng bên trái, hãy nhấp vào biểu tượng Trình đơn
. - Nhấp vào API và Dịch vụ > Thông tin xác thực. Trang thông tin xác thực cho dự án của bạn sẽ xuất hiện.
- Nhấp vào màn hình xin phép bằng OAuth. Màn hình "Màn hình xin phép bằng OAuth" sẽ xuất hiện.
- Trong phần "Loại người dùng", hãy chọn Nội bộ. Nếu bạn đang sử dụng tài khoản @gmail.com, hãy chọn Bên ngoài.
- Nhấp vào Tạo. Trang "Chỉnh sửa thông tin đăng ký ứng dụng" sẽ xuất hiện.
- Điền vào biểu mẫu:
- Trong phần Tên ứng dụng, hãy nhập "Tiện ích bổ sung Todo".
- Trong mục Email hỗ trợ người dùng, hãy nhập địa chỉ email cá nhân của bạn.
- Trong mục Thông tin liên hệ của nhà phát triển, hãy nhập địa chỉ email cá nhân của bạn.
- Nhấp vào Lưu và tiếp tục. Biểu mẫu Scopes (Phạm vi) sẽ xuất hiện.
- Trong biểu mẫu Phạm vi, hãy nhấp vào Lưu và tiếp tục. Một bản tóm tắt sẽ xuất hiện.
- Nhấp vào Quay lại Trang tổng quan.
4. Tạo tiện ích bổ sung ban đầu
Khởi chạy dự án
Để bắt đầu, bạn sẽ tạo một tiện ích bổ sung đơn giản "Xin chào thế giới" rồi triển khai tiện ích đó. Tiện ích bổ sung là các dịch vụ web phản hồi các yêu cầu https và phản hồi bằng một tải trọng JSON mô tả giao diện người dùng và các hành động cần thực hiện. Trong tiện ích bổ sung này, bạn sẽ sử dụng Node.js và khung Express.
Để tạo dự án mẫu này, hãy dùng Cloud Shell để tạo một thư mục mới có tên là todo-add-on rồi chuyển đến thư mục đó:
mkdir ~/todo-add-on cd ~/todo-add-on
Bạn sẽ thực hiện tất cả các thao tác cho lớp học lập trình trong thư mục này.
Khởi động dự án Node.js:
npm init
NPM sẽ hỏi một số câu hỏi về cấu hình dự án, chẳng hạn như tên và phiên bản. Đối với mỗi câu hỏi, hãy nhấn ENTER để chấp nhận các giá trị mặc định. Điểm truy cập mặc định là một tệp có tên index.js. Chúng ta sẽ tạo tệp này ở bước tiếp theo.
Tiếp theo, hãy cài đặt khung web Express:
npm install --save express express-async-handler
Tạo phần phụ trợ của tiện ích bổ sung
Đã đến lúc bắt đầu tạo ứng dụng.
Tạo một tệp có tên là index.js. Để tạo tệp, bạn có thể sử dụng Cloud Shell Editor bằng cách nhấp vào nút Open Editor (Mở trình chỉnh sửa) trên thanh công cụ của cửa sổ Cloud Shell. Ngoài ra, bạn có thể chỉnh sửa và quản lý tệp trong Cloud Shell bằng cách sử dụng vim hoặc emacs.
Sau khi tạo tệp index.js, hãy thêm nội dung sau:
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}`)
});
Máy chủ không làm gì nhiều ngoài việc hiện thông báo "Xin chào thế giới" và điều đó không sao cả. Sau này, bạn sẽ thêm nhiều chức năng khác.
Triển khai lên Cloud Run
Để triển khai trên Cloud Run, ứng dụng cần được đóng gói vào vùng chứa.
Tạo vùng chứa
Tạo một Dockerfile có tên là Dockerfile chứa:
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" ]
Loại bỏ các tệp không mong muốn khỏi vùng chứa
Để giữ cho vùng chứa có kích thước nhỏ, hãy tạo một tệp .dockerignore chứa:
Dockerfile
.dockerignore
node_modules
npm-debug.log
Bật Cloud Build
Trong lớp học lập trình này, bạn sẽ tạo và triển khai tiện ích bổ sung nhiều lần khi thêm chức năng mới. Thay vì chạy các lệnh riêng biệt để tạo vùng chứa, hãy đẩy vùng chứa đó vào sổ đăng ký vùng chứa và triển khai vùng chứa đó vào Cloud Build, hãy sử dụng Cloud Build để điều phối quy trình. Tạo một tệp cloudbuild.yaml có hướng dẫn về cách tạo và triển khai ứng dụng:
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
Chạy các lệnh sau để cấp cho Cloud Build quyền triển khai ứng dụng:
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
Tạo và triển khai phần phụ trợ của tiện ích bổ sung
Để bắt đầu quá trình tạo, trong Cloud Shell, hãy chạy lệnh:
gcloud builds submit
Quá trình tạo và triển khai đầy đủ có thể mất vài phút để hoàn tất, đặc biệt là lần đầu tiên.
Sau khi quá trình tạo bản dựng hoàn tất, hãy xác minh rằng dịch vụ đã được triển khai và tìm URL. Chạy lệnh:
gcloud run services list --platform managed
Sao chép URL này vì bạn sẽ cần đến nó trong bước tiếp theo – cho Google Workspace biết cách gọi tiện ích bổ sung.
Đăng ký tiện ích bổ sung
Bây giờ, khi máy chủ đã hoạt động, hãy mô tả tiện ích bổ sung để Google Workspace biết cách hiển thị và gọi tiện ích đó.
Tạo một bộ mô tả triển khai
Tạo tệp deployment.json có nội dung sau. Hãy nhớ sử dụng URL của ứng dụng đã triển khai thay cho trình giữ chỗ URL.
{
"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": {}
}
}
Tải bộ mô tả triển khai lên bằng cách chạy lệnh:
gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json
Cho phép truy cập vào phần phụ trợ của tiện ích bổ sung
Khung tiện ích bổ sung cũng cần có quyền gọi dịch vụ. Chạy các lệnh sau để cập nhật chính sách IAM cho Cloud Run nhằm cho phép Google Workspace gọi tiện ích bổ sung:
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"
Cài đặt tiện ích bổ sung để kiểm thử
Để cài đặt tiện ích bổ sung ở chế độ phát triển cho tài khoản của bạn, trong Cloud Shell, hãy chạy:
gcloud workspace-add-ons deployments install todo-add-on
Mở (Gmail)[https://mail.google.com/] trong một thẻ hoặc cửa sổ mới. Ở bên phải, hãy tìm tiện ích bổ sung có biểu tượng dấu kiểm.

Để mở tiện ích bổ sung, hãy nhấp vào biểu tượng dấu kiểm. Lời nhắc uỷ quyền cho tiện ích bổ sung sẽ xuất hiện.

Nhấp vào Uỷ quyền truy cập rồi làm theo hướng dẫn về quy trình uỷ quyền trong cửa sổ bật lên. Sau khi hoàn tất, tiện ích bổ sung sẽ tự động tải lại và hiển thị thông báo "Xin chào thế giới!".
Xin chúc mừng! Giờ đây, bạn đã triển khai và cài đặt một tiện ích bổ sung đơn giản. Đã đến lúc biến nó thành một ứng dụng danh sách việc cần làm!
5. Truy cập vào danh tính người dùng
Nhiều người dùng thường sử dụng tiện ích bổ sung để xử lý thông tin riêng tư của họ hoặc của tổ chức. Trong lớp học lập trình này, tiện ích bổ sung chỉ được hiển thị các việc cần làm của người dùng hiện tại. Danh tính người dùng được gửi đến tiện ích bổ sung thông qua mã thông báo danh tính cần được giải mã.
Thêm phạm vi vào bộ mô tả triển khai
Theo mặc định, danh tính người dùng không được gửi. Đó là dữ liệu người dùng và tiện ích bổ sung cần có quyền truy cập vào dữ liệu đó. Để có được quyền đó, hãy cập nhật deployment.json và thêm các phạm vi OAuth openid và email vào danh sách các phạm vi mà tiện ích bổ sung yêu cầu. Sau khi thêm các phạm vi OAuth, tiện ích bổ sung sẽ nhắc người dùng cấp quyền truy cập vào lần tiếp theo họ sử dụng tiện ích bổ sung.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"openid",
"email"
],
Sau đó, trong Cloud Shell, hãy chạy lệnh này để cập nhật bộ mô tả triển khai:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Cập nhật máy chủ tiện ích bổ sung
Mặc dù tiện ích bổ sung được định cấu hình để yêu cầu danh tính người dùng, nhưng bạn vẫn cần cập nhật quá trình triển khai.
Phân tích cú pháp mã thông báo nhận dạng
Bắt đầu bằng cách thêm thư viện xác thực của Google vào dự án:
npm install --save google-auth-library
Sau đó, hãy chỉnh sửa index.js để yêu cầu OAuth2Client:
const { OAuth2Client } = require('google-auth-library');
Sau đó, hãy thêm một phương thức trợ giúp để phân tích cú pháp mã thông báo nhận dạng:
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
Hiển thị danh tính người dùng
Đây là thời điểm thích hợp để kiểm tra trước khi thêm tất cả chức năng của danh sách việc cần làm. Cập nhật tuyến đường của ứng dụng để in địa chỉ email và mã nhận dạng duy nhất của người dùng thay vì "Xin chào thế giới".
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);
}));
Sau những thay đổi này, tệp index.js kết quả sẽ có dạng như sau:
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}`)
});
Triển khai lại và kiểm thử
Tạo lại và triển khai lại tiện ích bổ sung. Trong Cloud Shell, hãy chạy:
gcloud builds submit
Sau khi triển khai lại máy chủ, hãy mở hoặc tải lại Gmail rồi mở lại tiện ích bổ sung. Vì các phạm vi đã thay đổi, nên tiện ích bổ sung sẽ yêu cầu bạn uỷ quyền lại. Uỷ quyền lại cho tiện ích bổ sung. Sau khi hoàn tất, tiện ích bổ sung sẽ hiển thị địa chỉ email và mã nhận dạng người dùng của bạn.
Giờ đây, khi tiện ích bổ sung đã biết danh tính của người dùng, bạn có thể bắt đầu thêm chức năng danh sách việc cần làm.
6. Triển khai danh sách việc cần làm
Mô hình dữ liệu ban đầu cho lớp học lập trình này khá đơn giản: một danh sách các thực thể Task, mỗi thực thể có các thuộc tính cho văn bản mô tả công việc và dấu thời gian.
Tạo chỉ mục kho dữ liệu
Datastore đã được bật cho dự án trước đó trong lớp học lập trình. Phương thức này không yêu cầu giản đồ, mặc dù yêu cầu bạn phải tạo chỉ mục một cách rõ ràng cho các truy vấn kết hợp. Quá trình tạo chỉ mục có thể mất vài phút, vì vậy bạn sẽ thực hiện việc này trước.
Tạo một tệp có tên là index.yaml với nội dung sau:
indexes:
- kind: Task
ancestor: yes
properties:
- name: created
Sau đó, hãy cập nhật chỉ mục Datastore:
gcloud datastore indexes create index.yaml
Khi được nhắc tiếp tục, hãy nhấn phím ENTER trên bàn phím. Quá trình tạo chỉ mục diễn ra ở chế độ nền. Trong thời gian đó, hãy bắt đầu cập nhật mã tiện ích bổ sung để triển khai "todos".
Cập nhật phần phụ trợ của tiện ích bổ sung
Cài đặt thư viện Datastore vào dự án:
npm install --save @google-cloud/datastore
Đọc và ghi vào kho dữ liệu
Cập nhật index.js để triển khai "todos" bắt đầu bằng việc nhập thư viện kho dữ liệu và tạo ứng dụng:
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
Thêm các phương thức để đọc và ghi các việc cần làm từ kho dữ liệu:
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);
}
Triển khai quá trình hiển thị giao diện người dùng
Hầu hết các thay đổi đều là đối với giao diện người dùng của tiện ích bổ sung. Trước đây, tất cả thẻ do giao diện người dùng trả về đều là thẻ tĩnh – chúng không thay đổi tuỳ thuộc vào dữ liệu có sẵn. Ở đây, thẻ cần được tạo một cách linh động dựa trên danh sách việc cần làm hiện tại của người dùng.
Giao diện người dùng của lớp học lập trình này bao gồm một phần nhập văn bản cùng với danh sách các việc cần làm có hộp đánh dấu để đánh dấu là đã hoàn thành. Mỗi đối tượng này cũng có một thuộc tính onChangeAction dẫn đến một lệnh gọi lại vào máy chủ tiện ích bổ sung khi người dùng thêm hoặc xoá một việc cần làm. Trong mỗi trường hợp này, giao diện người dùng cần được kết xuất lại bằng danh sách việc cần làm mới. Để xử lý việc này, hãy giới thiệu một phương thức mới để tạo giao diện người dùng thẻ.
Tiếp tục chỉnh sửa index.js và thêm phương thức sau:
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;
}
Cập nhật tuyến đường
Giờ đây, khi đã có các phương thức trợ giúp để đọc và ghi vào kho dữ liệu cũng như tạo giao diện người dùng, hãy kết nối các phương thức này với nhau trong các tuyến của ứng dụng. Thay thế tuyến đường hiện có và thêm hai tuyến đường khác: một tuyến đường để thêm nhiệm vụ và một tuyến đường để xoá nhiệm vụ.
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);
}));
Sau đây là tệp index.js cuối cùng, hoạt động đầy đủ:
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}`)
});
Triển khai lại và kiểm thử
Để tạo lại và triển khai lại tiện ích bổ sung, hãy bắt đầu một bản dựng. Trong Cloud Shell, hãy chạy:
gcloud builds submit
Trong Gmail, hãy tải lại tiện ích bổ sung và giao diện người dùng mới sẽ xuất hiện. Hãy dành một phút để khám phá tiện ích bổ sung này. Thêm một vài việc cần làm bằng cách nhập nội dung vào ô nhập liệu rồi nhấn ENTER trên bàn phím, sau đó nhấp vào hộp đánh dấu để xoá các việc đó.

Nếu muốn, bạn có thể chuyển đến bước cuối cùng trong lớp học lập trình này và dọn dẹp dự án của mình. Hoặc nếu muốn tiếp tục tìm hiểu thêm về tiện ích bổ sung, bạn có thể hoàn tất thêm một bước nữa.
7. (Không bắt buộc) Thêm bối cảnh
Một trong những tính năng mạnh mẽ nhất của tiện ích bổ sung là khả năng nhận biết bối cảnh. Với sự cho phép của người dùng, các tiện ích bổ sung có thể truy cập vào các bối cảnh của Google Workspace, chẳng hạn như email mà người dùng đang xem, sự kiện trên lịch và tài liệu. Tiện ích bổ sung cũng có thể thực hiện các thao tác như chèn nội dung. Trong lớp học lập trình này, bạn sẽ thêm tính năng hỗ trợ bối cảnh cho các trình chỉnh sửa Workspace (Tài liệu, Trang tính và Trang trình bày) để đính kèm tài liệu hiện tại vào mọi việc cần làm được tạo trong trình chỉnh sửa. Khi tác vụ xuất hiện, người dùng có thể nhấp vào tác vụ đó để mở tài liệu trong một thẻ mới và quay lại tài liệu để hoàn tất tác vụ.
Cập nhật phần phụ trợ của tiện ích bổ sung
Cập nhật tuyến đường newTask
Trước tiên, hãy cập nhật tuyến đường /newTask để thêm mã nhận dạng tài liệu vào một việc cần làm (nếu có):
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);
}));
Các nhiệm vụ mới tạo hiện bao gồm mã nhận dạng tài liệu hiện tại. Tuy nhiên, theo mặc định, ngữ cảnh trong các trình chỉnh sửa sẽ không được chia sẻ. Giống như dữ liệu người dùng khác, người dùng phải cấp quyền cho tiện ích bổ sung để truy cập vào dữ liệu. Để ngăn chặn việc chia sẻ quá nhiều thông tin, phương pháp ưu tiên là yêu cầu và cấp quyền theo từng tệp.
Cập nhật giao diện người dùng
Trong index.js, hãy cập nhật buildCard để thực hiện 2 thay đổi. Thứ nhất là cập nhật cách hiển thị các việc cần làm để thêm đường liên kết đến tài liệu (nếu có). Thứ hai là hiển thị lời nhắc uỷ quyền không bắt buộc nếu tiện ích bổ sung được kết xuất trong một trình chỉnh sửa và quyền truy cập vào tệp chưa được cấp.
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;
}
Triển khai tuyến uỷ quyền tệp
Nút uỷ quyền sẽ thêm một tuyến đường mới vào ứng dụng, vì vậy, hãy triển khai nút này. Lộ trình này giới thiệu một khái niệm mới: hành động của ứng dụng lưu trữ. Đây là hướng dẫn đặc biệt để tương tác với ứng dụng lưu trữ của tiện ích bổ sung. Trong trường hợp này, hãy yêu cầu quyền truy cập vào tệp trình chỉnh sửa hiện tại.
Trong index.js, hãy thêm tuyến đường /authorizeFile:
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
Sau đây là tệp index.js cuối cùng, hoạt động đầy đủ:
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}`)
});
Thêm phạm vi vào bộ mô tả triển khai
Trước khi tạo lại máy chủ, hãy cập nhật bộ mô tả triển khai tiện ích bổ sung để thêm phạm vi OAuth https://www.googleapis.com/auth/drive.file. Cập nhật deployment.json để thêm https://www.googleapis.com/auth/drive.file vào danh sách các phạm vi 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"
]
Tải phiên bản mới lên bằng cách chạy lệnh Cloud Shell sau:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Triển khai lại và kiểm thử
Cuối cùng, hãy tạo lại máy chủ. Trong Cloud Shell, hãy chạy:
gcloud builds submit
Sau khi hoàn tất, thay vì mở Gmail, hãy mở một tài liệu hiện có trên Google hoặc tạo một tài liệu mới bằng cách mở doc.new. Nếu tạo tài liệu mới, hãy nhớ nhập một số văn bản hoặc đặt tên cho tệp.
Mở tiện ích bổ sung. Tiện ích bổ sung này sẽ hiển thị nút Uỷ quyền truy cập vào tệp ở cuối tiện ích bổ sung. Nhấp vào nút này, sau đó cho phép truy cập vào tệp.
Sau khi được uỷ quyền, hãy thêm một việc cần làm trong khi ở trình chỉnh sửa. Việc cần làm có một nhãn cho biết tài liệu đã được đính kèm. Khi bạn nhấp vào đường liên kết này, tài liệu sẽ mở ra trong một thẻ mới. Tất nhiên, việc mở tài liệu mà bạn đã mở là điều không cần thiết. Nếu bạn muốn tối ưu hoá giao diện người dùng để lọc các đường liên kết cho tài liệu hiện tại, hãy cân nhắc việc đó để nâng cao thành tích!
8. Xin chúc mừng
Xin chúc mừng! Bạn đã tạo và triển khai thành công một Tiện ích bổ sung của Google Workspace bằng Cloud Run. Mặc dù lớp học lập trình này đã đề cập đến nhiều khái niệm cốt lõi để xây dựng một tiện ích bổ sung, nhưng vẫn còn nhiều điều khác để khám phá. Hãy xem các tài nguyên bên dưới và nhớ dọn dẹp dự án của bạn để tránh bị tính thêm phí.
Dọn dẹp
Để gỡ cài đặt tiện ích bổ sung khỏi tài khoản của bạn, hãy chạy lệnh sau trong Cloud Shell:
gcloud workspace-add-ons deployments uninstall todo-add-on
Để tránh bị tính phí cho tài khoản Google Cloud Platform đối với các tài nguyên được dùng trong hướng dẫn này, hãy làm như sau:
- Trong Cloud Console, hãy chuyển đến trang Quản lý tài nguyên. Nhấp vào biểu tượng Ở góc trên cùng bên trái, hãy nhấp vào Trình đơn
> IAM và Quản trị > Quản lý tài nguyên.
- Trong danh sách dự án, hãy chọn dự án của bạn rồi nhấp vào Xoá.
- Trong hộp thoại, hãy nhập mã dự án rồi nhấp vào Tắt để xoá dự án.
Tìm hiểu thêm
- Tổng quan về tiện ích bổ sung cho Google Workspace
- Tìm các ứng dụng và tiện ích bổ sung hiện có trong marketplace