1. Wprowadzenie
Dodatki do Google Workspace to dostosowane aplikacje, które integrują się z aplikacjami Google Workspace, takimi jak Gmail, Dokumenty, Arkusze i Prezentacje. Umożliwiają one programistom tworzenie dostosowanych interfejsów użytkownika, które są bezpośrednio zintegrowane z Google Workspace. Dodatki pomagają użytkownikom pracować wydajniej i rzadziej zmieniać kontekst.
Z tego laboratorium dowiesz się, jak utworzyć i wdrożyć prosty dodatek do listy zadań za pomocą Node.js, Cloud Run i Datastore.
Czego się nauczysz
- Korzystanie z Cloud Shell
- Wdrożenie w Cloud Run
- Tworzenie i wdrażanie deskryptora wdrożenia dodatku
- Tworzenie interfejsów dodatków za pomocą platformy kart
- Reagowanie na interakcje użytkowników
- Wykorzystywanie kontekstu użytkownika w dodatku
2. Konfiguracja i wymagania
Postępuj zgodnie z instrukcjami konfiguracji, aby utworzyć projekt w chmurze Google Cloud i włączyć interfejsy API oraz usługi, z których będzie korzystać dodatek.
Samodzielne konfigurowanie środowiska
- Otwórz Cloud Console i utwórz nowy projekt. (Jeśli nie masz jeszcze konta Gmail lub Google Workspace, utwórz je).
Zapamiętaj identyfikator projektu, czyli unikalną nazwę we wszystkich projektach Google Cloud (podana powyżej nazwa jest już zajęta i nie będzie działać w Twoim przypadku). W dalszej części tego laboratorium będzie on nazywany PROJECT_ID.
- Następnie, aby korzystać z zasobów Google Cloud, włącz płatności w konsoli Cloud.
Ukończenie tego laboratorium nie powinno wiązać się z dużymi kosztami, a nawet z żadnymi. Wykonaj instrukcje z sekcji „Czyszczenie” na końcu ćwiczenia, w której znajdziesz informacje o tym, jak wyłączyć zasoby, aby uniknąć naliczenia opłat po zakończeniu tego samouczka. Nowi użytkownicy Google Cloud mogą skorzystać z programu bezpłatnego okresu próbnego, w którym mają do dyspozycji środki w wysokości 300 USD.
Google Cloud Shell
Z Google Cloud można korzystać zdalnie na laptopie, ale w tym module użyjemy Google Cloud Shell, czyli środowiska wiersza poleceń działającego w chmurze.
Aktywowanie Cloud Shell
- W konsoli Cloud kliknij Aktywuj Cloud Shell
.
Gdy po raz pierwszy otworzysz Cloud Shell, zobaczysz opisową wiadomość powitalną. Jeśli zobaczysz wiadomość powitalną, kliknij Dalej. Wiadomość powitalna nie pojawi się ponownie. Oto wiadomość powitalna:
Uzyskanie dostępu do środowiska Cloud Shell i połączenie się z nim powinno zająć tylko kilka chwil. Po połączeniu zobaczysz terminal Cloud Shell:
Ta maszyna wirtualna zawiera wszystkie potrzebne narzędzia dla programistów. Zawiera również stały katalog domowy o pojemności 5 GB i działa w Google Cloud, co znacznie zwiększa wydajność sieci i usprawnia proces uwierzytelniania. Wszystkie zadania w tym laboratorium możesz wykonać w przeglądarce lub na Chromebooku.
Po połączeniu z Cloud Shell zobaczysz, że uwierzytelnianie zostało już przeprowadzone, a projekt jest już ustawiony na Twój identyfikator projektu.
- Aby potwierdzić, że uwierzytelnianie zostało przeprowadzone, uruchom w Cloud Shell to polecenie:
gcloud auth list
Jeśli pojawi się pytanie o autoryzację Cloud Shell do wykonywania wywołań interfejsu API GCP, kliknij Autoryzuj.
Wynik polecenia
Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
Aby ustawić aktywne konto, uruchom to polecenie:
gcloud config set account <ACCOUNT>
Aby potwierdzić, że wybrano właściwy projekt, uruchom w Cloud Shell to polecenie:
gcloud config list project
Wynik polecenia
[core] project = <PROJECT_ID>
Jeśli nie zostanie zwrócony właściwy projekt, możesz go ustawić za pomocą tego polecenia:
gcloud config set project <PROJECT_ID>
Wynik polecenia
Updated property [core/project].
W tym samouczku używamy zarówno operacji w wierszu poleceń, jak i edytowania plików. Do edytowania plików możesz użyć wbudowanego edytora kodu w Cloud Shell. Aby to zrobić, kliknij przycisk Otwórz edytor po prawej stronie paska narzędzi Cloud Shell. W Cloud Shell znajdziesz też popularne edytory, takie jak vim i emacs.
3. Włączanie interfejsów Cloud Run, Datastore i dodatków API
Włączanie interfejsów Cloud API
W Cloud Shell włącz interfejsy Cloud API dla komponentów, które będą używane:
gcloud services enable \ run.googleapis.com \ cloudbuild.googleapis.com \ cloudresourcemanager.googleapis.com \ datastore.googleapis.com \ gsuiteaddons.googleapis.com
Wykonanie tej operacji może potrwać kilka chwil.
Po zakończeniu pojawi się komunikat o powodzeniu podobny do tego:
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
Tworzenie instancji magazynu danych
Następnie włącz App Engine i utwórz bazę danych Datastore. Włączenie App Engine jest wymagane do korzystania z Datastore, ale nie będziemy używać App Engine do niczego innego.
gcloud app create --region=us-central gcloud firestore databases create --type=datastore-mode --region=us-central
Tworzenie ekranu zgody OAuth
Dodatek wymaga zgody użytkownika na uruchomienie i wykonywanie działań na jego danych. Aby to zrobić, skonfiguruj ekran zgody w projekcie. Na potrzeby tego laboratorium skonfigurujesz ekran zgody jako aplikację wewnętrzną, co oznacza, że nie będzie ona przeznaczona do publicznego rozpowszechniania.
- Otwórz konsolę Google Cloud w nowej karcie lub nowym oknie.
- Obok „Konsola Google Cloud” kliknij strzałkę w dół
i wybierz swój projekt. - W lewym górnym rogu kliknij Menu
. - Kliknij Interfejsy API i usługi > Dane logowania. Pojawi się strona danych logowania projektu.
- Kliknij Ekran zgody OAuth. Wyświetli się ekran „Ekran zgody OAuth”.
- W sekcji „Typ użytkownika” wybierz Wewnętrzny. Jeśli używasz konta @gmail.com, wybierz Zewnętrzne.
- Kliknij Utwórz. Pojawi się strona „Edytowanie rejestracji aplikacji”.
- Wypełnij formularz:
- W polu Nazwa aplikacji wpisz „Dodatek do listy zadań”.
- W polu Adres e-mail dla użytkowników potrzebujących pomocy wpisz swój osobisty adres e-mail.
- W sekcji Dane kontaktowe dewelopera wpisz swój osobisty adres e-mail.
- Kliknij Zapisz i kontynuuj. Pojawi się formularz Zakresy.
- W formularzu Zakresy kliknij Zapisz i kontynuuj. Pojawi się podsumowanie.
- Kliknij Powrót do panelu.
4. Tworzenie początkowego dodatku
Inicjowanie projektu
Na początek utworzysz prosty dodatek „Hello world” i go wdrożysz. Dodatki to usługi internetowe, które odpowiadają na żądania HTTPS i zwracają ładunek JSON opisujący interfejs i działania do wykonania. W tym dodatku użyjesz Node.js i platformy Express.
Aby utworzyć ten projekt szablonu, użyj Cloud Shell do utworzenia nowego katalogu o nazwie todo-add-on i przejdź do niego:
mkdir ~/todo-add-on cd ~/todo-add-on
Wszystkie zadania w ramach tego ćwiczenia wykonasz w tym katalogu.
Zainicjuj projekt Node.js:
npm init
NPM zadaje kilka pytań dotyczących konfiguracji projektu, np. nazwy i wersji. W przypadku każdego pytania naciśnij ENTER, aby zaakceptować wartości domyślne. Domyślnym punktem wejścia jest plik o nazwie index.js, który utworzymy w następnym kroku.
Następnie zainstaluj platformę internetową Express:
npm install --save express express-async-handler
Tworzenie backendu dodatku
Czas zacząć tworzyć aplikację.
Utwórz plik o nazwie index.js. Aby utworzyć pliki, możesz użyć edytora Cloud Shell. W tym celu kliknij przycisk Otwórz edytor na pasku narzędzi w oknie Cloud Shell. Możesz też edytować pliki w Cloud Shell i nimi zarządzać za pomocą edytorów vim lub emacs.
Po utworzeniu pliku index.js dodaj do niego tę treść:
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}`)
});
Serwer nie robi niczego poza wyświetlaniem komunikatu „Hello world”, co jest w porządku. Więcej funkcji dodasz później.
Wdrożenie w Cloud Run
Aby wdrożyć aplikację w Cloud Run, musi ona być skonteneryzowana.
Tworzenie kontenera
Utwórz plik Dockerfile o nazwie Dockerfile zawierający:
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" ]
Zapobieganie umieszczaniu niechcianych plików w kontenerze
Aby kontener był lekki, utwórz plik .dockerignore zawierający:
Dockerfile
.dockerignore
node_modules
npm-debug.log
Włącz Cloud Build
W tym ćwiczeniu z programowania kilka razy utworzysz i wdrożysz dodatek, gdy dodamy do niego nowe funkcje. Zamiast uruchamiać osobne polecenia, aby utworzyć kontener, przenieść go do rejestru kontenerów i wdrożyć w Cloud Build, użyj Cloud Build do koordynowania tej procedury. Utwórz plik cloudbuild.yaml z instrukcjami tworzenia i wdrażania aplikacji:
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
Uruchom te polecenia, aby przyznać Cloud Build uprawnienia do wdrożenia aplikacji:
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
Tworzenie i wdrażanie backendu dodatku
Aby rozpocząć kompilację, uruchom w Cloud Shell to polecenie:
gcloud builds submit
Pełne kompilowanie i wdrażanie może potrwać kilka minut, zwłaszcza za pierwszym razem.
Gdy kompilacja się zakończy, sprawdź, czy usługa została wdrożona, i znajdź adres URL. Uruchom polecenie:
gcloud run services list --platform managed
Skopiuj ten adres URL, ponieważ będzie Ci potrzebny w następnym kroku – informowaniu Google Workspace o tym, jak wywołać dodatek.
Rejestrowanie dodatku
Teraz, gdy serwer działa, opisz dodatek, aby Google Workspace wiedział, jak go wyświetlać i wywoływać.
Tworzenie deskryptora wdrożenia
Utwórz plik deployment.json o tej treści. Pamiętaj, aby zamiast symbolu zastępczego URL użyć adresu URL wdrożonej aplikacji.
{
"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": {}
}
}
Prześlij deskryptor wdrożenia, uruchamiając to polecenie:
gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json
Udzielanie dostępu do backendu dodatku
Platforma dodatków również potrzebuje uprawnień do wywoływania usługi. Uruchom te polecenia, aby zaktualizować zasady IAM dla Cloud Run i zezwolić Google Workspace na wywoływanie dodatku:
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"
Instalowanie dodatku do testowania
Aby zainstalować dodatek w trybie programowania na swoim koncie, w Cloud Shell uruchom to polecenie:
gcloud workspace-add-ons deployments install todo-add-on
Otwórz (Gmail)[https://mail.google.com/] w nowej karcie lub nowym oknie. Po prawej stronie znajdź dodatek z ikoną znacznika wyboru.

Aby otworzyć dodatek, kliknij ikonę znacznika wyboru. Pojawi się prośba o autoryzację dodatku.

Kliknij Authorize Access (Autoryzuj dostęp) i postępuj zgodnie z instrukcjami w wyskakującym okienku. Po zakończeniu tego procesu dodatek automatycznie się przeładuje i wyświetli komunikat „Hello world!”.
Gratulacje! Masz teraz wdrożony i zainstalowany prosty dodatek. Czas przekształcić go w aplikację do tworzenia list zadań.
5. Uzyskiwanie dostępu do tożsamości użytkownika
Dodatki są zwykle używane przez wielu użytkowników do pracy z informacjami, które są prywatne dla nich lub ich organizacji. W tym laboratorium kodowania dodatek powinien wyświetlać tylko zadania bieżącego użytkownika. Tożsamość użytkownika jest wysyłana do dodatku za pomocą tokena tożsamości, który należy zdekodować.
Dodawanie zakresów do deskryptora wdrożenia
Tożsamość użytkownika nie jest wysyłana domyślnie. Są to dane użytkownika, a dodatek potrzebuje uprawnień, aby uzyskać do nich dostęp. Aby uzyskać to uprawnienie, zaktualizuj deployment.json i dodaj zakresy OAuth openid i email do listy zakresów wymaganych przez dodatek. Po dodaniu zakresów OAuth dodatek poprosi użytkowników o przyznanie dostępu przy następnym użyciu dodatku.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"openid",
"email"
],
Następnie w Cloud Shell uruchom to polecenie, aby zaktualizować deskryptor wdrożenia:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Aktualizowanie serwera dodatku
Dodatek jest skonfigurowany tak, aby żądać tożsamości użytkownika, ale implementacja wymaga jeszcze aktualizacji.
Analizowanie tokena tożsamości
Zacznij od dodania do projektu biblioteki uwierzytelniania Google:
npm install --save google-auth-library
Następnie zmień index.js, aby wymagać OAuth2Client:
const { OAuth2Client } = require('google-auth-library');
Następnie dodaj metodę pomocniczą do analizowania tokena identyfikatora:
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
Wyświetlanie tożsamości użytkownika
To dobry moment na sprawdzenie, zanim dodasz wszystkie funkcje listy zadań. Zaktualizuj ścieżkę aplikacji, aby zamiast „Hello world” wyświetlać adres e-mail i unikalny identyfikator użytkownika.
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);
}));
Po wprowadzeniu tych zmian wynikowy plik index.js powinien wyglądać tak:
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}`)
});
Ponowne wdrażanie i testowanie
Przebuduj i wdróż ponownie dodatek. W Cloud Shell uruchom:
gcloud builds submit
Po ponownym wdrożeniu serwera otwórz lub ponownie załaduj Gmaila i ponownie otwórz dodatek. Ponieważ zakresy uległy zmianie, dodatek poprosi o ponowne autoryzowanie. Ponownie autoryzuj dodatek. Po zakończeniu tego procesu dodatek wyświetli Twój adres e-mail i identyfikator użytkownika.
Teraz, gdy dodatek wie, kim jest użytkownik, możesz zacząć dodawać funkcję listy zadań.
6. Wdrażanie listy zadań
Początkowy model danych w tym laboratorium jest prosty: to lista Task, z których każda ma właściwości opisujące tekst zadania i sygnaturę czasową.
Tworzenie indeksu magazynu danych
Usługa Datastore została już wcześniej włączona w projekcie w ramach tego samouczka. Nie wymaga schematu, ale w przypadku zapytań złożonych trzeba jawnie utworzyć indeksy. Utworzenie indeksu może potrwać kilka minut, więc najpierw to zrobisz.
Utwórz plik o nazwie index.yaml z następującą treścią:
indexes:
- kind: Task
ancestor: yes
properties:
- name: created
Następnie zaktualizuj indeksy Datastore:
gcloud datastore indexes create index.yaml
Gdy pojawi się prośba o dalsze działanie, naciśnij ENTER na klawiaturze. Indeks jest tworzony w tle. W tym czasie zacznij aktualizować kod dodatku, aby zaimplementować „listę zadań”.
Aktualizowanie backendu dodatku
Zainstaluj w projekcie bibliotekę Datastore:
npm install --save @google-cloud/datastore
Odczytywanie i zapisywanie w Datastore
Zaktualizuj plik index.js, aby zaimplementować „todos”, zaczynając od zaimportowania biblioteki Datastore i utworzenia klienta:
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
Dodaj metody odczytywania i zapisywania zadań w 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);
}
Wdrażanie renderowania interfejsu
Większość zmian dotyczy interfejsu dodatku. Wcześniej wszystkie karty zwracane przez interfejs były statyczne – nie zmieniały się w zależności od dostępnych danych. W tym przypadku karta musi być tworzona dynamicznie na podstawie bieżącej listy zadań użytkownika.
Interfejs codelabu składa się z pola do wprowadzania tekstu oraz listy zadań z polami wyboru, które umożliwiają oznaczanie zadań jako ukończonych. Każdy z nich ma też właściwość onChangeAction, która powoduje wywołanie zwrotne na serwerze dodatku, gdy użytkownik doda lub usunie zadanie. W każdym z tych przypadków interfejs musi zostać ponownie wyrenderowany ze zaktualizowaną listą zadań. Aby to zrobić, wprowadźmy nową metodę tworzenia interfejsu karty.
Kontynuuj edycję index.js i dodaj tę metodę:
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;
}
Aktualizowanie tras
Teraz, gdy mamy już metody pomocnicze do odczytywania i zapisywania danych w Datastore oraz do tworzenia interfejsu, połączmy je w ścieżkach aplikacji. Zastąp istniejącą ścieżkę i dodaj 2 kolejne: jedną do dodawania zadań i jedną do ich usuwania.
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);
}));
Oto ostateczna, w pełni funkcjonalna wersja pliku 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}`)
});
Ponowne wdrażanie i testowanie
Aby przebudować i ponownie wdrożyć dodatek, rozpocznij kompilację. W Cloud Shell uruchom:
gcloud builds submit
W Gmailu ponownie załaduj dodatek. Pojawi się nowy interfejs. Poświęć chwilę na zapoznanie się z dodatkiem. Dodaj kilka zadań, wpisując tekst w polu i naciskając ENTER na klawiaturze, a następnie kliknij pole wyboru, aby je usunąć.

Jeśli chcesz, możesz przejść do ostatniego kroku w tym laboratorium i wyczyścić projekt. Jeśli chcesz dowiedzieć się więcej o dodatkach, możesz wykonać jeszcze jeden krok.
7. (Opcjonalnie) Dodawanie kontekstu
Jedną z najważniejszych funkcji dodatków jest świadomość kontekstu. Dodatki mogą, za zgodą użytkownika, uzyskiwać dostęp do kontekstów Google Workspace, takich jak e-mail, który użytkownik przegląda, wydarzenie w kalendarzu czy dokument. Dodatki mogą też wykonywać działania, takie jak wstawianie treści. W tym laboratorium kodu dodasz obsługę kontekstu w edytorach Workspace (Dokumentach, Arkuszach i Prezentacjach), aby dołączać bieżący dokument do wszystkich zadań utworzonych w tych edytorach. Gdy zadanie się wyświetli, kliknięcie go spowoduje otwarcie dokumentu w nowej karcie, aby użytkownik mógł wrócić do dokumentu i dokończyć zadanie.
Aktualizowanie backendu dodatku
Zaktualizuj trasę newTask
Najpierw zaktualizuj /newTask, aby uwzględnić identyfikator dokumentu w zadaniu, jeśli jest dostępny:
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);
}));
Nowo utworzone zadania zawierają teraz identyfikator bieżącego dokumentu. Kontekst w edytorach nie jest jednak domyślnie udostępniany. Podobnie jak w przypadku innych danych użytkownika, musi on przyznać dodatek uprawnienia do dostępu do danych. Aby zapobiec nadmiernemu udostępnianiu informacji, zalecamy proszenie o uprawnienia i ich przyznawanie w przypadku każdego pliku z osobna.
Aktualizowanie interfejsu
W index.js zaktualizuj buildCard, aby wprowadzić 2 zmiany. Pierwsza zmiana polega na zaktualizowaniu renderowania zadań, aby w razie potrzeby zawierały link do dokumentu. Drugi sposób to wyświetlanie opcjonalnego monitu o autoryzację, jeśli dodatek jest renderowany w edytorze, a dostęp do pliku nie został jeszcze przyznany.
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;
}
Wdróż ścieżkę autoryzacji plików
Przycisk autoryzacji dodaje do aplikacji nową ścieżkę, więc zaimplementujmy go. W tym przypadku wprowadzamy nową koncepcję – działania aplikacji hosta. Są to specjalne instrukcje dotyczące interakcji z aplikacją hostującą dodatek. W tym przypadku poproś o dostęp do bieżącego pliku edytora.
W polu index.js dodaj trasę /authorizeFile:
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
Oto ostateczna, w pełni funkcjonalna wersja pliku 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}`)
});
Dodawanie zakresów do deskryptora wdrożenia
Zanim ponownie skompilujesz serwer, zaktualizuj deskryptor wdrożenia dodatku, aby uwzględnić zakres OAuth https://www.googleapis.com/auth/drive.file. Zaktualizuj deployment.json, aby dodać https://www.googleapis.com/auth/drive.file do listy zakresów 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"
]
Prześlij nową wersję, uruchamiając to polecenie Cloud Shell:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Ponowne wdrażanie i testowanie
Na koniec odbuduj serwer. W Cloud Shell uruchom:
gcloud builds submit
Po zakończeniu zamiast otwierać Gmaila otwórz istniejący dokument Google lub utwórz nowy, otwierając doc.new. Jeśli tworzysz nowy dokument, wpisz tekst lub nadaj plikowi nazwę.
Otwórz dodatek. U dołu dodatku pojawi się przycisk Autoryzuj dostęp do pliku. Kliknij przycisk, a potem autoryzuj dostęp do pliku.
Po autoryzacji dodaj zadanie w edytorze. Zadanie zawiera etykietę informującą, że dokument jest załączony. Kliknięcie linku spowoduje otwarcie dokumentu w nowej karcie. Otwieranie dokumentu, który jest już otwarty, jest oczywiście trochę bez sensu. Jeśli chcesz zoptymalizować interfejs, aby odfiltrować linki do bieżącego dokumentu, możesz to zrobić.
8. Gratulacje
Gratulacje! Udało Ci się utworzyć i wdrożyć dodatek do Google Workspace za pomocą Cloud Run. W tym samouczku omówiliśmy wiele podstawowych koncepcji tworzenia dodatku, ale jest jeszcze wiele do odkrycia. Zapoznaj się z materiałami poniżej i nie zapomnij zwolnić miejsca w projekcie, aby uniknąć dodatkowych opłat.
Czyszczenie danych
Aby odinstalować dodatek z konta, uruchom w Cloud Shell to polecenie:
gcloud workspace-add-ons deployments uninstall todo-add-on
Aby uniknąć obciążenia konta Google Cloud Platform opłatami za zasoby zużyte w tym samouczku:
- W Cloud Console otwórz stronę Zarządzanie zasobami. W lewym górnym rogu kliknij Menu
Uprawnienia > Zarządzaj zasobami.
- Na liście projektów wybierz projekt, a następnie kliknij Usuń.
- W oknie wpisz identyfikator projektu i kliknij Wyłącz, aby usunąć projekt.
Więcej informacji
- Omówienie dodatków do Google Workspace
- Znajdowanie istniejących aplikacji i dodatków w Marketplace