素早くアイデアを実験できるAWS Amplifyを試す(バックエンド編)

こんにちは。GMOグローバルサイン・ホールディングスCTO室の神沼(@t_kanuma)です。

本稿は以下の記事の後編です。シンプルなメモアプリを題材に、AWS Amplifyでサーバレスアプリのバックエンドの構築/開発を試します。

環境概略図

それではバックエンドの構築/開発を始めます。フロントエンドと同様に、amplify-cliのガイドに従いながら進めます。順番としては、DynamoDB → API Gateway/Lambda → Cognitoの順です。

1. DynamoDBの構築

以下のコマンドでDBを構築します。

amplify add storage

下のキャプチャの通り、DynamoDBのテーブル名、パーティションキー、ソートキー、GSI、Streamsの構成を設定します。

amplify-cliのガイド内容から、必要なカラムを全て定義しなくてはいけないと感じるかもしれませんがそうではありません。DynamoDBはスキーマレスですので、パーティションキー、ソートキー、GSI以外のカラムをここで入力する必要はありません。
実際、これら以外のカラムを入力したとしても生成されるCloudFormationテンプレートには反映されません。(このちょっとした分かりにくさはGitHubにIssueとして上がっています。)

ガイドが正常に完了すると、ローカルのプロジェクトにDynamoDBのCloudFormationテンプレートなどが生成されます。

クラウドへプッシュ

以下のコマンドでAWS上にDynamoDBを構築します。

amplify push storage

完了後、以下のAWS CLIコマンドやDynamo DBのコンソールから構築できていることを確認できます。(ちなみに、デフォルトではCapacity ModeはProvisionedで、RCUs/WCUsはそれぞれ5で設定されます。)

aws dynamodb list-tables
aws dynamodb describe-table --table-name memo-prod

データロード

Amplifyから外れてしまいますが、AWS CLIでデータをインサートします。

aws dynamodb batch-write-item  \
    --request-items file://request-items.json
{
    "memo-prod": [
        {
            "PutRequest": {
                "Item": {
                    "userId": {
                        "N": "1"
                    },
                    "title": {
                        "S": "今日やること"
                    },
                    "text": {
                        "S": "お風呂を掃除して掃除機をかけてゴミを出して部屋を片付けて郵便局に行く。"
                    },
                    "favorite": {
                        "BOOL": false
                    },
                    "done": {
                        "BOOL": false
                    },
                    "archived": {
                        "BOOL": false
                    }
                }
            }
        },
        {
            "PutRequest": {
                "Item": {
                    "userId": {
                        "N": "1"
                    },
                    "title": {
                        "S": "明日やること"
                    },
                    "text": {
                        "S": "自転車を点検に出して、ドラッグストアに行ってティッシュペーパーを買う。"
                    },
                    "favorite": {
                        "BOOL": false
                    },
                    "done": {
                        "BOOL": false
                    },
                    "archived": {
                        "BOOL": true
                    }
                }
            }
        },
        // omit (その他レコード)
    ]
}

以下のコマンドやDynamoDBのコンソールからデータを確認できます。

aws dynamodb scan --table-name memo-prod

ここまででDynamoDBの構築ができました。

2. API Gatewayの構築 / Lambda Functionの実装

API GatewayとLambdaの関係

下図のようにリソースを2つ(memos, archives)用意して、リソースごとに1:1になるLambda Functionを用意します。amplify-cliのガイドに従うと、HTTPメソッドはANYで用意されます。Serverless Expressフレームワークを選択して、Function内部でPath+HTTPメソッドごとに関数を用意する形になります。(モノリス的アプローチでLambdaの実装方式としてどうなのか、アンチパターンではないかという気もしますが・・・)

備考:最初に目指した姿

当初はLambdaのGood Practiceに従い、HTTPメソッドをANYにするのを辞めて、下図のようにPath+HTTPメソッドごとにLambda Functionを用意しようと思いました。LambdaテンプレートにHello Worldを選択し、CloudFormationテンプレートを手修正してトライしてみましたが上手くいかず、しかも一度構築した後にamplify update apiで更新すると、手修正の部分が上書きされて元に戻ってしまうことも分かりました。ここは大人しくガイドに従うことにします。

API Gatewayの構築

以下のコマンドでAPI Gatewayを1つと、その裏のLambda Functionの雛形を2つ作ります。

amplify add api

Advanced Settingsの部分で、上で構築したDynamoDBのCRUD権限を設定しています。Lambda Layerは今回は利用しません。また、Lambdaの環境変数およびシークレット値(AWS Systems Manager Parameter Store)の設定は(amplify update apiコマンドで)後でも出来ると考え何も設定していません。

最後の部分で、この時点ではAPI Key/Resource Policy/CORSなどによるアクセス制御、およびIAM/Lambda Authorizer/Cognitoによる認証認可についても何も設定していません。(CORSについてはデフォルトで有効化されます。プリフライトのOPTIONメソッドが任意のorigin、またAuthorizationなどいくつかのカスタムheaderを許容する形で生成されます。また後述するLambda Functionテンプレートで各種HTTPメソッドに対応するCORSの設定が生成されます。任意のoriginとカスタムheaderを許容するコードがテンプレートに組み込まれます。)

