サーバーレス ウェブ API のワークショップ

1. 概要

この Codelab の目標は、「サーバーレス」に関する経験を積むことです。さまざまなサービスを提供しています。

  • Cloud Functions - さまざまなイベント(Pub/Sub メッセージ、Cloud Storage 内の新しいファイル、HTTP リクエストなど)に反応する小規模なビジネス ロジックを関数の形でデプロイします。
  • App Engine - ウェブアプリ、ウェブ API、モバイル バックエンド、静的アセットをデプロイして提供します。迅速なスケールアップとスケールダウンの機能を備えています。
  • Cloud Run - 任意の言語、ランタイム、ライブラリを含めることができるコンテナをデプロイしてスケールします。

また、サーバーレス サービスを利用してウェブ API と REST API をデプロイおよびスケールする方法と、優れた RESTful 設計原則を確認する方法を学びます。

このワークショップでは、以下の要素で構成される本棚探検家を作成します。

  • Cloud Functions の関数: 図書館にある書籍の初期データセットを Cloud Firestore ドキュメント データベースにインポートします。
  • Cloud Run コンテナ: データベースのコンテンツに対する REST API を公開します。
  • App Engine ウェブ フロントエンド: REST API を呼び出して書籍の一覧をブラウジングします。

この Codelab を終えると、ウェブ フロントエンドは次のようになります。

705e014da0ca5e90.png

学習内容

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

2. 設定と要件

セルフペース型の環境設定

  1. Google Cloud Console にログインして、プロジェクトを新規作成するか、既存のプロジェクトを再利用します。Gmail アカウントも Google Workspace アカウントもまだお持ちでない場合は、アカウントを作成してください。

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • プロジェクト名は、このプロジェクトの参加者に表示される名称です。Google API では使用されない文字列です。いつでも更新できます。
  • プロジェクト ID は、すべての Google Cloud プロジェクトにおいて一意でなければならず、不変です(設定後は変更できません)。Cloud コンソールでは一意の文字列が自動生成されます。通常は、この内容を意識する必要はありません。ほとんどの Codelab では、プロジェクト ID(通常は PROJECT_ID と識別されます)を参照する必要があります。生成された ID が好みではない場合は、ランダムに別の ID を生成できます。または、ご自身で試して、利用可能かどうかを確認することもできます。このステップ以降は変更できず、プロジェクトを通して同じ ID になります。
  • なお、3 つ目の値として、一部の API が使用するプロジェクト番号があります。これら 3 つの値について詳しくは、こちらのドキュメントをご覧ください。
  1. 次に、Cloud のリソースや API を使用するために、Cloud コンソールで課金を有効にする必要があります。この Codelab の操作をすべて行って、費用が生じたとしても、少額です。このチュートリアルの終了後に請求が発生しないようにリソースをシャットダウンするには、作成したリソースを削除するか、プロジェクトを削除します。Google Cloud の新規ユーザーは、300 米ドル分の無料トライアル プログラムをご利用いただけます。

Cloud Shell を起動する

Google Cloud はノートパソコンからリモートで操作できますが、この Codelab では、Google Cloud Shell(Cloud 上で動作するコマンドライン環境)を使用します。

Google Cloud Console で、右上のツールバーにある Cloud Shell アイコンをクリックします。

84688aa223b1c3a2.png

プロビジョニングと環境への接続にはそれほど時間はかかりません。完了すると、次のように表示されます。

320e18fedb7fbe0.png

この仮想マシンには、必要な開発ツールがすべて用意されています。永続的なホーム ディレクトリが 5 GB 用意されており、Google Cloud で稼働します。そのため、ネットワークのパフォーマンスと認証機能が大幅に向上しています。この Codelab での作業はすべて、ブラウザ内から実行できます。インストールは不要です。

3. 環境を準備して Cloud APIs を有効にする

このプロジェクトで必要となるさまざまなサービスを使用するために、いくつかの API を有効にします。そのためには、Cloud Shell で次のコマンドを実行します。

$ gcloud services enable \
      appengine.googleapis.com \
      cloudbuild.googleapis.com \
      cloudfunctions.googleapis.com \
      compute.googleapis.com \
      firestore.googleapis.com \
      run.googleapis.com

しばらくすると、オペレーションが正常に完了したことがわかります。

Operation "operations/acf.5c5ef4f6-f734-455d-b2f0-ee70b5a17322" finished successfully.

また、環境変数も設定します。環境変数は、関数、アプリ、コンテナをデプロイする Cloud リージョンです。

$ export REGION=europe-west3

Cloud Firestore データベースにデータを保存するため、データベースを作成する必要があります。

$ gcloud app create --region=${REGION}
$ gcloud firestore databases create --location=${REGION}

この Codelab の後半では、REST API を実装するときに、データの並べ替えとフィルタリングを行う必要があります。この目的のために、次の 3 つのインデックスを作成します。

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=language,order=ascending \
      --field-config field-path=updated,order=descending 

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=author,order=ascending \
      --field-config field-path=updated,order=descending 

これら 3 つのインデックスは、更新されたフィールドによってコレクション内の順序を維持しながら、著者または言語による検索に対応しています。

4. コードを取得する

コードは次の GitHub リポジトリから入手します。

$ git clone https://github.com/glaforge/serverless-web-apis

アプリケーション コードは Node.JS を使用して記述します。

このラボに関連する次のフォルダ構造が表示されます。

