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

1. 概要

この Codelab の目的は、Google Cloud Platform が提供する「サーバーレス」サービスを体験することです。

  • 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 API を有効にする

このプロジェクト全体で使用するさまざまなサービスを利用できるように、いくつかの 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.

また、関数、アプリ、コンテナをデプロイするクラウド リージョンなど、途中で必要になる環境変数も設定します。

$ 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 に保存されている書籍データにアクセスするための Web 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

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

書籍データのインポート関数のロジックを含む 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 リクエストのみを受け付け、それ以外の場合は 405 ステータス コードを返して、他の HTTP メソッドが許可されていないことを示します。
  • 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"});

データの大部分の準備ができたので、オペレーションをコミットできます。ストレージ オペレーションが失敗した場合は、失敗したことを示す 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 Console 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

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

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 コードが必要)新しい書籍ペイロードを投稿します。

本文ペイロード: book オブジェクト。

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

戻り値: なし。

ステータス コード:

  • 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 ライブラリ。REST API は App Engine ウェブ アプリケーションのフロントエンドのクライアント コードから呼び出されるため、CORS(クロスオリジン リソース シェアリング)リクエストを処理します。
  • API の設計に使用するウェブ フレームワークである Express フレームワーク。
  • また、書籍の ISBN コードの検証に役立つ isbn3 モジュールもあります。

また、開発とテストのためにアプリケーションをローカルで起動する際に役立つ 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'],
}));

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

querystring モジュールは、URL を操作する際に役立ちます。これは、ページネーションの目的で Link ヘッダーを作成する場合に当てはまります(詳しくは後述)。

次に、cors モジュールを構成します。ほとんどのヘッダーは通常削除されますが、ここでは、通常のコンテンツの長さとタイプ、およびページネーション用に指定する Link ヘッダーを保持するため、CORS 経由で渡すヘッダーを明示的に指定します。

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

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

  • GET /books

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

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 でフィルタリングしましたが、このセクションでは、書籍のリストを最終更新日の順に並べ替えます(最終更新日が最も新しいものが先頭になります)。また、上限(返す要素の数)とオフセット(次の書籍のバッチを返す開始点)を定義して、結果をページネーションします。

クエリを実行してデータのスナップショットを取得し、その結果を 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 定義で指定された最大数の書籍が含まれている場合。さらに多くのデータを含む別のページが続くことを想定)、次へのリンクを追加します。次に、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 を返します。

成功を返すときには、ロケーション ヘッダーも設定して、新しく作成されたリソースがどこにあるかを 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}`});
    }
});

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 / ノードサーバーを起動する

最後に、サーバーを起動します。デフォルトではポート 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 コンテナ イメージを介して実行するか)に関係なく、いくつかのクエリを実行できるようになりました。

  • 新しい書籍を作成します(本文ペイロードに 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
  • 新しい書籍を作成します(パス パラメータに 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 で Cloud にデプロイするのに最適なタイミングです。

この処理は次の 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 Console 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

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

3aa21a9e16e3244e.png

  • データベースからさらに書籍を読み込むためのボタン:

3925ad81c91bbac9.png

これらのビジュアル コンポーネントをすべて組み合わせると、ライブラリを閲覧するためのウェブページは次のようになります。

18a5117150977d6.png

app.yaml 構成ファイル

app.yaml 構成ファイルを見て、この App Engine アプリケーションのコードベースを詳しく見ていきましょう。これは 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(/.*)を Node.JS アプリケーション(つまり、説明した静的アセットとは対照的に、アプリケーションの「動的」部分)にルーティングするデフォルトのものです。

それでは、Cloud Run サービスの Web 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 を使用して実行します。書籍の ISBN コードを検証するために、Express フレームワークと isbn3 NPM モジュールに依存しています。

開発依存関係では、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 が必要です。

定義した 2 つのルートを見てみましょう。

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 ファイルにリダイレクトするだけです。

2 番目のエンドポイント /webapi は、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 ウェブ コンポーネント ライブラリ(スクリプトとスタイルシート)をインポートします。

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

最後の行では、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 はこれで十分です。コードの確認はほぼ完了しました。最後に、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 Engine ノードコードのおかげで、REST API の URL を取得します。このコードは、app.yaml で最初に設定した環境変数を返します。環境変数と、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 分後、アプリケーションがデプロイされます。

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

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 データベースを使用しているため、そのリアルタイム機能を利用して、変更が行われたときに表示される書籍データを更新します。