チェ・ゲバムラの日記

脱犬の道を目指す男のブログ

【AWS】サーバーレス(API Gateway+Lambda+DynamoDB)でお問い合わせフォームを作った

今年の5月くらいに作ったのだが、そういえばブログにかいてないのを思い出したのでかいておく。

普段サイトを作っていて、ものによってはテレビで紹介されたりバズったりすることもある。
そういうときにCDN置いたりいろいろと対策をすると思うが、その場合静的サイトならS3に置いとけばええやんとなるが、
じゃあフォームとかどうすんねんみたいな話になる。

ということで高負荷でも落ちにくいお問い合わせフォームを作ることにした。

全体の流れ

まずはざっくりこんなイメージになる。
Form -> API Gateway -> Lambda -> DynamoDB

順番は別にないが、今回はちゃんと動くのを一つずつテストしながら作りたいので、後ろから作っていく。
名前とかは任意。

後述もするが、注意点として、作成時点においてはAmazon SESの対応が日本ではまだの為、
リージョンを今回は全てus-east-1とした。


①DynamoDB データ格納用テーブル作成

テーブル名:ContactForm
プライマリキー:id
デフォルト設定の使用:チェックはずす
AutoScaling:なし プロビジョニングキャパシティを読み書き各1にする
テーブル作成、ARNをコピーしておく。

●項目追加
id1 String:1
firstname String:鈴木

  1. ボタンを押してAppend、Stringで追加可能。

ただこのままの状態だとid変えない限りはfirstnameを変えても上書きされてしまう。
この辺はRDBMSと大きく違う点。
自分で自動インクリメントを定義しなければならない。

②DynamoDB シーケンス番号カウント用のテーブルを作成

アトミックカウンタを使う
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/GettingStarted.NodeJs.03.html#GettingStarted.NodeJs.03.04

テーブル名:ContactFormSeq
●項目追加
table_name:ContactForm
seq:0

③Lambdaにプログラム記載
(SとかNとかつけなくていいDocumentClientを使う方法にした)
※注意
attribute_updatesは非推奨のため、UpdateExpressionを使う。

var AWS = require("aws-sdk");
var docClient = new AWS.DynamoDB.DocumentClient({region:'us-east-1'});

exports.handler = function(event, context) {
    //シーケンス番号更新
	var response = {
		TableName:'ContactFormSeq',
		Key:{
			"table_name":'ContactForm',
		},
		UpdateExpression:"set seq = seq + :val",
		ExpressionAttributeValues:{
			":val":1
		},
		ReturnValues:"UPDATED_NEW"
	};
	docClient.update(response, function(err, data) {
        if (err) {
            console.error("Unable to update item. Error JSON:", JSON.stringify(err, null, 2));
        } else {
            console.log("UpdateItem succeeded:", JSON.stringify(data, null, 2));
            //console.log(data['Attributes']['seq']);
            var nextnum = JSON.stringify(data['Attributes']['seq'], null, 2);
            //Save to DynamoDB フォームから送信された値をDBに保存
            var item = {
                'id':nextnum,
                'firstname':decodeURIComponent(event.firstname),
                'lastname':decodeURIComponent(event.lastname)
            };
            var param = {
                TableName: 'ContactForm',
                Item:item
            };
            docClient.put(param, function(err, data) {
                if (err) {
                    console.log("失敗" + err);
                } else {
                    console.log("成功");
                }
            });
        }
    });    
};
④APIGatewayにMapping Templateを設定(Velocityテンプレート言語(VTL))

AWSAPI Gateway,LambdaはどちらもJSON形式の入力を期待しているため、設定が必要。

APIマッピングテンプレートはapplication/x-www-form-urlencodedで間違いない
→POSTされてくるデータはURLの後ろにパラメータがくっついてくるので、これにすることでAPI側で受け入れられる。
逆にこれにしないとAPIに対してのPOSTは失敗する。
マッピング時に空白考慮してエンコードしてる

設定方法
1. Amazon API Gatewayを適当な名前で作成(ContactForm)
2. 好きなResourceに対してPOSTメソッドを作成
3. Integration type(統合タイプ) に "Lambda function" を選択し、Lambda functionがあるregionを指定
4. 作成後、"Integration Request" → "Mapping Templates" → "Add mapping template"とクリック
5. Content-Type に "application/x-www-form-urlencoded" を設定し"Mapping template"として下記を保存
6. 適当な名前でAPIをデプロイ。APIのURLを発行してコピー

## convert HTML FORM POST data to JSON for insertion directly into a Lambda function
## get the raw post data from the AWS built-in variable and give it a nicer name
#set($rawPostData = $input.path('$'))
## first we get the number of "&" in the string, this tells us if there is more than one key value pair
#set($countAmpersands = $rawPostData.length() - $rawPostData.replace("&", "").length())
## if there are no "&" at all then we have only one key value pair.
## we append an ampersand to the string so that we can tokenise it the same way as multiple kv pairs.
## the "empty" kv pair to the right of the ampersand will be ignored anyway.
#if ($countAmpersands == 0)
 #set($rawPostData = $rawPostData + "&")