serverless-web-apis
 |
 ├── data
 |   ├── books.json
 |
 ├── function-import
 |   ├── index.js
 |   ├── package.json
 |
 ├── run-crud
 |   ├── index.js
 |   ├── package.json
 |   ├── Dockerfile
 |
 ├── appengine-frontend
     ├── public
     |   ├── css/style.css
     |   ├── html/index.html
     |   ├── js/app.js
     ├── index.js
     ├── package.json
     ├── app.yaml

関連するフォルダは次のとおりです。

  • data - このフォルダには、100 冊の書籍の一覧のサンプルデータが含まれています。
  • function-import - この関数によって、サンプルデータをインポートするためのエンドポイントが提供されます。
  • run-crud - このコンテナは、Cloud Firestore に保存されている書籍データにアクセスするウェブ API を公開します。
  • appengine-frontend - この App Engine ウェブ アプリケーションは、書籍のリストを閲覧するためにシンプルな読み取り専用フロントエンドを表示します。

5. 書籍ライブラリのデータのサンプル

データフォルダには、おすすめの書籍 100 冊のリストを含む books.json ファイルがあります。この JSON ドキュメントは、JSON オブジェクトを含む配列です。Cloud Functions の関数を使用して取り込むデータの形状を見てみましょう。

[
  {
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  },
  {
    "isbn": "9781414251196",
    "author": "Hans Christian Andersen",
    "language": "Danish",
    "pages": 784,
    "title": "Fairy tales",
    "year": 1836
  },
  ...
]

この配列のすべての書籍エントリには、次の情報が含まれます。

  • isbn - 書籍を識別する ISBN-13 コード。
  • author - 書籍の著者の名前。
  • language - 書籍が書かれている話し言葉。
  • pages - 書籍のページ数。
  • title - 書籍のタイトル。
  • year - 書籍が出版された年。

6. サンプルの書籍データをインポートする関数エンドポイント

この最初のセクションでは、書籍のサンプルデータをインポートするために使用するエンドポイントを実装します。このために Cloud Functions を使用します。

コードを確認する

まず、package.json ファイルを見てみましょう。

{
    "name": "function-import",
    "description": "Import sample book data",
    "license": "Apache-2.0",
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9"
    },
    "devDependencies": {
        "@google-cloud/functions-framework": "^3.1.0"
    },
    "scripts": {
        "start": "npx @google-cloud/functions-framework --target=parseBooks"
    }
}

ランタイムの依存関係では、データベースにアクセスして書籍データを保存するために必要なのは @google-cloud/firestore NPM モジュールだけです。Cloud Functions ランタイムは内部で Express ウェブ フレームワークも提供するため、依存関係として宣言する必要はありません。

開発の依存関係では、Functions Framework@google-cloud/functions-framework)を宣言しています。これは、関数の呼び出しに使用されるランタイム フレームワークです。これはオープンソースのフレームワークであり、マシン上でローカルに(この場合は Cloud Shell 内で)使用できるため、変更を加えるたびにデプロイせずに関数を実行できるため、開発フィードバック ループが改善されます。

依存関係をインストールするには、install コマンドを使用します。

$ npm install

start スクリプトは Functions Framework を使用して、関数をローカルで実行するためのコマンドを提供します。次の命令を実行します。

$ npm start

HTTP GET リクエストで関数を操作するには、curl を使用するか、Cloud Shell のウェブ プレビューを使用します。

次に、書籍データのインポート関数のロジックが含まれている index.js ファイルを見てみましょう。

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Firestore モジュールをインスタンス化し、書籍コレクション(リレーショナル データベースのテーブルに似ています)をポイントします。

functions.http('parseBooks', async (req, resp) => {
    if (req.method !== "POST") {
        resp.status(405).send({error: "Only method POST allowed"});
        return;
    }
    if (req.headers['content-type'] !== "application/json") {
        resp.status(406).send({error: "Only application/json accepted"});
        return;
    }
    ... 
})

parseBooks JavaScript 関数をエクスポートします。これは、後でデプロイするときに宣言する関数です。

次の 2 つの手順で、次の点を確認します。

  • HTTP POST リクエストのみ受け入れられます。それ以外の場合は、他の HTTP メソッドが許可されていないことを示す 405 ステータス コードが返されます。
  • application/json ペイロードのみを受け入れます。それ以外の場合は 406 ステータス コードを送信して、これが許容されるペイロード形式ではないことを示します。
    const books = req.body;

    const writeBatch = firestore.batch();

    for (const book of books) {
        const doc = bookStore.doc(book.isbn);
        writeBatch.set(doc, {
            title: book.title,
            author: book.author,
            language: book.language,
            pages: book.pages,
            year: book.year,
            updated: Firestore.Timestamp.now()
        });
    }

次に、リクエストの body を介して JSON ペイロードを取得できます。すべての書籍を一括で保存できるように、Firestore のバッチ オペレーションを準備しています。書籍情報で構成される JSON 配列を反復処理し、isbntitleauthorlanguagepagesyear の各フィールドを調べます。書籍の ISBN コードが主キーまたは識別子となります。

    try {
        await writeBatch.commit();
        console.log("Saved books in Firestore");
    } catch (e) {
        console.error("Error saving books:", e);
        resp.status(400).send({error: "Error saving books"});
        return;
    };

    resp.status(202).send({status: "OK"});

データの大部分の準備が整ったので、オペレーションを commit できます。ストレージ オペレーションが失敗した場合は、失敗を通知する 400 ステータス コードが返されます。それ以外の場合は、一括保存リクエストが承認されたことを示す 202 ステータス コードで、OK レスポンスが返されます。

インポート関数の実行とテスト

コードを実行する前に、次のコマンドで依存関係をインストールします。

$ npm install

