Google Workspace-Add-on mit Node.js und Cloud Run erstellen

1. Einführung

Google Workspace-Add-ons sind benutzerdefinierte Anwendungen, die in Google Workspace-Anwendungen wie Gmail, Docs, Sheets und Präsentationen eingebunden werden. Damit können Entwickler benutzerdefinierte Benutzeroberflächen erstellen, die direkt in Google Workspace integriert sind. Mit Add‑ons können Nutzer effizienter arbeiten und müssen nicht so oft zwischen Anwendungen wechseln.

In diesem Codelab erfahren Sie, wie Sie mit Node.js, Cloud Run und Datastore ein einfaches Aufgabenlisten-Add-on erstellen und bereitstellen.

Lerninhalte

  • Cloud Shell verwenden
  • In Cloud Run bereitstellen
  • Add-on-Bereitstellungsdeskriptor erstellen und bereitstellen
  • Add-on-Benutzeroberflächen mit dem Karten-Framework erstellen
  • Auf Nutzerinteraktionen reagieren
  • Nutzerkontext in einem Add-on nutzen

2. Einrichtung und Anforderungen

Folgen Sie der Einrichtungsanleitung, um ein Google Cloud-Projekt zu erstellen und die APIs und Dienste zu aktivieren, die vom Add-on verwendet werden.

Umgebung zum selbstbestimmten Lernen einrichten

  1. Öffnen Sie die Cloud Console und erstellen Sie ein neues Projekt. Wenn Sie noch kein Gmail- oder Google Workspace-Konto haben, erstellen Sie eines.

Menü „Projekt auswählen“

Die neue Schaltfläche „Projekt“

Die Projekt-ID

Notieren Sie sich die Projekt-ID, also den projektübergreifend nur einmal vorkommenden Namen eines Google Cloud-Projekts. Der oben angegebene Name ist bereits vergeben und kann leider nicht mehr verwendet werden. Sie wird später in diesem Codelab als PROJECT_ID bezeichnet.

  1. Als Nächstes müssen Sie in der Cloud Console die Abrechnung aktivieren, um Google Cloud-Ressourcen verwenden zu können.

Die Durchführung dieses Codelabs sollte keine oder nur geringe Kosten verursachen. Folgen Sie unbedingt der Anleitung im Abschnitt „Bereinigen“ am Ende des Codelabs, in der Sie erfahren, wie Sie Ressourcen herunterfahren können, damit nach Abschluss dieses Codelabs keine Gebühren anfallen. Neue Nutzer von Google Cloud kommen für das Programm für den kostenlosen Testzeitraum mit einem Guthaben von 300$ infrage.

Google Cloud Shell

Während Sie Google Cloud von Ihrem Laptop aus per Fernzugriff nutzen können, wird in diesem Codelab Google Cloud Shell verwendet, eine Befehlszeilenumgebung, die in der Cloud ausgeführt wird.

Cloud Shell aktivieren

  1. Klicken Sie in der Cloud Console auf Cloud Shell aktivieren Cloud Shell-Symbol.

Cloud Shell-Symbol in der Menüleiste

Wenn Sie Cloud Shell zum ersten Mal öffnen, wird eine informative Begrüßungsnachricht angezeigt. Wenn die Begrüßungsnachricht angezeigt wird, klicken Sie auf Weiter. Die Willkommensnachricht wird nicht noch einmal angezeigt. Hier ist die Willkommensnachricht:

Cloud Shell-Willkommensnachricht

Das Herstellen der Verbindung mit der Cloud Shell sollte nur wenige Augenblicke dauern. Nachdem Sie eine Verbindung hergestellt haben, wird das Cloud Shell-Terminal angezeigt:

Das Cloud Shell-Terminal

Auf dieser virtuellen Maschine sind alle Entwicklungstools installiert, die Sie benötigen. Sie bietet ein Basisverzeichnis mit 5 GB nichtflüchtigem Speicher und läuft in Google Cloud, was die Netzwerkleistung und Authentifizierung erheblich verbessert. Alle Aufgaben in diesem Codelab können mit einem Browser oder Ihrem Chromebook erledigt werden.

Sobald die Verbindung mit der Cloud Shell hergestellt ist, sehen Sie, dass Sie bereits authentifiziert sind und für das Projekt schon Ihre Projekt-ID eingestellt ist.

  1. Führen Sie in der Cloud Shell den folgenden Befehl aus, um zu prüfen, ob Sie authentifiziert sind:
gcloud auth list

Wenn Sie aufgefordert werden, Cloud Shell für einen GCP-API-Aufruf zu autorisieren, klicken Sie auf Autorisieren.

Befehlsausgabe

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

Mit diesem Befehl legen Sie das aktive Konto fest:

gcloud config set account <ACCOUNT>

Führen Sie in Cloud Shell den folgenden Befehl aus, um zu prüfen, ob Sie das richtige Projekt ausgewählt haben:

gcloud config list project

Befehlsausgabe

[core]
project = <PROJECT_ID>

Wenn das richtige Projekt nicht zurückgegeben wird, können Sie es mit diesem Befehl festlegen:

gcloud config set project <PROJECT_ID>

Befehlsausgabe

Updated property [core/project].

Im Codelab werden sowohl Befehlszeilenoperationen als auch Dateibearbeitung verwendet. Zum Bearbeiten von Dateien können Sie den integrierten Code-Editor in Cloud Shell verwenden. Klicken Sie dazu in der Cloud Shell-Symbolleiste auf die Schaltfläche Editor öffnen. Beliebte Editoren wie Vim und Emacs sind ebenfalls in Cloud Shell verfügbar.

3. Cloud Run-, Datastore- und Add-on-APIs aktivieren

Cloud APIs aktivieren

Aktivieren Sie in Cloud Shell die Cloud APIs für die Komponenten, die verwendet werden:

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

Dieser Vorgang kann einige Momente dauern.

Nach Abschluss wird eine Erfolgsmeldung wie die folgende angezeigt:

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

Datenspeicherinstanz erstellen

Aktivieren Sie als Nächstes App Engine und erstellen Sie eine Datastore-Datenbank. Die Aktivierung von App Engine ist eine Voraussetzung für die Verwendung von Datastore, aber wir verwenden App Engine für nichts anderes.

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

Das Add-on benötigt die Nutzerberechtigung, um ausgeführt zu werden und Aktionen für die Daten auszuführen. Konfigurieren Sie den Zustimmungsbildschirm des Projekts, um dies zu ermöglichen. Für das Codelab konfigurieren Sie den Einwilligungsbildschirm als interne Anwendung, die nicht für die öffentliche Verteilung vorgesehen ist.

  1. Öffnen Sie die Google Cloud Console in einem neuen Tab oder Fenster.
  2. Klicken Sie neben „Google Cloud Console“ auf den Abwärtspfeil Drop-down-Pfeil und wählen Sie Ihr Projekt aus.
  3. Klicken Sie links oben auf das Dreistrich-Menü Menüsymbol.
  4. Klicken Sie auf APIs & Dienste > Anmeldedaten. Die Seite mit den Anmeldedaten für Ihr Projekt wird angezeigt.
  5. Klicken Sie auf OAuth-Zustimmungsbildschirm. Der Bildschirm „OAuth-Zustimmungsbildschirm“ wird angezeigt.
  6. Wählen Sie unter „Nutzertyp“ die Option Intern aus. Wenn Sie ein @gmail.com-Konto verwenden, wählen Sie Extern aus.
  7. Klicken Sie auf Erstellen. Die Seite „App-Registrierung bearbeiten“ wird angezeigt.
  8. Füllen Sie das Formular aus:
    • Geben Sie unter App name (App-Name) „Todo Add-on“ ein.
    • Geben Sie unter E-Mail-Adresse des Nutzersupports Ihre private E‑Mail-Adresse ein.
    • Geben Sie unter Kontaktdaten des Entwicklers Ihre private E‑Mail-Adresse ein.
  9. Klicken Sie auf Speichern und fortfahren. Ein Formular für Bereiche wird angezeigt.
  10. Klicken Sie im Formular „Bereiche“ auf Speichern und fortfahren. Eine Zusammenfassung wird angezeigt.
  11. Klicken Sie auf Zurück zum Dashboard.

4. Erstes Add-on erstellen

Das Projekt initialisieren

Zuerst erstellen Sie ein einfaches „Hello World“-Add-on und stellen es bereit. Add-ons sind Webservices, die auf HTTPS-Anfragen mit einer JSON-Nutzlast antworten, die die Benutzeroberfläche und die auszuführenden Aktionen beschreibt. In diesem Add-on verwenden Sie Node.js und das Express-Framework.

Erstellen Sie mit Cloud Shell ein neues Verzeichnis namens todo-add-on und rufen Sie es auf, um dieses Vorlagenprojekt zu erstellen:

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