Lambda Function(memosFunction)の実装

// app.js

/* Amplify Params - DO NOT EDIT
    ENV
    REGION
    STORAGE_DYNAMO001_ARN
    STORAGE_DYNAMO001_NAME
    STORAGE_DYNAMO001_STREAMARN
Amplify Params - DO NOT EDIT */

var express = require("express");
var bodyParser = require("body-parser");
var awsServerlessExpressMiddleware = require("aws-serverless-express/middleware");

// declare a new express app
var app = express();
app.use(bodyParser.json());
app.use(awsServerlessExpressMiddleware.eventContext());

// Enable CORS for all methods
app.use(function (req, res, next) {
  res.header("Access-Control-Allow-Origin", "*");
  res.header("Access-Control-Allow-Headers", "*");
  next();
});

const AWS = require("aws-sdk");
const docClient = new AWS.DynamoDB.DocumentClient();

app.get("/memos", async function (req, res) {
  const params = {
    ExpressionAttributeValues: {
      ":archived": false,
    },
    FilterExpression: "archived = :archived",
    TableName: process.env.STORAGE_DYNAMO001_NAME,
  };

  try {
    const memos = await docClient.scan(params).promise();
    return res.status(200).json(memos.Items);
  } catch (err) {
    res.status(500).json({ error: err });
  }
});

app.post("/memos", async function (req, res) {
  // omit...
});

app.post("/memos/:id", async function (req, res) {
  // omit...
});

app.put("/memos/:id", async function (req, res) {
  // omit...
});

module.exports = app;

クラウドへプッシュ

API GatewayとLambda FunctionをAWSにデプロイします。

amplify push api

CLIやコンソールからリソースを確認できると思います。

ここまでの検証

前述の通り、この時点ではアクセス制御/認証認可の設定はしておらず、APIはパブリックな状態です。ローカルPCからAPIクライアントツールでAPIを呼び出してみます。

正常に呼び出すことができました。
ここまででAPI GatewayとLambda Functionの構築ができました。

備考: ローカル環境での動作

現時点で(2022/1/13)Amplifyは、APIがGraphQL(AppSync)であれば背後のLambdaとDynamoDB含めて、丸ごとローカルで動作させることが可能のようです。(基盤としてJavaを必要とします。Dockerではないようです。)しかし、RESTは対応していないようです。Lambda Function単体をローカルで動作させることもできるようですが、その場合はAmplify外でDynamo DB Localを構築する必要があるようにも見えます。

また公式Doc上に、以下の記載もあります。ここはスルーして、AWS上で動作させることにしました。

Testing with amplify mock function should be used to get quick feedback on the correctness of your function but should not be used as a substitute for testing in a cloud development environment.

3. Cognitoで認証認可の構築

CognitoにUser Poolを構築し、サインアップ/サインイン後にJWTを発行してもらい認可処理を行います。既にサインアップ/サインイン画面を用意しているため、Amplify提供のPre-Built UIではなく、マニュアルでAmplifyライブラリのAuthクラスをコールする実装にします。

ユーザープールの構築

amplify add auth

とりあえずデフォルト構成でガイドを完了しPushしました。

当初は、Cognitoがログイン画面(ドメイン)を用意してくれて、そこからOAuth2の認可コードGrantのフローで進むのかと想像していたのですが、デフォルト構成ではそこまで設定されません。ログイン画面はあくまでフロントエンド側でホストされ、パスワード認証後に発行されるアクセストークンで認可するもっとシンプルなフローのようです。

(フロントエンド側は当然サードパーティアプリではなくオリジナルのアプリそのもので、ユーザ名/パスワードを知ることができても何ら問題がない。またフロントとバックが密結合になっているAmplify開発の特性上、このバックエンドAPIはこのフロントエンド専用のモノであり他にクライアントアプリを作ることはない。これらの考え方からOAuthの必要性は無く、この形なのだと理解しています。)

API Gatewayとの統合

どうやら少なくともREST APIにおいてマニュアルでAuthクラスを呼び出す場合は、amplify-cliでAPI GatewayとCognitoの連携はできないように見えます。API Gatewayのコンソールから連携させて、Cognitoが発行するアクセストークン(JWT)を検証するようにします。

先ほど成功したAPIアクセスが、アクセストークンなしでは401になることが確認できました。

コンソールからマニュアルで変更しまいましたが、これは果たして大丈夫なのか・・・。試しにaws-cliでAPIに変更を加えてPushし、上記のCognitoの変更がどうなるかを確認して見たところ、やはり変更が上書きされて無くなってしまいました。ここは個人的に課題を残す結果になりました。(GraphQL API(AppSync)だと話が変わる?)

フロントエンド側のコード修正

Amplifyライブラリのインストールとmain.jsの修正