Functions Framework を使用して関数をローカルで実行するには、package.json で定義した start スクリプト コマンドを使用します。

$ npm start

> start
> npx @google-cloud/functions-framework --target=parseBooks

Serving function...
Function: parseBooks
URL: http://localhost:8080/

HTTP POST リクエストをローカル関数に送信するには、次のコマンドを実行します。

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       http://localhost:8080/

このコマンドを起動すると、次の出力が表示され、関数がローカルで実行されていることを確認できます。

{"status":"OK"}

Cloud コンソール UI に移動して、データが実際に Firestore に保存されていることを確認することもできます。

409982568cebdbf8.png

上のスクリーンショットでは、作成された books コレクション、書籍の ISBN コードで識別される書籍ドキュメントのリスト、右側にその書籍エントリの詳細が表示されています。

クラウドへの関数のデプロイ

Cloud Functions に関数をデプロイするには、function-import ディレクトリで次のコマンドを使用します。

$ gcloud functions deploy bulk-import \
         --gen2 \
         --trigger-http \
         --runtime=nodejs20 \
         --allow-unauthenticated \
         --max-instances=30
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

この関数は、bulk-import のシンボリック名でデプロイします。この関数は HTTP リクエストを介してトリガーされます。Node.JS 20 ランタイムを使用します。関数を公開してデプロイします(理想的には、そのエンドポイントを保護する必要があります)。関数を配置するリージョンを指定します。ローカル ディレクトリ内のソースを指定し、エントリ ポイントとして parseBooks(エクスポートされた JavaScript 関数)を使用します。

数分以内に、関数がクラウドにデプロイされます。Cloud コンソール UI に関数が表示されます。

c910875d4dc0aaa8.png

デプロイの出力で、特定の命名規則(https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME})に従った関数の URL を確認できます。また、この HTTP トリガー URL は、Cloud コンソール UI のトリガータブで確認することもできます。

380ffc46eb56441e.png

gcloud を使用して、コマンドラインで URL を取得することもできます。

$ export BULK_IMPORT_URL=$(gcloud functions describe bulk-import \
                                  --region=$REGION \
                                  --format 'value(httpsTrigger.url)')
$ echo $BULK_IMPORT_URL

デプロイした関数をテストするために再利用できるように、BULK_IMPORT_URL 環境変数に格納します。

デプロイされた関数のテスト

先ほどローカルで実行されている関数のテストに使用したのと同様の curl コマンドを使用して、デプロイされた関数をテストします。唯一の変更点は、URL です。

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       $BULK_IMPORT_URL

ここでも、成功すると次の出力が返されます。

{"status":"OK"}

インポート関数のデプロイと準備が完了し、サンプルデータのアップロードが完了したので、このデータセットを公開する REST API を開発します。

7. REST API コントラクト

Open API 仕様などを使用して API コントラクトを定義しませんが、REST API のさまざまなエンドポイントを確認します。

API は、次の要素で構成される予約 JSON オブジェクトを交換します。

  • isbn(省略可)- 有効な ISBN コードを表す 13 文字の String
  • author - 空でない String。書籍の著者名を表します。
  • language - 書籍が書かれた言語を含む空でない String
  • pages - 書籍のページ数の正の Integer
  • title - 書籍のタイトルを含む空でない String
  • year - 書籍の出版年を表す Integer 値。

書籍のペイロードの例:

{
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  }

GET /books

すべての書籍のリストを取得します。著者や言語でフィルタしたり、一度に 10 件の検索結果を表示するウィンドウでページ分けされたりします。

本文のペイロード: なし。

クエリ パラメータ:

  • author(省略可)- 書籍リストを著者でフィルタします。
  • language(省略可)- 書籍リストを言語でフィルタします。
  • page(省略可、デフォルト = 0)- 返される結果のページのランクを示します。

戻り値: 書籍オブジェクトの JSON 配列。

ステータス コード:

  • 200 - リクエストが書籍のリストの取得に成功した場合。
  • 400 - エラーが発生した場合。

POST /books と POST /books/{isbn}

isbn パス パラメータを指定するか(この場合、書籍のペイロードに isbn コードは不要です)、または含めない(この場合、書籍のペイロードに isbn コードを含める必要があります)の新しい書籍ペイロードを送信します。

本文のペイロード: 書籍オブジェクト。

クエリ パラメータ: なし。

戻り値: なし。

ステータス コード:

  • 201 - 書籍が正常に保存されると、
  • 406 - isbn コードが無効な場合。
  • 400 - エラーが発生した場合。

GET /books/{isbn}

isbn コードで識別され、パスパラメータとして渡される書籍をライブラリから取得します。

本文のペイロード: なし。

クエリ パラメータ: なし。

戻り値: 書籍の JSON オブジェクト、または書籍が存在しない場合はエラー オブジェクト。

ステータス コード:

  • 200 - 書籍がデータベースで見つかった場合。
  • 400 - エラーが発生した場合
  • 404 - 書籍が見つからなかった場合
  • 406 - isbn コードが無効な場合。

PUT /books/{isbn}

パスパラメータとして渡された isbn で識別される既存の書籍を更新します。

本文のペイロード: 書籍オブジェクト。更新が必要なフィールドのみを渡すことができます。その他のフィールドは省略できます。

クエリ パラメータ: なし。

戻り値: 更新された書籍。

ステータス コード:

  • 200 - 書籍が正常に更新された場合
  • 400 - エラーが発生した場合
  • 406 - isbn コードが無効な場合。

DELETE /books/{isbn}

パスパラメータとして渡された isbn によって識別される既存の書籍を削除します。

本文のペイロード: なし。