Sie führen alle Aufgaben für das Codelab in diesem Verzeichnis aus.

Initialisieren Sie das Node.js-Projekt:

npm init

NPM stellt mehrere Fragen zur Projektkonfiguration, z. B. zu Name und Version. Drücken Sie für jede Frage ENTER, um die Standardwerte zu übernehmen. Der Standard-Einstiegspunkt ist eine Datei mit dem Namen index.js, die wir als Nächstes erstellen.

Installieren Sie als Nächstes das Express-Web-Framework:

npm install --save express express-async-handler

Add‑on-Backend erstellen

Jetzt ist es an der Zeit, die App zu erstellen.

Erstellen Sie eine Datei mit dem Namen index.js. Zum Erstellen von Dateien können Sie den Cloud Shell-Editor verwenden. Klicken Sie dazu in der Symbolleiste des Cloud Shell-Fensters auf die Schaltfläche Editor öffnen. Alternativ können Sie Dateien in Cloud Shell mit vim oder emacs bearbeiten und verwalten.

Fügen Sie nach dem Erstellen der Datei index.js den folgenden Inhalt hinzu:

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}`)
});

Der Server macht nicht viel mehr, als die Nachricht „Hello World“ anzuzeigen, und das ist in Ordnung. Sie fügen später weitere Funktionen hinzu.

In Cloud Run bereitstellen

Für die Bereitstellung in Cloud Run muss die App containerisiert werden.

Container erstellen

Erstellen Sie ein Dockerfile mit dem Namen Dockerfile mit folgendem Inhalt:

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" ]

Unerwünschte Dateien aus dem Container fernhalten

Damit der Container schlank bleibt, erstellen Sie eine .dockerignore-Datei mit folgendem Inhalt:

Dockerfile
.dockerignore
node_modules
npm-debug.log

Cloud Build aktivieren

In diesem Codelab erstellen und stellen Sie das Add-on mehrmals bereit, wenn neue Funktionen hinzugefügt werden. Anstatt separate Befehle auszuführen, um den Container zu erstellen, per Push in die Container Registry zu übertragen und in Cloud Build bereitzustellen, können Sie Cloud Build verwenden, um den Vorgang zu orchestrieren. Erstellen Sie eine cloudbuild.yaml-Datei mit einer Anleitung zum Erstellen und Bereitstellen der Anwendung:

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

Führen Sie die folgenden Befehle aus, um Cloud Build die Berechtigung zum Bereitstellen der App zu erteilen:

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

Add‑on-Backend erstellen und bereitstellen

Führen Sie in Cloud Shell Folgendes aus, um den Build zu starten:

gcloud builds submit

Der vollständige Build- und Bereitstellungsvorgang kann einige Minuten dauern, insbesondere beim ersten Mal.

Prüfen Sie nach Abschluss des Builds, ob der Dienst bereitgestellt wurde, und suchen Sie nach der URL. Führen Sie diesen Befehl aus:

gcloud run services list --platform managed

Kopieren Sie diese URL. Sie benötigen sie im nächsten Schritt, um Google Workspace mitzuteilen, wie das Add‑on aufgerufen werden soll.

Add‑on registrieren

Nachdem der Server eingerichtet ist, müssen Sie das Add‑on beschreiben, damit Google Workspace weiß, wie es angezeigt und aufgerufen werden soll.

Deployment-Deskriptor erstellen

Erstellen Sie die Datei deployment.json mit folgendem Inhalt. Verwenden Sie anstelle des Platzhalters URL die URL der bereitgestellten App.

{
  "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": {}
  }
}

Laden Sie den Bereitstellungsdeskriptor mit dem folgenden Befehl hoch:

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

Zugriff auf das Add‑on-Backend autorisieren

Das Add-on-Framework benötigt außerdem die Berechtigung, den Dienst aufzurufen. Führen Sie die folgenden Befehle aus, um die IAM-Richtlinie für Cloud Run zu aktualisieren, damit Google Workspace das Add-on aufrufen kann:

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"

Add-on zum Testen installieren

Wenn Sie das Add-on im Entwicklermodus für Ihr Konto installieren möchten, führen Sie in Cloud Shell Folgendes aus:

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

Öffnen Sie (Gmail)[https://mail.google.com/] in einem neuen Tab oder Fenster. Suchen Sie auf der rechten Seite nach dem Add-on mit einem Häkchensymbol.

Symbol für installiertes Add‑on

Klicken Sie auf das Häkchen, um das Add-on zu öffnen. Eine Aufforderung zur Autorisierung des Add‑ons wird angezeigt.

Aufforderung zur Autorisierung

Klicken Sie auf Zugriff autorisieren und folgen Sie der Anleitung im Pop-up-Fenster. Nach Abschluss wird das Add-on automatisch neu geladen und die Meldung „Hello world!“ angezeigt.

Glückwunsch! Sie haben jetzt ein einfaches Add‑on bereitgestellt und installiert. Zeit, daraus eine Aufgabenlisten-App zu machen!

5. Auf die Nutzeridentität zugreifen

Add-ons werden in der Regel von vielen Nutzern verwendet, um mit Informationen zu arbeiten, die für sie oder ihre Organisationen privat sind. In diesem Codelab sollen im Add-on nur die Aufgaben für den aktuellen Nutzer angezeigt werden. Die Nutzeridentität wird über ein Identitätstoken an das Add-on gesendet, das decodiert werden muss.

Bereiche zum Bereitstellungsdeskriptor hinzufügen

Die Nutzeridentität wird nicht standardmäßig gesendet. Es handelt sich um Nutzerdaten und das Add-on benötigt die Berechtigung für den Zugriff darauf. Um diese Berechtigung zu erhalten, aktualisieren Sie deployment.json und fügen Sie die OAuth-Bereiche openid und email der Liste der Bereiche hinzu, die das Add-on benötigt. Nachdem Sie OAuth-Bereiche hinzugefügt haben, werden Nutzer beim nächsten Verwenden des Add-ons aufgefordert, Zugriff zu gewähren.

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

Führen Sie dann in Cloud Shell diesen Befehl aus, um den Bereitstellungsdeskriptor zu aktualisieren:

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

Add-on-Server aktualisieren

Das Add-on ist zwar so konfiguriert, dass die Nutzeridentität angefordert wird, die Implementierung muss aber noch aktualisiert werden.

Identitätstoken parsen

Fügen Sie zuerst die Google-Authentifizierungsbibliothek zum Projekt hinzu:

npm install --save google-auth-library

Bearbeiten Sie dann index.js, um OAuth2Client zu erfordern:

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

Fügen Sie dann eine Hilfsmethode zum Parsen des ID-Tokens hinzu:

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

Nutzeridentität anzeigen

Das ist ein guter Zeitpunkt für einen Checkpoint, bevor Sie die gesamte Funktionalität der Aufgabenliste hinzufügen. Aktualisieren Sie den Routenpfad der App, damit anstelle von „Hello world“ die E-Mail-Adresse und die eindeutige ID des Nutzers ausgegeben werden.

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);
}));

Nach diesen Änderungen sollte die resultierende index.js-Datei so aussehen:

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}`)
});