公式Docの通りのため、コードは割愛します。

サインアップ/サインイン/サインアウト処理

ここも公式Docの通りのため、コードは割愛します。注意点が3つあります。

  • Cognitoユーザプールのカスタム属性を使う場合は、ここに記載の通りサインアップ処理前にコンソールからカスタム属性を追加しておく必要があります。(今回はカスタム属性にuserIdを追加しています。)
  • MFAをオフにしていても、サインアップでは検証コードがメール通知されました。Auth.confirmSignUp(..)で、もしくはCognitoコンソールからユーザーをActivateする必要があります。
  • ここに記載の通り、AuthクラスからではなくCognitoコンソールからユーザーを登録した場合は、Auth.sigIn(..)の呼び出しで認証が完了しません。レスポンスのchallengeNameはNEW_PASSWORD_REQUIREDで、sessionはnullで返ってきます。この場合、さらにAuth.completeNewPassword(..)をコールする必要があります。コンソールでユーザー登録した時点ではユーザ情報やトークンの取得ができません。

リソースアクセス処理

Authクラスは、Auth.siginIn(..)時にLocalStorageにJWTを保管します。そしてAuth.siginOut(..)時に LocalStorageからJWTを削除します。LocalStorageから取り出してAPIコールしてもいいですが、ここでは試しに直前にCognitoから取得する形でリソースアクセスしてみます。

ちなみに、このIssueでもAmplifyユーザが嘆いているように、Amplify/CognitoではJWTをCookieにhttpOnlyで保管する仕組みがないようです。(すなわち、悪意のあるプロジェクト内ライブラリからのXSS対策ができない。)

また、Amplifyにおけるリソースアクセスの実装方式の通例はAmplifyライブラリのAPIクラスを使うことだと思いますが、ここでは後述するフロントエンドの環境変数設定を試したく、あえてFetch APIでアクセスしてみます。

  methods: {
    async showMemos() {
      try {
        const jwt = (await Auth.currentSession()).getIdToken().getJwtToken();
        const response = await fetch(
          `${process.env.VUE_APP_API_BASE_URL}/memos`,
          {
            cache: "no-store",
            method: "GET",
            mode: "cors",
            headers: {
              "Content-Type": "application/json",
              Authorization: `Bearer ${jwt}`,
            },
          }
        );
        this.memos = await response.json();
        // omit...
      } catch (error) {
        // TODO 401でログイン、その他はエラー画面に遷移
        console.log(error);
      }
    },

前述のLambda Functionコードの修正

DB検索条件に、JWTのClaimの1つであるuserIdを追加します。
これはCognitoで追加したカスタム属性です。

またuserIdはDynamoDB構築時にパーティションキーに設定していました。
DB読み取りメソッドをscan(..)からquery(..)に修正して(今のデータ内容だと変わりないですが)検索性能を向上させます。

// omit...
app.get("/memos", async function (req, res) {
  const params = {
    ExpressionAttributeValues: {
      ":archived": false,
      ":userId": parseInt(
        req.apiGateway.event.requestContext.authorizer.claims["custom:userId"],
        10
      ),
    },
    KeyConditionExpression: "userId = :userId",
    FilterExpression: "archived = :archived",
    TableName: process.env.STORAGE_DYNAMO001_NAME,
  };

  try {
    const memos = await docClient.query(params).promise();
    return res.status(200).json(memos.Items);
  } catch (err) {
    res.status(500).json({ error: err });
  }
});
// omit...

ここまでで構築/開発作業が一通り完了しました。

4. 最終的な検証

フロントエンドの環境変数設定

Ampifyコンソールからフロントエンド環境変数にバックエンドのエンドポイントを設定します。

buildspecも修正します。(赤枠を追加します。)

フルスタックでのCI/CDパイプラインの実行

ローカルGitにamplifyディレクトリと修正したフロントエンド側コードをコミット後、GitHubにPushしてフルスタックのパイプラインを実行します。バックエンドのビルドの実行ログを確認すると、クラウドの現環境とコミットしたCloudFormationテンプレートとの差分を見て、クラウド環境をアップデートしているように見えます。

ブラウザからの検証

上記完了後にブラウザからホストされたドメインにアクセスします。サインアップ/サインイン/サインアウト/リソースアクセスに問題がないことを確認できました。

終わりに

以上のように手数をかけずに素早くサーバレスな、すなわち定型的な運用作業を必要とせず、スケールに応じたコスト最適化が成された、スケーラブルでアベイラブルなバックエンド環境を構築することができました。

CypressでのE2Eテストを追加してCI/CDパイプラインの質を高めて、さらにAnaliyticsカテゴリでAmazon Pinpointを構築しユーザー行動をコホート分析するなどして、市場からのフィードバックループを回していくことができるのではと考えます。

また今回は単純な環境でしたが、複数のGitブランチ/複数のバックエンド環境を用いて複数人で開発する際はこのドキュメントが役立ちそうです。

どなたかのお役に立てたならば幸いです。