#end
## now we tokenise using the ampersand(s)
#set($tokenisedAmpersand = $rawPostData.split("&"))
## we set up a variable to hold the valid key value pairs
#set($tokenisedEquals = [])
## now we set up a loop to find the valid key value pairs, which must contain only one "="
#foreach( $kvPair in $tokenisedAmpersand )
 #set($countEquals = $kvPair.length() - $kvPair.replace("=", "").length())
 #if ($countEquals == 1)
  #set($kvTokenised = $kvPair.split("="))
  #if ($kvTokenised[0].length() > 0)
   ## we found a valid key value pair. add it to the list.
   #set($devNull = $tokenisedEquals.add($kvPair))
  #end
 #end
#end
## next we set up our loop inside the output structure "{" and "}"
{
#foreach( $kvPair in $tokenisedEquals )
  ## finally we output the JSON for this pair and append a comma if this isn't the last pair
  #set($kvTokenised = $kvPair.split("="))
 "$kvTokenised[0]" : #if($kvTokenised[1].length() > 0)"$kvTokenised[1]"#{else}""#end#if( $foreach.hasNext ),#end
#end
}

※以下記事より引用させていただきました。VTL全然わかんないです。
https://qiita.com/tmiki/items/32654e85a925bb841f7d

⑤IAMロール > 権限付与

今回必要なものはLambdaがCloudWatch権限必要ぽいのと、
DynamoDBへの書き込み権限は最低限必要なのでそのへんを管理画面で付与する。
今後のことも考えて一応DB読み込み権限も付与だけしておく。

JSONでは下記。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem",
                "dynamodb:GetItem",
                "dynamodb:Scan",
                "dynamodb:Query",
                "dynamodb:UpdateItem"
            ],
            "Resource": [
                "arn:aws:dynamodb:us-east-1:xxxxxxxxxxxxxxxxxxxx:table/ContactForm",
                "arn:aws:dynamodb:us-east-1:xxxxxxxxxxxxxxxxxxxx:table/ContactFormSeq"
            ]
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:CreateLogGroup",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}
⑥HTMLフォームを作成

ここで作成したAPIのURLをPOST先として指定する
http://hogehoge.com/aws/serverless-form.html

<form action="https:/xxxxxxxxxxxxxxxxxxxxxx-1.amazonaws.com/stage/contactform" method="POST">
  <h2>お問い合わせフォーム</h2>
  <div class="form-group">
    <label></label>
    <input type="text" name="firstname" cols="40" rows="5" class="form-control">
  </div>
  <div class="form-group">
    <label></label>
    <input type="text" name="lastname" cols="40" rows="5" class="form-control">
  </div>
  <button type="submit" class="btn btn-default">送信</button>
</form>
⑦Lambdaで適当にテストを書く
{
  "firstname": "name",
  "lastname": "aaa"
}

※公式参考 putItemのSyntax SはStringなど
http://docs.aws.amazon.com/cli/latest/reference/dynamodb/put-item.html


⑧完了ページへリダイレクト

このままだとDBに登録後、null値がブラウザに返って来て意味不明なので修正する。
API Gatewayの処理を変更
メソッドレスポンス>既存の200レスポンスを削除、代わりに302レスポンスを登録。
レスポンスヘッダーに「Location」と入力。

統合レスポンス>既存の200レスポンスを削除、代わりに302レスポンスを選択。
レスポンスヘッダー Location
マッピングの値 integration.response.body.location
としてAPIをデプロイ。

Lambdaのputitem成功時の処理に下記を追記
context.succeed({location:"http://hogehoge.com/thanks/"});
(URLは適当。実際に使う際にはテーブル名なども変えるのでここも任意に変える必要あり)

⑨リダイレクトテスト

http://hogehoge.com/aws/serverless-form.html
から送信すると、DBに登録後、設定したURLにリダイレクトされる。

⑩サンクスメール送信

お問い合わせフォームなので、登録されたメアドへサンクスメールを送信する。
SESは現在東京リージョンでサポート外のため、米国東部バージニア北部を使う。

SESの制限
SESにアカウントを作成すると、サンドボックス内に作成され、以下の制限がかかる。
・E メールの送信先は、検証済み E メールアドレスおよびドメイン、または Amazon SES メールボックスシミュレーターに制限される。
・E メールは、検証済み E メールアドレスまたはドメインからのみ送信可能。

このままではサンクスメールが送れない。
※管理者宛のメールならメールアドレスがわかるはずなのでデフォルトで可能。

サンドボックス外への移動
下記の手順に従い、AWSサポートセンターに制限解除の依頼が必要。
1営業日程度かかる。
https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/request-production-access.html