クエリ パラメータ: なし。

戻り値: なし。

ステータス コード:

  • 204 - 書籍が正常に削除された場合
  • 400 - エラーが発生した場合。

8. コンテナに REST API をデプロイして公開する

コードを確認する

Dockerfile

まず、アプリケーション コードをコンテナ化する Dockerfile について見ていきましょう。

FROM node:20-slim
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . ./
CMD [ "node", "index.js" ]

Node.JS 20 の "slim" イメージを使用します。/usr/src/app ディレクトリで作業しています。とりわけ、依存関係を定義する package.json ファイル(詳細は下記)をコピーします。npm install で依存関係をインストールし、ソースコードをコピーします。最後に、node index.js コマンドを使用して、このアプリケーションの実行方法を指定します。

package.json

次に、package.json ファイルを確認します。

{
    "name": "run-crud",
    "description": "CRUD operations over book data",
    "license": "Apache-2.0",
    "engines": {
        "node": ">= 20.0.0"
    },
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9",
        "cors": "^2.8.5",
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "scripts": {
        "start": "node index.js"
    }
}

Dockerfile の場合と同様に、Node.JS 14 を使用することを指定します。

ウェブ API アプリケーションは、以下に依存します。

  • データベース内の書籍データにアクセスする Firestore NPM モジュール、
  • cors ライブラリ: CORS(クロスオリジン リソース シェアリング)リクエストを処理します。REST API は、App Engine ウェブ アプリケーション フロントエンドのクライアント コードから呼び出されるため、
  • Express フレームワーク(API を設計するためのウェブ フレームワーク)は、
  • isbn3 モジュールは、書籍の ISBN コードの検証に役立ちます。

また、start スクリプトを指定します。開発やテストの目的で、アプリケーションをローカルで起動する場合に便利です。

index.js

コードの本文に移り、index.js を詳しく見てみます。

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Firestore モジュールを必要とし、書籍のデータが保存されている books コレクションを参照します。

const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.json());

const querystring = require('querystring');

const cors = require('cors');
app.use(cors({
    exposedHeaders: ['Content-Length', 'Content-Type', 'Link'],
}));

REST API を実装するために、ウェブ フレームワークとして Express を使用しています。body-parser モジュールを使用して、API と交換された JSON ペイロードを解析します。

querystring モジュールは URL の操作に役立ちます。これは、ページ分けの目的で Link ヘッダーを作成する場合です(詳細は後述)。

次に、cors モジュールを構成します。CORS で渡すヘッダーを明示します。ほとんどのヘッダーは通常省略されますが、ここでは通常のコンテンツの長さと種類、ページネーション用に指定する Link ヘッダーをそのままにします。

const ISBN = require('isbn3');

function isbnOK(isbn, res) {
    const parsedIsbn = ISBN.parse(isbn);
    if (!parsedIsbn) {
        res.status(406)
            .send({error: `Invalid ISBN: ${isbn}`});
        return false;
    }
    return parsedIsbn;
}

Google では、isbn3 NPM モジュールを使用して ISBN コードの解析と検証を行います。また、ISBN コードを解析し、ISBN コードが無効な場合にレスポンスで 406 ステータス コードを返す小さなユーティリティ関数を開発します。

  • GET /books

GET /books エンドポイントを 1 つずつ見ていきましょう。

app.get('/books', async (req, res) => {
    try {
        var query = new Firestore().collection('books');

        if (!!req.query.author) {
            console.log(`Filtering by author: ${req.query.author}`);
            query = query.where("author", "==", req.query.author);
        }
        if (!!req.query.language) {
            console.log(`Filtering by language: ${req.query.language}`);
            query = query.where("language", "==", req.query.language);
        }

        const page = parseInt(req.query.page) || 0;

        // - -  - -  - -  - -  - -  - -

    } catch (e) {
        console.error('Failed to fetch books', e);
        res.status(400)
            .send({error: `Impossible to fetch books: ${e.message}`});
    }
});

クエリを作成して、データベースにクエリを実行する準備をしています。このクエリは、オプションのクエリ パラメータを使用して、作成者や言語でフィルタします。また、書籍リストは 10 冊のチャンク単位で返されます。

書籍の取得中にエラーが発生した場合は、400 ステータス コードとともにエラーが返されます。

このエンドポイントの切り取られた部分にズームインしてみましょう。

        const snapshot = await query
            .orderBy('updated', 'desc')
            .limit(PAGE_SIZE)
            .offset(PAGE_SIZE * page)
            .get();

        const books = [];

        if (snapshot.empty) {
            console.log('No book found');
        } else {
            snapshot.forEach(doc => {
                const {title, author, pages, year, language, ...otherFields} = doc.data();
                const book = {isbn: doc.id, title, author, pages, year, language};
                books.push(book);
            });
        }

前のセクションでは、authorlanguage でフィルタしましたが、このセクションでは書籍のリストを最終更新日(最終更新日が先)で並べ替えます。また、limit(返される要素の数)とオフセット(次の一連の書籍を返す際の開始点)を定義して、結果をページ分けします。

クエリを実行してデータのスナップショットを取得し、その結果を JavaScript 配列に格納します。この配列は関数の最後に返されます。

Link ヘッダーを使用して、データの最初、前、次、最後のページへの URI リンクを定義するおすすめの方法について、このエンドポイントの説明を締めくくりましょう(今回は「前後」のみを指定します)。

        var links = {};
        if (page > 0) {
            const prevQuery = querystring.stringify({...req.query, page: page - 1});
            links.prev = `${req.path}${prevQuery != '' ? `?${prevQuery}` : ''}`;
        }
        if (snapshot.docs.length === PAGE_SIZE) {
            const nextQuery = querystring.stringify({...req.query, page: page + 1});
            links.next = `${req.path}${nextQuery != '' ? `?${nextQuery}` : ''}`;
        }
        if (Object.keys(links).length > 0) {
            res.links(links);
        }

        res.status(200).send(books);