Neu bereitstellen und testen

Erstellen Sie das Add-on neu und stellen Sie es noch einmal bereit. Führen Sie in Cloud Shell Folgendes aus:

gcloud builds submit

Wenn der Server neu bereitgestellt wurde, öffnen oder aktualisieren Sie Gmail und öffnen Sie das Add‑on noch einmal. Da sich die Bereiche geändert haben, muss das Add-on neu autorisiert werden. Autorisieren Sie das Add-on noch einmal. Danach werden Ihre E-Mail-Adresse und Nutzer-ID angezeigt.

Da das Add-on jetzt weiß, wer der Nutzer ist, können Sie die Funktion für die Aufgabenliste hinzufügen.

6. Aufgabenliste implementieren

Das erste Datenmodell für das Codelab ist einfach: eine Liste von Task-Entitäten, die jeweils Properties für den beschreibenden Text der Aufgabe und einen Zeitstempel enthalten.

Datastore-Index erstellen

Datastore wurde bereits früher im Codelab für das Projekt aktiviert. Es ist kein Schema erforderlich, aber für zusammengesetzte Abfragen müssen explizit Indexe erstellt werden. Das Erstellen des Index kann einige Minuten dauern. Das ist der erste Schritt.

Erstellen Sie eine Datei mit dem Namen index.yaml und folgendem Inhalt:

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

Aktualisieren Sie dann die Datastore-Indexe:

gcloud datastore indexes create index.yaml

Wenn Sie aufgefordert werden, fortzufahren, drücken Sie die Eingabetaste auf Ihrer Tastatur. Die Indexerstellung erfolgt im Hintergrund. Währenddessen können Sie den Add-on-Code aktualisieren, um die „Todos“ zu implementieren.