⑪時刻をDBに保存
var dt = new Date();
            //日本時間に変換
            dt.setTime(dt.getTime() + 32400000); // 1000 * 60 * 60 * 9(hour)
            // 日付を数字として取り出す
            var year  = dt.getFullYear();
            var month = dt.getMonth()+1;
            var day   = dt.getDate();
            var hour  = dt.getHours();
            var min   = dt.getMinutes();
            var sec   = dt.getSeconds();
            
            // 値が1桁であれば '0'を追加 
            if (month < 10) {
                month = '0' + month;
            }
            
            if (day   < 10) {
                day   = '0' + day;
            }
            
            if (hour   < 10) {
                hour  = '0' + hour;
            }
            
            if (min   < 10) {
                min   = '0' + min;
            }
            
            if (sec   < 10) {
                sec   = '0' + sec;
            }
            
            // 出力
            var Date_now = year + '-' + month + '-' + day + ' ' + hour + ':' + min + ':' + sec;
            
            //半角スペースが+になるのでデコード後に調整
            var firstname = decodeURIComponent(event.firstname).replace('+', '');
            var lastname = decodeURIComponent(event.lastname).replace('+', '');
            var email = decodeURIComponent(event.email).replace('+', '');
            var body = decodeURIComponent(event.firstname).replace('+', ' ');
        
            var item = {
                'id':nextnum,
                'firstname':firstname,
                'lastname':lastname,
                'email':email,
                'body':body,
                'insert_date':Date_now
       };

最後に Lambda全ソース

ごちゃっとしたので念のためまとめておく。

var AWS = require("aws-sdk");
var docClient = new AWS.DynamoDB.DocumentClient({region:'us-east-1'});
var ses = new AWS.SES();

exports.handler = function(event, context, callback) {
    //シーケンス番号更新
	var response = {
		TableName:'ContactFormSeq',
		Key:{
			"table_name":'ContactForm',
		},
		UpdateExpression:"set seq = seq + :val",
		ExpressionAttributeValues:{
			":val":1
		},
		ReturnValues:"UPDATED_NEW"
	};
	
	docClient.update(response, function(err, data) {
        if (err) {
            console.error("Unable to update item. Error JSON:", JSON.stringify(err, null, 2));
        } else {
            console.log("UpdateItem succeeded:", JSON.stringify(data, null, 2));
            var nextnum = JSON.stringify(data['Attributes']['seq'], null, 2);
            //Save to DynamoDB フォームから送信された値をDBに保存
            var dt = new Date();
            //日本時間に変換
            dt.setTime(dt.getTime() + 32400000); // 1000 * 60 * 60 * 9(hour)
            // 日付を数字として取り出す
            var year  = dt.getFullYear();
            var month = dt.getMonth()+1;
            var day   = dt.getDate();
            var hour  = dt.getHours();
            var min   = dt.getMinutes();
            var sec   = dt.getSeconds();
            
            // 値が1桁であれば '0'を追加 
            if (month < 10) {
                month = '0' + month;
            }
            if (day   < 10) {
                day   = '0' + day;
            }
            if (hour   < 10) {
                hour  = '0' + hour;
            }
            if (min   < 10) {
                min   = '0' + min;
            }
            if (sec   < 10) {
                sec   = '0' + sec;
            }
            
            // 出力
            var Date_now = year + '-' + month + '-' + day + ' ' + hour + ':' + min + ':' + sec;
        
            //半角スペースが+になるのでデコード後に調整
            var firstname = decodeURIComponent(event.firstname).replace('+', '');
            var lastname = decodeURIComponent(event.lastname).replace('+', '');
            var email = decodeURIComponent(event.email).replace('+', '');
            var body = decodeURIComponent(event.firstname).replace('+', ' ');
        
            var item = {
                'id':nextnum,
                'firstname':firstname,
                'lastname':lastname,
                'email':email,
                'body':body,
                'insert_date':Date_now
            };
            var param = {
                TableName: 'ContactForm',
                Item:item
            };
            docClient.put(param, function(err, data) {
                if (err) {
                    console.log("失敗" + err);
                } else {
                    console.log("成功");
                    context.succeed({location:"http:/hogehoge.com/aws/serverless-thanks.html"});
                }
            });
        }
    });

    
    //サンクスメール
    var eParams = {
        Destination: {
            ToAddresses: [
                decodeURIComponent(event.email)
            ],
            BccAddresses: [
            ], 
            CcAddresses: [
            ]
        },
        Message: {
            Body: {
                Text: {
                    Data: decodeURIComponent(event.firstname).replace('+', '') + "様\n" + "この度はお問い合わせありがとうございます。\n確認後、担当者よりご連絡させて頂きます。"
                }
            },
            Subject: {
                Data: "お問い合わせありがとうございます。"
            }
        },
        Source: "xxxx@hogehoge.com"
    };

    console.log('===SENDING EMAIL===');
    var abc = ses.sendEmail(eParams, function(err, data){
        if(err){
            console.log("===EMAIL ERR===");
            console.log(err);
            //context.done(null, 'ERR'); 
        }else {
            console.log("===EMAIL SENT===");
            console.log(data);
            //context.done(null, 'SUCCESS');
        }
    });
    console.log("EMAIL CODE END");
    
};

とても参考にさせていただいた記事↓
https://qiita.com/horike37/items/6f2575d6a061216fe019