初めはロジックが少し複雑に見えるかもしれませんが、データの最初のページになっていない場合に [前] のリンクを追加します。また、データのページがいっぱいである(つまり、PAGE_SIZE 定数で定義された書籍の最大数が含まれているとき、別のページでさらに多くのデータがあると仮定した場合)は、next リンクを追加します。次に、Express の resource#links() 関数を使用して、正しい構文で適切なヘッダーを作成します。

参考までに、リンクヘッダーは次のようになります。

link: </books?page=1>; rel="prev", </books?page=3>; rel="next"
  • POST /booksPOST /books/:isbn

新しい書籍を作成するには、両方のエンドポイントを使用します。1 つは書籍のペイロードで ISBN コードを渡しますが、もう 1 つはパスパラメータとして渡します。いずれの場合も、createBook() 関数を呼び出します。

async function createBook(isbn, req, res) {
    const parsedIsbn = isbnOK(isbn, res);
    if (!parsedIsbn) return;

    const {title, author, pages, year, language} = req.body;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            title, author, pages, year, language,
            updated: Firestore.Timestamp.now()
        });
        console.log(`Saved book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} created`});
    } catch (e) {
        console.error(`Failed to save book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to create book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
}

isbn コードが有効であるかどうかを確認します。有効でない場合は、関数から戻ります(さらに 406 ステータス コードを設定します)。リクエストの本文で渡されたペイロードから書籍フィールドを取得します。次に、書籍の詳細を Firestore に保存します。成功した場合は 201、失敗した場合は 400 を返します。

正常に返されるときには、location ヘッダーも設定し、新しく作成されたリソースがある API のクライアントにキューを与えられるようにしています。ヘッダーは次のようになります。

Location: /books/9781234567898
  • GET /books/:isbn

ISBN で識別される書籍を Firestore から取得しましょう。

app.get('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        const docSnapshot = await docRef.get();

        if (!docSnapshot.exists) {
            console.log(`Book not found ${parsedIsbn.isbn13}`)
            res.status(404)
                .send({error: `Could not find book ${parsedIsbn.isbn13}`});
            return;
        }

        console.log(`Fetched book ${parsedIsbn.isbn13}`, docSnapshot.data());

        const {title, author, pages, year, language, ...otherFields} = docSnapshot.data();
        const book = {isbn: parsedIsbn.isbn13, title, author, pages, year, language};

        res.status(200).send(book);
    } catch (e) {
        console.error(`Failed to fetch book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to fetch book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

通常どおり、Google では ISBN が有効かどうかを確認します。Firestore にクエリを実行して書籍を取得します。snapshot.exists プロパティは、書籍が実際に見つかったかどうかを確認するのに役立ちます。それ以外の場合は、エラーと 404 Not Found ステータス コードが返されます。書籍データを取得し、書籍を表す JSON オブジェクトを作成して返します。

  • PUT /books/:isbn

既存の書籍の更新には PUT メソッドを使用しています。

app.put('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            ...req.body,
            updated: Firestore.Timestamp.now()
        }, {merge: true});
        console.log(`Updated book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} updated`});
    } catch (e) {
        console.error(`Failed to update book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to update book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
});

updated の日付/時刻フィールドを更新して、そのレコードの最終更新日を記憶します。ここでは、既存のフィールドを新しい値に置き換える {merge:true} 戦略を使用します(そうしないと、すべてのフィールドが削除され、ペイロード内の新しいフィールドのみが保存され、以前の更新または最初の作成から既存のフィールドが消去されます)。

また、書籍の URI を指すように Location ヘッダーを設定します。

  • DELETE /books/:isbn

書籍の削除はとても簡単です。ドキュメント参照で delete() メソッドを呼び出すだけです。コンテンツは返されないため、204 ステータス コードを返します。

app.delete('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.delete();
        console.log(`Book ${parsedIsbn.isbn13} was deleted`);

        res.status(204).end();
    } catch (e) {
        console.error(`Failed to delete book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to delete book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

Express / Node サーバーを起動する

最後に、サーバーを起動し、デフォルトでポート 8080 をリッスンします。

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Books Web API service: listening on port ${port}`);
    console.log(`Node ${process.version}`);
});

アプリケーションをローカルで実行する

アプリケーションをローカルで実行するには、まず、次のコマンドで依存関係をインストールします。

$ npm install

次に、以下から始めます。

$ npm start

サーバーは localhost で起動し、デフォルトではポート 8080 でリッスンします。

次のコマンドを使用して、Docker コンテナをビルドし、コンテナ イメージを実行することもできます。

$ docker build -t crud-web-api .

$ docker run --rm -p 8080:8080 -it crud-web-api

また、Docker 内で実行することは、Cloud Build を使用してクラウドでアプリケーションをビルドする際に、アプリケーションのコンテナ化に問題がないか再確認する方法としても優れています。

API のテスト

REST API コードを実行する方法(Node から直接、または Docker コンテナ イメージ経由で)に関係なく、REST API コードに対していくつかのクエリを実行できるようになりました。

  • 新しい書籍を作成します(本文ペイロードに ISBN あり)。
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books
  • 新しい書籍を作成します(path パラメータに ISBN あり)。
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books/9782070368228
  • 書籍(作成した書籍)を削除する:
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • ISBN で書籍を取得する:
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • 既存の書籍を書籍名のみを変更して更新します。
$ curl -XPUT \
       -d '{"title":"Book"}' \
       -H "Content-Type: application/json" \      
       http://localhost:8080/books/9780003701203
  • 書籍のリスト(最初の 10 冊)を取得します。
$ curl http://localhost:8080/books
  • 特定の著者による書籍を検索するには:
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • 英語で書かれた書籍を挙げる:
$ curl http://localhost:8080/books?language=English
  • 書籍の 4 ページ目を読み込みます。
$ curl http://localhost:8080/books?page=3

authorlanguagebooks の各クエリ パラメータを組み合わせて検索を絞り込むこともできます。

コンテナ化された REST API の構築とデプロイ

REST API が計画どおりに動作できたことに満足した今こそ、クラウドの Cloud Run にデプロイする絶好のタイミングです。

これは次の 2 つのステップで行います。

  • まず、次のコマンドを使用して、Cloud Build でコンテナ イメージをビルドします。
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • 次に、次の 2 番目のコマンドでサービスをデプロイします。
$ gcloud run deploy run-crud \
         --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
         --allow-unauthenticated \
         --region=${REGION} \
         --platform=managed

最初のコマンドでは、Cloud Build がコンテナ イメージをビルドし、Container Registry でホストします。次のコマンドで、レジストリからコンテナ イメージをデプロイし、クラウド リージョンにデプロイします。

Cloud コンソール UI で、Cloud Run サービスがリストに表示されていることを再確認できます。

f62fbca02a8127c0.png

ここで行う最後のステップとして、次のコマンドにより、新しくデプロイした Cloud Run サービスの URL を取得します。

$ export RUN_CRUD_SERVICE_URL=$(gcloud run services describe run-crud \
                                       --region=${REGION} \
                                       --platform=managed \
                                       --format='value(status.url)')

次のセクションでは、App Engine フロントエンド コードが API とやり取りするため、Cloud Run REST API の URL が必要になります。

9. ウェブアプリをホストしてライブラリをブラウジングする

最後に、REST API とやり取りするウェブ フロントエンドを提供します。この目的のために、Google App Engine と、(クライアントサイドの Fetch API を使用して)AJAX リクエストを介して API を呼び出すクライアント JavaScript コードをいくつか使用します。

このアプリケーションは Node.JS App Engine ランタイムにデプロイされていますが、そのほとんどは静的リソースで構成されています。ユーザー操作のほとんどは、クライアント側の JavaScript を介してブラウザで行われるため、バックエンドのコードはあまり必要ありません。高度なフロントエンド JavaScript フレームワークは使用せず、標準 JavaScript をいくつか使用します。また、UI には Shoelace ウェブ コンポーネント ライブラリを使用したウェブ コンポーネントをいくつか使用します。

  • 書籍の言語を選択する選択ボックス:

6fb9f741000a2dc1.png

  • 特定の書籍の詳細を表示するカード コンポーネント(JsBarcode ライブラリを使用して書籍の ISBN を表すバーコードを含む):

3aa21a9e16e3244e.png

  • データベースから書籍をさらに読み込むためのボタンが表示されます。

3925ad81c91bbac9.png

これらすべての視覚的要素を組み合わせると、ライブラリをブラウジングするためのウェブページは次のようになります。

18a5117150977d6.png

app.yaml 構成ファイル

まず、この App Engine アプリケーションのコードベースについて詳しく見ていきましょう。まず、app.yaml 構成ファイルを確認します。これは App Engine に固有のファイルで、環境変数やアプリケーションのさまざまな「ハンドラ」などの設定を行うことができます。また、一部のリソースを静的アセットとして指定し、App Engine の組み込み CDN によって処理することもできます。

runtime: nodejs14

env_variables:
  RUN_CRUD_SERVICE_URL: CHANGE_ME

handlers:

- url: /js
  static_dir: public/js

- url: /css
  static_dir: public/css

- url: /img
  static_dir: public/img

- url: /(.+\.html)
  static_files: public/html/\1
  upload: public/(.+\.html)

- url: /
  static_files: public/html/index.html
  upload: public/html/index\.html

- url: /.*
  secure: always
  script: auto

アプリケーションが Node.JS であることと、バージョン 14 を使用することを指定します。

次に、Cloud Run サービス URL を指す環境変数を定義します。CHANGE_ME プレースホルダを正しい URL に更新する必要があります(変更方法については後述します)。

その後、さまざまなハンドラを定義します。最初の 3 つのコードは、public/ フォルダとそのサブフォルダの下にある、HTML、CSS、JavaScript のクライアントサイド コードの場所を指しています。4 つ目は、App Engine アプリケーションのルート URL が index.html ページを指す必要があることを指定します。これにより、ウェブサイトのルートにアクセスする際に、URL に index.html 接尾辞が表示されなくなります。最後の URL はデフォルトのもので、他のすべての URL(/.*)を Node.JS アプリケーションにルーティングします(前述の静的アセットとは対照的に、アプリケーションの「動的」部分)。

Cloud Run サービスのウェブ API URL を更新しましょう。

appengine-frontend/ ディレクトリで次のコマンドを実行して、Cloud Run ベースの REST API の URL を指す環境変数を更新します。

$ sed -i -e "s|CHANGE_ME|${RUN_CRUD_SERVICE_URL}|" app.yaml

または、app.yamlCHANGE_ME 文字列を正しい URL に手動で変更します。

env_variables:
    RUN_CRUD_SERVICE_URL: CHANGE_ME

Node.JS package.json ファイル

{
    "name": "appengine-frontend",
    "description": "Web frontend",
    "license": "Apache-2.0",
    "main": "index.js",
    "engines": {
        "node": "^14.0.0"
    },
    "dependencies": {
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "devDependencies": {
        "nodemon": "^2.0.7"
    },
    "scripts": {
        "start": "node index.js",
        "dev": "nodemon --watch server --inspect index.js"
    }
}

繰り返しになりますが、Node.JS 14 を使用してこのアプリケーションを実行する必要があることを再度強調します。書籍の検証には Express フレームワークと isbn3 NPM モジュールが使用されますISBN コード。

開発の依存関係では、nodemon モジュールを使用してファイルの変更をモニタリングします。npm start を使用してアプリケーションをローカルで実行することもできますが、コードを変更し、^C でアプリを停止して再起動しますが、少し面倒です。代わりに、次のコマンドを使用して、変更時にアプリケーションを自動的に再読み込み / 再起動できます。

$ npm run dev

index.js Node.JS コード

const express = require('express');
const app = express();

app.use(express.static('public'));

const bodyParser = require('body-parser');
app.use(bodyParser.json());

Express ウェブ フレームワークが必要です。公開ディレクトリに、static ミドルウェアが提供できる静的アセットが含まれていることを指定します(少なくとも、開発モードでローカルで実行する場合)。最後に、JSON ペイロードを解析するために body-parser が必要です。

定義したルートをいくつか見てみましょう。

app.get('/', async (req, res) => {
    res.redirect('/html/index.html');
});

app.get('/webapi', async (req, res) => {
    res.send(process.env.RUN_CRUD_SERVICE_URL);
});

最初に / に一致するものが、public/html ディレクトリの index.html にリダイレクトされます。開発モードでは、App Engine ランタイム内では実行しないため、App Engine の URL ルーティングは実行されません。そのため、ここでは単純にルート URL を HTML ファイルにリダイレクトしています。

/webapi を定義する 2 つ目のエンドポイントは、Cloud RUN REST API の URL を返します。これにより、クライアントサイドの JavaScript コードは、書籍のリストを取得するための呼び出し先を把握できます。

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Book library web frontend: listening on port ${port}`);
    console.log(`Node ${process.version}`);
    console.log(`Web API endpoint ${process.env.RUN_CRUD_SERVICE_URL}`);
});