Add‑on-Backend aktualisieren

Installieren Sie die Datastore-Bibliothek für das Projekt:

npm install --save @google-cloud/datastore

Daten in Datastore lesen und schreiben

Aktualisieren Sie index.js, um die „Todos“ zu implementieren. Beginnen Sie mit dem Importieren der Datastore-Bibliothek und dem Erstellen des Clients:

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

Fügen Sie Methoden zum Lesen und Schreiben von Aufgaben aus Datastore hinzu:

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);
}

Rendering der Benutzeroberfläche implementieren

Die meisten Änderungen betreffen die Add‑on-Benutzeroberfläche. Bisher waren alle von der Benutzeroberfläche zurückgegebenen Karten statisch. Sie haben sich nicht in Abhängigkeit von den verfügbaren Daten geändert. Hier muss die Karte dynamisch auf Grundlage der aktuellen Aufgabenliste des Nutzers erstellt werden.

Die Benutzeroberfläche des Codelabs besteht aus einer Texteingabe und einer Liste von Aufgaben mit Kästchen zum Markieren als erledigt. Jede dieser Eigenschaften hat auch eine onChangeAction-Eigenschaft, die zu einem Callback an den Add-on-Server führt, wenn der Nutzer eine Aufgabe hinzufügt oder löscht. In jedem dieser Fälle muss die Benutzeroberfläche mit der aktualisierten Aufgabenliste neu gerendert werden. Dazu führen wir eine neue Methode zum Erstellen der Karten-UI ein.

Bearbeiten Sie index.js weiter und fügen Sie die folgende Methode hinzu:

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;
}

Routen aktualisieren

Nachdem wir nun Hilfsmethoden zum Lesen und Schreiben in Datastore und zum Erstellen der Benutzeroberfläche haben, können wir sie in den App-Routen miteinander verbinden. Ersetzen Sie die vorhandene Route und fügen Sie zwei weitere hinzu: eine zum Hinzufügen von Aufgaben und eine zum Löschen von Aufgaben.

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);
}));

Hier sehen Sie die endgültige, voll funktionsfähige index.js-Datei:

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}`)
});

Neu bereitstellen und testen

Wenn Sie das Add‑on neu erstellen und bereitstellen möchten, starten Sie einen Build. Führen Sie diesen Befehl in Cloud Shell aus:

gcloud builds submit

Aktualisieren Sie das Add-on in Gmail. Die neue Benutzeroberfläche wird angezeigt. Nehmen Sie sich eine Minute Zeit, um das Add-on kennenzulernen. Fügen Sie einige Aufgaben hinzu, indem Sie Text in die Eingabe eingeben und die EINGABETASTE auf Ihrer Tastatur drücken. Klicken Sie dann auf das Kästchen, um sie zu löschen.

Add-on mit Aufgaben

Wenn Sie möchten, können Sie zum letzten Schritt dieses Codelabs springen und Ihr Projekt bereinigen. Wenn Sie mehr über Add-ons erfahren möchten, können Sie noch einen weiteren Schritt ausführen.

7. Optional: Kontext hinzufügen

Eine der leistungsstärksten Funktionen von Add-ons ist die Kontextsensitivität. Add-ons können mit Nutzerberechtigung auf Google Workspace-Kontexte wie die E-Mail, die ein Nutzer gerade ansieht, einen Kalendertermin und ein Dokument zugreifen. Add-ons können auch Aktionen wie das Einfügen von Inhalten ausführen. In diesem Codelab fügen Sie Kontextunterstützung für die Workspace-Editoren (Docs, Tabellen und Präsentationen) hinzu, damit das aktuelle Dokument an alle Aufgaben angehängt werden kann, die in den Editoren erstellt werden. Wenn die Aufgabe angezeigt wird, wird das Dokument durch Klicken darauf in einem neuen Tab geöffnet, damit der Nutzer zum Dokument zurückkehren und die Aufgabe abschließen kann.

Add‑on-Backend aktualisieren

Route newTask aktualisieren

Aktualisieren Sie zuerst die /newTask-Route, um die Dokument-ID in eine Aufgabe aufzunehmen, falls sie verfügbar ist:

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);
}));

Neu erstellte Aufgaben enthalten jetzt die aktuelle Dokument-ID. Der Kontext in den Editoren wird jedoch nicht standardmäßig freigegeben. Wie bei anderen Nutzerdaten muss der Nutzer dem Add-on die Berechtigung zum Zugriff auf die Daten erteilen. Um zu vermeiden, dass zu viele Informationen freigegeben werden, ist es besser, Berechtigungen für jede Datei einzeln anzufordern und zu erteilen.

Benutzeroberfläche aktualisieren

Aktualisieren Sie in index.js den Wert für buildCard, um zwei Änderungen vorzunehmen. Erstens wird die Darstellung der Aufgaben aktualisiert, um einen Link zum Dokument einzufügen, sofern vorhanden. Die zweite Möglichkeit besteht darin, eine optionale Autorisierungsaufforderung anzuzeigen, wenn das Add-on in einem Editor gerendert wird und noch kein Dateizugriff gewährt wurde.

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;
}

Dateiautorisierungsroute implementieren

Mit der Autorisierungsschaltfläche wird der App eine neue Route hinzugefügt. Implementieren wir sie also. In dieser Route wird ein neues Konzept eingeführt: Host-App-Aktionen. Dies sind spezielle Anweisungen für die Interaktion mit der Hostanwendung des Add-ons. In diesem Fall wird der Zugriff auf die aktuelle Editor-Datei angefordert.

Fügen Sie in index.js die /authorizeFile-Route hinzu:

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

Hier sehen Sie die endgültige, voll funktionsfähige index.js-Datei:

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}`)
});

