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

1. 概要

この Codelab の目標は、Google Cloud Platform で提供される「サーバーレス」サービスの経験を積むことです。

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

サーバーレス サービスを活用してウェブと REST API をデプロイしてスケールする方法と、優れた RESTful 設計の原則を実践する方法について説明します。

このワークショップでは、以下で構成されるブックシェルフ エクスプローラを作成します。

  • Cloud Function: Cloud Firestore ドキュメント データベースにある、ライブラリで使用可能な書籍の初期データセットをインポートします。
  • Cloud Run コンテナ: データベースのコンテンツに対して REST API を公開します。
  • App Engine ウェブ フロントエンド: REST API を呼び出して書籍のリストを参照できます。

この Codelab の最後にウェブ フロントエンドは次のようになります。

b6964f26b9624565.png

学習内容

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

2. 設定と要件

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

  1. Cloud Console にログインし、新しいプロジェクトを作成するか、既存のプロジェクトを再利用します(Gmail または Google Workspace アカウントをまだお持ちでない場合は、アカウントを作成する必要があります)。

96a9c957bc475304.png

b9a10ebdf5b5a448.png

a1e3c01a38fa61c2.png

プロジェクト ID を忘れないようにしてください。プロジェクト ID はすべての Google Cloud プロジェクトを通じて一意の名前にする必要があります(上記の名前はすでに使用されているので使用できません)。以降、このコードラボでは PROJECT_ID と呼びます。

  1. 次に、Google Cloud リソースを使用するために、Cloud Console で課金を有効にする必要があります。

このコードラボを実行しても、費用はほとんどかからないはずです。このチュートリアル以外で請求が発生しないように、リソースのシャットダウン方法を説明する「クリーンアップ」セクションの手順に従うようにしてください。Google Cloud の新規ユーザーは、300 米ドルの無料トライアル プログラムをご利用いただけます。

Cloud Shell の起動

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

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

bce75f34b2c53987.png

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

f6ef2b5f13479f3a.png

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

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.

また、途中で必要になる環境変数(関数、アプリ、コンテナをデプロイする場所)もセットアップします。

$ export REGION=europe-west3

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

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

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

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

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

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

これら 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": "^1.7.1"
    },
    "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 モジュールをインスタンス化し、書籍コレクションを指しています(リレーショナル データベースのテーブルと同様)。

exports.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 関数をエクスポートしています。これは、後でデプロイするときに宣言する関数です。

次に、以下の点を確認します。

  • Google では、HTTP POST リクエストのみを受け付けており、それ以外の場合は、他の HTTP メソッドが許可されていないことを示すステータス コード 405 を返します。
  • Google では、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 レスポンスが返されます。

import 関数の実行とテスト

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

$ 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 に保存されているかどうかを確認することもできます。

d6a2b31bfa3443f2.png

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

クラウドに関数をデプロイする

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

$ gcloud functions deploy bulk-import \
         --trigger-http \
         --runtime=nodejs12 \
         --allow-unauthenticated \
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

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

数分以内に、関数はクラウドにデプロイされます。Cloud Console UI に、関数が表示されます。

c3156d50ba917ddd.png

デプロイ出力には、特定の命名規則(https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME})に沿った関数の URL が表示されます。また、この HTTP トリガー URL は、Cloud Console UI のトリガータブ:

2D19539de3de98eb.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 コードが無効である場合。

/books/{isbn} を削除

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

本文ペイロード: なし。

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

戻り値: なし。

ステータス コード:

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

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

コードを確認する

Dockerfile

まず、アプリケーション コードをコンテナ化する役割を担う Dockerfile を見てみましょう。

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

Node.JS 14 の "slim" イメージを使用しています。Google では /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": ">= 14.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 を使用することを指定します。

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

  • データベース内の書籍データにアクセスするための Firestore NPM モジュール
  • CORS(クロスオリジン リソース シェアリング)リクエストを処理する cors ライブラリ。REST API は App Engine ウェブ アプリケーション フロントエンドのクライアント コードから呼び出されます。
  • Express のフレームワークは API を設計するためのウェブ フレームワークで、
  • 次に、書籍の 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'],
}));

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 エンドポイントを順に見てみましょう。

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 /booksおよびPOST /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 ヘッダーも設定します。ヘッダーは次のようになります。

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} 戦略を使用して既存のフィールドを新しい値に置き換えます(そうしないと、すべてのフィールドが削除され、ペイロードの新しいフィールドのみが保存されて、以前の更新や最初の作成から既存のフィールドが消去されます)。

また、Location ヘッダーで書籍の URI を指定しています。

  • 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 コンテナ イメージを介して)に関係なく、ある程度のクエリを実行できるようになりました。

  • 新しい書籍を作成します(本文ペイロードに 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 を使用して 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 サービスがリストに表示されていることを再度確認できます。

2021/05/13b0a703b2126.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 と、AJAX リクエスト(クライアント側の Fetch API を使用)を介して API を呼び出すクライアント JavaScript コードを使用します。

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

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

1b7bf64bd327b1ee.png

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

4dd54e4d5ee53367.png

  • データベースからより多くの書籍を読み込むボタン:

4766C796a9d87475.png

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

fb6eae65811c8ac2.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 行は、HTML、CSS、JavaScript のクライアント側コードの場所(public/ フォルダとそのサブフォルダの下)を指しています。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 で実行します。Google では、Express フレームワークに加え、書籍の ISBN コードの検証に 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 が必要です。

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

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 行で、Shelace ウェブ コンポーネント ライブラリ(スクリプトとスタイルシート)をインポートしています。

次の行で、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 = '';

まず、REST API の URL を取得します。これは、最初に app.yaml で設定した環境変数を返す App Engine ノードコードのおかげです。環境変数(/webapi エンドポイント)は、JavaScript のクライアント側コードから呼び出されるので、フロントエンドのコードで 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 から利用できます。

App Engine ウェブ アプリケーションの UI を確認する

次のことができるようになりました。

  • [[More books...]] ボタンをクリックすると、さらに書籍を読み込むことができます。
  • 特定の言語の書籍のみを表示する場合は、その言語を選択します。
  • 選択ボックスの小さなクロスで選択を解除すると、すべての書籍の一覧に戻ります。

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

アプリを保持する予定がない場合は、リソースをクリーンアップしてコストを削減し、プロジェクト全体を削除することでクラウド全体を改善できます。

gcloud projects delete ${GOOGLE_CLOUD_PROJECT}

11. 完了

Cloud Functions、App Engine、Cloud Run を使用して一連のサービスを作成し、さまざまなウェブ 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 データベースを使用しているため、変更が行われたときに表示される書籍データを更新するために、リアルタイム機能を活用します。