終了まで、Express ウェブアプリを実行してデフォルトでポート 8080 をリッスンします。

index.html ページ

この長い HTML ページのすべての行を確認することはしません。代わりに、いくつかの重要な行を強調して説明します。

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/themes/base.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/shoelace.js"></script>

<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.0/dist/barcodes/JsBarcode.ean-upc.min.js"></script>

<script src="/js/app.js"></script>
<link rel="stylesheet" type="text/css" href="/css/style.css">

最初の 2 行で、Shoelace ウェブ コンポーネント ライブラリ(スクリプトとスタイルシート)をインポートします。

次の行は、JsBarcode ライブラリをインポートして、書籍の ISBN コードのバーコードを作成します。

最後の行では、public/ サブディレクトリにある独自の JavaScript コードと CSS スタイルシートをインポートしています。

HTML ページの body では、次のような Shoelace コンポーネントとカスタム要素タグを使用します。

<sl-icon name="book-half"></sl-icon>
...

<sl-select id="language-select" placeholder="Select a language..." clearable>
    <sl-menu-item value="English">English</sl-menu-item>
    <sl-menu-item value="French">French</sl-menu-item>
    ...
</sl-select>
...

<sl-button id="more-button" type="primary" size="large">
    More books...
</sl-button>
...

また、HTML テンプレートとそのスロット充填機能を使用して本を表現しています。そのテンプレートのコピーを作成して書籍のリストに挿入し、スロットの値を書籍の詳細情報に置き換えます。

    <template id="book-card">
        <sl-card class="card-overview">
        ...
            <slot name="author">Author</slot>
            ... 
        </sl-card>
    </template>