Bereiche zum Bereitstellungsdeskriptor hinzufügen

Aktualisieren Sie vor dem Neuerstellen des Servers den Add-on-Bereitstellungsdeskriptor, damit er den https://www.googleapis.com/auth/drive.file-OAuth-Bereich enthält. Aktualisieren Sie deployment.json, um https://www.googleapis.com/auth/drive.file der Liste der OAuth-Bereiche hinzuzufügen:

"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"
]

Laden Sie die neue Version hoch, indem Sie diesen Cloud Shell-Befehl ausführen:

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

Neu bereitstellen und testen

Abschließend wird der Server neu erstellt. Führen Sie in Cloud Shell Folgendes aus:

gcloud builds submit

Öffnen Sie nach Abschluss der Einrichtung anstelle von Gmail ein vorhandenes Google-Dokument oder erstellen Sie ein neues, indem Sie doc.new aufrufen. Wenn Sie ein neues Dokument erstellen, müssen Sie Text eingeben oder der Datei einen Namen geben.

Öffnen Sie das Add‑on. Unten im Add-on wird die Schaltfläche Dateizugriff autorisieren angezeigt. Klicken Sie auf die Schaltfläche und autorisieren Sie den Zugriff auf die Datei.

Nach der Autorisierung können Sie im Editor eine Aufgabe hinzufügen. Die Aufgabe enthält ein Label, das angibt, dass das Dokument angehängt ist. Wenn Sie auf den Link klicken, wird das Dokument in einem neuen Tab geöffnet. Das Dokument, das Sie bereits geöffnet haben, noch einmal zu öffnen, ist natürlich etwas unsinnig. Wenn Sie die Benutzeroberfläche optimieren möchten, um Links für das aktuelle Dokument herauszufiltern, ist das ein Bonus.

8. Glückwunsch

Glückwunsch! Sie haben mit Cloud Run erfolgreich ein Google Workspace-Add-on erstellt und bereitgestellt. In diesem Codelab wurden viele der wichtigsten Konzepte für die Entwicklung eines Add-ons behandelt. Es gibt jedoch noch viel mehr zu entdecken. Sehen Sie sich die Ressourcen unten an und denken Sie daran, Ihr Projekt zu bereinigen, um zusätzliche Gebühren zu vermeiden.

Bereinigen

Wenn Sie das Add-on aus Ihrem Konto deinstallieren möchten, führen Sie in Cloud Shell den folgenden Befehl aus:

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

So vermeiden Sie, dass Ihrem Google Cloud Platform-Konto die in dieser Anleitung verwendeten Ressourcen berechnet werden:

  • Wechseln Sie in der Cloud Console zur Seite Ressourcen verwalten. Klicken Sie oben links auf das Dreistrich-MenüMenüsymbol > IAM & Verwaltung > Ressourcen verwalten.
  1. Wählen Sie in der Projektliste Ihr Projekt aus und klicken Sie auf Löschen.
  2. Geben Sie im Dialogfeld die Projekt-ID ein und klicken Sie auf Beenden, um das Projekt zu löschen.

Weitere Informationen