HTML の説明は十分です。コードの確認はあと少しで完了です。最後にもう 1 つ、REST API とやり取りする app.js クライアントサイドの JavaScript コードがあります。

app.js クライアントサイドの JavaScript コード

まず、DOM コンテンツが読み込まれるのを待つ、トップレベルのイベント リスナーから始めます。

document.addEventListener("DOMContentLoaded", async function(event) {
    ...
}

準備ができたら、重要な定数と変数を設定します。

    const serverUrlResponse = await fetch('/webapi');
    const serverUrl = await serverUrlResponse.text();
    console.log('Web API endpoint:', serverUrl);
    
    const server = serverUrl + '/books';
    var page = 0;
    var language = '';

まず、app.yaml で最初に設定した環境変数を返す App Engine ノードコードのおかげで、REST API の URL を取得します。JavaScript クライアントサイド コードから呼び出される環境変数 /webapi エンドポイントのおかげで、フロントエンド コードに REST API URL をハードコードする必要がなくなりました。

また、ページ分けと言語フィルタリングの追跡に使用する page 変数と language 変数も定義します。

    const moreButton = document.getElementById('more-button');
    moreButton.addEventListener('sl-focus', event => {
        console.log('Button clicked');
        moreButton.blur();

        appendMoreBooks(server, page++, language);
    });

書籍を読み込むためのイベント ハンドラをボタンに追加します。これをクリックすると、appendMoreBooks() 関数が呼び出されます。

    const langSelect = document.getElementById('language-select');
    langSelect.addEventListener('sl-change', event => {
        page = 0;
        language = event.srcElement.value;
        document.getElementById('library').replaceChildren();
        console.log(`Language selected: "${language}"`);

        appendMoreBooks(server, page++, language);
    });

選択ボックスと同様に、言語選択の変更について通知を受けるイベント ハンドラを追加します。ボタンと同様に、appendMoreBooks() 関数も呼び出し、REST API の URL、現在のページ、選択した言語を渡します。

書籍をフェッチして追加する関数を見てみましょう。

async function appendMoreBooks(server, page, language) {
    const searchUrl = new URL(server);
    if (!!page) searchUrl.searchParams.append('page', page);
    if (!!language) searchUrl.searchParams.append('language', language);
        
    const response = await fetch(searchUrl.href);
    const books = await response.json();
    ... 
}

上記では、REST API の呼び出しに使用する正確な URL を作成しています。通常は 3 つのクエリ パラメータを指定できますが、この UI では次の 2 つのみを指定します。

  • page - 書籍のページ分けの現在のページを示す整数。
  • language - 記述言語でフィルタする言語文字列。

次に、Fetch API を使用して、書籍の詳細情報を含む JSON 配列を取得します。

    const linkHeader = response.headers.get('Link')
    console.log('Link', linkHeader);
    if (!!linkHeader && linkHeader.indexOf('rel="next"') > -1) {
        console.log('Show more button');
        document.getElementById('buttons').style.display = 'block';
    } else {
        console.log('Hide more button');
        document.getElementById('buttons').style.display = 'none';
    }

レスポンスに Link ヘッダーが存在するかどうかに応じて、[More books...] ボタンを表示または非表示にします。Link ヘッダーは、まだ読み込む書籍がまだあるかどうかを伝えるヒントです(Link ヘッダーに next URL があります)。

    const library = document.getElementById('library');
    const template = document.getElementById('book-card');
    for (let book of books) {
        const bookCard = template.content.cloneNode(true);

        bookCard.querySelector('slot[name=title]').innerText = book.title;
        bookCard.querySelector('slot[name=language]').innerText = book.language;
        bookCard.querySelector('slot[name=author]').innerText = book.author;
        bookCard.querySelector('slot[name=year]').innerText = book.year;
        bookCard.querySelector('slot[name=pages]').innerText = book.pages;
        
        const img = document.createElement('img');
        img.setAttribute('id', book.isbn);
        img.setAttribute('class', 'img-barcode-' + book.isbn)
        bookCard.querySelector('slot[name=barcode]').appendChild(img);

        library.appendChild(bookCard);
        ... 
    }
}

関数の上記のセクションでは、REST API から返された書籍ごとに、書籍を表すウェブ コンポーネントを使用してテンプレートのクローンを作成し、テンプレートのスロットに書籍の詳細を入力します。

JsBarcode('.img-barcode-' + book.isbn).EAN13(book.isbn, {fontSize: 18, textMargin: 0, height: 60}).render();

ISBN コードを少し見やすくするために、JsBarcode ライブラリを使用して、本物の本の裏表紙のような優れたバーコードを作成します。

アプリケーションをローカルで実行してテストする

ここまでのコードは以上です。アプリケーションの実際の動作を確認してみましょう。まず、実際にデプロイする前に、Cloud Shell 内でローカルに実行します。

アプリケーションに必要な NPM モジュールを、次のコマンドでインストールします。

$ npm install

そして、通常どおりにアプリを実行します。

$ npm start

または、次のように nodemon による変更の自動再読み込みを行います。

$ npm run dev

アプリケーションはローカルで実行されており、http://localhost:8080 のブラウザからアクセスできます。

App Engine アプリケーションのデプロイ

アプリケーションがローカルで正常に動作することを確認できたので、次はアプリケーションを App Engine にデプロイします。

アプリケーションをデプロイするために、次のコマンドを実行します。

$ gcloud app deploy -q

約 1 分後にアプリケーションがデプロイされます。

アプリケーションは、次の形式の URL で使用できるようになります。https://${GOOGLE_CLOUD_PROJECT}.appspot.com

App Engine ウェブ アプリケーションの UI の探索

次のことを行えるようになりました。

  • 他の書籍を読み込むには、[More books...] ボタンをクリックします。
  • 特定の言語を選択すると、その言語でのみ書籍が表示されます。
  • 選択ボックスの小さなバツ印で選択を解除すると、すべての書籍のリストに戻ります。

10. クリーンアップ(省略可)

アプリを残すつもりがない場合は、プロジェクト全体を削除することで、リソースをクリーンアップすることで費用を節約し、クラウド全般的に正しい行動を取ることができます。

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. 完了

Cloud Functions、App Engine、Cloud Run のおかげで、さまざまな Web API エンドポイントとウェブ フロントエンドを公開し、REST API 開発の優れた設計パターンに沿って書籍のライブラリを保存、更新、閲覧する一連のサービスを作成しました。

学習した内容

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

さらに詳しく見てみましょう

この具体例を詳しく調べて展開したい場合は、次のリストをご覧ください。

  • API Gateway を利用して、データ インポート関数と REST API コンテナに共通の API ファサードを提供したり、API にアクセスするための API キーの処理などの機能を追加したり、API コンシューマのレート制限を定義したりできます。
  • App Engine アプリケーションに Swagger-UI ノード モジュールをデプロイしてドキュメント化し、REST API のテスト プレイグラウンドを提供します。
  • フロントエンドでは、既存のブラウジング機能に加えて、データを編集するための画面を追加し、新しい書籍エントリを作成します。また、Cloud Firestore データベースを使用しているため、そのリアルタイム機能を利用して、変更が行われると表示される書籍データを更新します。