AWS Lambda・API Gatewayでいいね機能を自前で作る

各記事の評価機能として気軽に「いいね(Like)」ボタンを押してもらうことを考え、実装を試みた。画面ロード時、AjaxからAWSのAPI Gatewayを呼び出した後、AWS Lambdaを実行し、現在の「いいね」数を取得。「いいね」ボタンクリック時、いいね+1をDynamoDBに保存する、というようなプログラムを作る。CORS(Cross-Origin Resource Sharing)のエラーに苦しめられ、デバッグに時間がかかったものの、REST APIを手軽に作ることができる自由を感じた。

完成画面

システム概要図

いいね件数をインクリメントする仕様

DynamoDB(Like)

パーティションキー:PageName(String)→各ページごとのユニークキーとしてページ名とした。
ソートキー:なし
その他カラム:NumberOfLikes(いいね数)、timestamp(更新日時)

Lambda(Like-function/Python3.9)

POSTパラメーター:page_name=xxx.html →自ページの名前をキーにいいね数を保持する。
処理:渡されたpage_nameをキーにDynamoDBから値をget_itemし、取得できなかった場合は「1」をput_itemする。取得できた場合は+1してput_itemする。

API Gateway(LikeAPI/REST API)

リソース:/like
メソッド:POST
注意点:CORSの有効化が必要

いいね件数を取得する仕様

Lambda(Get-like-function/Python3.9)

GETパラメーター:page_name=xxx.html
処理:渡されたpage_nameをキーにDynamoDBから値をget_itemし、取得できなかった場合は「0」を返す。取得できた場合はその値を返す。

API Gateway(GetLikeAPI/REST API)

リソース:/getlike
メソッド:GET
注意点:CORSの有効化が必要

DynamoDB テーブルの作成

テーブル名とパーティションキーを入力してテーブルを作成する。

Lambdaの実装

Lambda Like-functionの作成

「一から作成」でLike-FunctionをPython3.9で作成する。

LambdaにDynamoDBへのアクセスを許可する。「アクセス権限」→ロール名クリック→「アクセス許可を追加▲」→「ポリシーをアタッチ」→「DynamoDB」でフィルター→「AmazonDynamoDBFullAccess」を選択→「ポリシーをアタッチ」ボタンをクリック。

「設定」→「一般設定」→「編集」ボタンクリックで、「タイムアウト」を10秒にしておく。

紆余曲折を経て以下のソースになる。多少冗長だがわかりやすさを優先した。

import json
import boto3
from datetime import datetime 
import urllib
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

dynamodb = boto3.resource('dynamodb')
dynamodb_like_table = dynamodb.Table('Like')

def lambda_handler(event, context):
    
    cors_domain = 'https://twinkangaroos.com'
    
    likes = 0
    try :
        #example) event['body'] = "page_name=show_like.html"
        param = urllib.parse.parse_qs(event['body'])
        page_name = param['page_name'][0]

    except KeyError:
        return {
            'statusCode': 501,
            'body': json.dumps({
                'error':'Parameters are missing.'
            }),
            'isBase64Encoded': False,
            'headers': {
                'Access-Control-Allow-Headers': 'Content-Type',
                'Access-Control-Allow-Origin': cors_domain,
                'Access-Control-Allow-Methods': 'OPTIONS,POST,GET'
            },
        }
    
    try:
        exist_data = dynamodb_like_table.get_item(
            Key={
                'PageName': page_name
            }
        )
        likes = exist_data['Item']['NumberOfLikes']
    except KeyError:
        print('Not found data. OK. Insert new data.')
    except Exception as e:
        print('Select DB Error!!!')
        print(e)
        return {
            'statusCode': 502,
            'body': json.dumps({
                'error':'Select error has occurred.'
            }),
            'isBase64Encoded': False,
            'headers': {
                'Access-Control-Allow-Headers': 'Content-Type',
                'Access-Control-Allow-Origin': cors_domain,
                'Access-Control-Allow-Methods': 'OPTIONS,POST,GET'
            },
        }
    
    try:
        likes += 1
        zone = ZoneInfo("Asia/Tokyo")
        now = datetime.now(zone)
        dynamodb_like_table.put_item(
            Item = {
                'PageName': page_name,
                'NumberOfLikes': likes,
                'timestamp': now.strftime("%Y/%m/%d %H:%M:%S"),
            }
        )
    except Exception as e:
        print('Insert(Update) Error!!!')
        print(e)
        return {
            'statusCode': 503,
            'body': json.dumps({
                'error':'Insert error has occurred.'
            }),
            'isBase64Encoded': False,
            'headers': {
                'Access-Control-Allow-Headers': 'Content-Type',
                'Access-Control-Allow-Origin': cors_domain,
                'Access-Control-Allow-Methods': 'OPTIONS,POST,GET'
            },
        }
        
    return {
        'statusCode': 200,
        'body': json.dumps({
            'likes': str(likes)
        }),
        'isBase64Encoded': False,
        'headers': {
            'Access-Control-Allow-Headers': 'Content-Type',
            'Access-Control-Allow-Origin': cors_domain,
            'Access-Control-Allow-Methods': 'OPTIONS,POST,GET'
        },
    }

ここでのはまりポイントは、Lambda側でAPI GatewayからのPOST値をどのように受け取ればよいのか?という点。この時点ではAPI Gatewayが作成されていないので気づいていない。後ほど詳しく解説する。

Lambda Get-Like-functionの作成

続いて同様の方法で、Like数を取得するLambda関数も作る。GETパラメーターの受け取りについては、AWS Hands-on for Beginners Serverless #1 で実例があったのでそこから拝借する。

import json
import boto3

dynamodb = boto3.resource('dynamodb')
dynamodb_like_table = dynamodb.Table('Like')

def lambda_handler(event, context):
    
    cors_domain = 'https://twinkangaroos.com'
    likes = '0'
    page_name = ''
    
    try :
        page_name = event['queryStringParameters']['page_name']
    except KeyError:
        return {
            'statusCode': 500,
            'body': json.dumps({
                'error': 'Parameters are missing.'
            }),
            'isBase64Encoded': False,
            'headers': {
                'Access-Control-Allow-Headers': 'Content-Type',
                'Access-Control-Allow-Origin': cors_domain,
                'Access-Control-Allow-Methods': 'OPTIONS,POST,GET'
            },
        }
    
    try:
        exist_data = dynamodb_like_table.get_item(
            Key={
                'PageName': page_name
            }
        )
        likes = exist_data['Item']['NumberOfLikes']
        print(likes)
    except KeyError:
        print('Not found data. OK.')
    except Exception as e:
        print('Select DB Error!!!')
        print(e)
        return {
            'statusCode': 500,
            'body': json.dumps({
                'error': 'Select error has occurred.'
            }),
            'isBase64Encoded': False,
            'headers': {
                'Access-Control-Allow-Headers': 'Content-Type',
                'Access-Control-Allow-Origin': cors_domain,
                'Access-Control-Allow-Methods': 'OPTIONS,POST,GET'
            },
        }
    
    return {
        'statusCode': 200,
        'body': json.dumps({
            'likes': str(likes)
        }),
        'isBase64Encoded': False,
        'headers': {
            'Access-Control-Allow-Headers': 'Content-Type',
            'Access-Control-Allow-Origin': cors_domain,
            'Access-Control-Allow-Methods': 'OPTIONS,POST,GET'
        },
    }

API Gatewayの実装

API Gateway LikeAPIの作成

API Gateway から、「REST API」の「構築」ボタンをクリック。

API名を入力し、「APIの作成」ボタンをクリック。

「アクション」→「リソースの作成」で、リソース名(URLのパス)を入力し、「API Gateway CORSを有効にする」をチェックして(ここでチェックしなくても後ほどドメイン指定したCORSの有効化を行うため問題なし)「リソースの作成」ボタンをクリック。

続いて、「/like」を選択した状態で「アクション」→「メソッドの作成」クリック。

プルダウンから「POST」を選択し、チェックをクリック。

「Lambdaプロキシ統合の使用」にチェックを入れ(これを忘れるとPOST値を受け取る方法が変わるので要注意。ただし作成後でも変更できる)、先ほど作成したLambda関数(Like-Function)を選択し、「保存」ボタンをクリック。

「Lambda関数に権限を追加する」→「OK」ボタンクリック。

このような感じになる。ちなみに、わかりづらいが「統合リクエスト」リンクをクリックすると、先ほどの「Lambdaプロキシ統合の使用」にチェックを忘れた場合でもチェックし直すことができる。

次に、twinkangaroos.comのドメインから当APIを呼び出せるようクロスドメインの設定をする。「Like」を選択した状態で、「アクション」→「CORS の有効化」をクリック。

「Access-Control-Allow-Origin」に呼び出すJSを設置するドメイン(今回の場合、https://twinkangaroos.com)を入力し、「CORS を有効にして既存のCORSヘッダーを置換」ボタンをクリック。「メソッド変更の確認」→「はい、既存の値を置き換えます」ボタンをクリック。

「アクション」→「APIのデプロイ」から、「デプロイされるステージ」に「新しいステージ」、「ステージ名」に「dev」など入力し(URLの一部になる)、「デプロイ」ボタンをクリック。

「like」の「POST」を選択すると、JSからPOSTすべきURLが表示されるのでコピーしておく。

API Gateway GetLikeAPIの作成

同様に、いいね数を取得するAPI、GetLikeAPIを作成する。違いはPOSTとGETの違いのみ。

POSTのAPI作成時はPOST値がどのように渡されるのかわからなかったが、GETのパラメータ取得はイメージできるのでテストしてみる。上記の「リソース」→「GET」を選択した画面で左にある「テスト」をクリックする。「クエリ文字列」に「page_name=dummy.html」と入力し、「テスト」ボタンをクリック。

リクエスト結果が返され、正常に完了したことがわかる。

LikeAPIを呼び出すHTML(JS)の実装

API Gatewayを呼び出すHTMLとJSを書く。仕様としては、以下の通り。

画面ロード時(load_like)

・現在のURLからパス(ファイル名)を取得。
・Ajaxでいいね数を取得。
・件数を画面に埋め込み。
・Cookieを取得し、既にLike済みの場合クリック後の画像をセットし、aタグを削除。

いいねクリック時(click_like)

・現在のURLからパス(ファイル名)を取得。
・Cookieに保存。
・Ajaxでいいね数をインクリメント。
・件数を画面に埋め込み。
・クリック後の画像をセットし、aタグを削除。

最終的にはこうなった。

(省略)
<!-- like function -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="/wp-content/themes/twentynineteen/js/like.js"></script>
(省略)
<div>
  <label>
  <div>
    <div style="float:left; height:32px; padding:4px;">
      <a href="#" id="like1">
        <img src="/wp-content/themes/twentynineteen/images/favorite_heart_like.png" id="like_image" width="32" height="32">
      </a>
    </div>
    <div id="likes">0</div>
    <div id="result" style="font-size:12px"></div>
  </div>
  </label>
</div>
<script type="text/javascript">
$(function(){
  load_like();
  $('#like1').click(function(){
    click_like();
    return false;
  });
});
</script>
(省略)

処理を切り出した like.js のfunctionはこちら。

function load_like() {
  var page_name = location.pathname.replace('/', '');
  $.ajax({
    url:'https://xxx.execute-api.ap-northeast-1.amazonaws.com/dev/getlike?page_name=' + page_name,
    type:'GET',
    dataType:'json',
  }).done(function(data){
    $('#likes').html(data.likes);
    // Cookie保存されていれば過去にクリックした→_onし、aタグを削除(押せない)
    const cookies = document.cookie;
    var index = cookies.indexOf(page_name);
    if (index != -1) {
      $('#like1').children('img').attr('src', '/wp-content/themes/twentynineteen/images/favorite_heart_like_on.png');
      $('#like1').contents().unwrap();
    }
  }).fail(function(){
    console.log('Communication failed.');
  });
}

function click_like() {
  var p_name = location.pathname.replace('/', '');
  document.cookie = 'like-' + p_name + '=1';
  $.ajax({
    url:'https://xxx.execute-api.ap-northeast-1.amazonaws.com/dev/like',
    type:'POST',
    dataType:'json',
    contentType:'application/json',
    data:{
      // #example) event['body'] = "page_name=show_like.html"
      "page_name":p_name,
    },
  }).done(function(data2){
    // onし、aタグ削除(クリックは一度きり)
    $('#likes').html(data2.likes);
    $('#like1').children('img').attr('src', '/wp-content/themes/twentynineteen/images/favorite_heart_like_on.png');
    $('#like1').contents().unwrap();
    $('#result').html("ありがとうございました!");
  }).fail(function(data2){
    console.log('Communication failed..');
    //console.log('err=' + JSON.stringify(data2));
    $('#result').html(data2["responseText"]);
  });
}

Ajaxの知識があやふやだったのでPOST値の投げ方を試行錯誤したが、最終、data:{} 内に「”page_name”:p_name」をPOSTで投げる形になる。

当初、いいね数を取得するAPIを実行時、ブラウザのコンソールにて以下のエラーが多発した。

Access to XMLHttpRequest at 'https://xxx.execute-api.ap-northeast-1.amazonaws.com/dev/getlike?page_name=test.html' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

以下のようにheadersに設置するHTMLのドメインを「Access-Control-Allow-Origin」に設定すればよい。

    return {
        'statusCode': 200,
        'body': json.dumps({
            'likes': str(likes)
        }),
        'isBase64Encoded': False,
        'headers': {
            'Access-Control-Allow-Headers': 'Content-Type',
            'Access-Control-Allow-Origin': 'https://twinkangaroos.com',
            'Access-Control-Allow-Methods': 'OPTIONS,POST,GET'
        },
    }

それでもエラーが出る場合、前述した通り、API Gateway側で「CORS の有効化」し、HTML呼び出し側のドメイン(今回の場合、https://twinkangaroos.com)を許可する。

余談だが、API Gatewayをデプロイ後、即時反映されないようなので注意が必要。デプロイから8秒後にHTMLのJSを実行するとCORRエラーが発生したが、20秒後に同じJSを実行すると正常動作した。何度「CORS の有効化」を実行してもエラーのままだったので、トラブルシュートにかなりの時間を費やしてしまった。

各部品の動作確認

HTML内のJS(Ajax)でAPI GatewayのURLを呼び出し、Lambdaが呼ばれ、DynamoDBにput_itemできれば成功、となるのだが、POST値をどのように渡せばよいのか?Lambdaにはどのような形で渡って来るのか?テストを実行しようにも値をどのようにして渡せばよいのか悩んでしまった。API GatewayとLambda、それぞれデバッグしてPOST値を確認することで解決した。

API Gatewayが受け取るPOSTデータ確認

CloudWatchでデバッグ

以下を参考にしながら進める。

■API ゲートウェイ REST API と WebSocket API の CloudWatch ログを有効にする
https://aws.amazon.com/jp/premiumsupport/knowledge-center/api-gateway-cloudwatch-logs/

IAMの左メニューから「アクセス管理」の「ロール」をクリック。

「ロールを作成」ボタンをクリック。

「AWS のサービス」を選択し、

ユースケースの「他の AWS のサービスのユースケース」から「API Gateway」を選択し、「次へ」ボタンクリック。

ポリシー「AmazonAPIGatewayPushToCloudWatchLogs」が追加されているので「次へ」。

ロール名を命名し、「ロールを作成」ボタンをクリック。

作成されたと表示されるので「ロールを表示」ボタンをクリック。

ここの「ARN」をコピーする。

API Gatewayに戻り、左メニューの「設定」から「CloudWatch ログのロール ARN」に先ほどコピーしたロール名をペーストし、「保存」ボタンをクリックする。(※メッセージらしきものが表示されず、保存されたのか不安になるが保存されている)

最後に、「ステージ」で「dev」を選択し、「ログ/トレース」タブをクリックする。「CloudWatchログを有効化」にチェックを入れ、「リクエスト/レスポンスをすべてログ」にもチェックし、「変更を保存」ボタンをクリック。(※費用がかかる旨のヒントがあったため、検証が終わればチェックを外すほうがよいかも)

実際にHTMLからPOSTを実行後、CloudWatchのロググループからログイベントをみると、POST値が見え、Lambda呼び出しもされていることがわかる。

Lambdaが受け取るPOSTデータ確認

CloudWatchでデバッグ

こちらは(わかってみれば)簡単であった。以下の1行をLambdaのソースに貼り付ければよい。

print("event=" + json.dumps(event))

HTMLのJS実行後、Lambdaでのprint文はCloudWatchに記録される。CloudWatchを見ると以下のようにprint文が忠実に出力されている。

JSでPOSTしたところ以下の結果が出力されていた。

2022/03/13 12:55:16 Like-function [info] event={
    "resource": "/like",
    "path": "/like",
    "httpMethod": "POST",
    "headers": {
        "accept": "application/json, text/javascript, */*; q=0.01",
        "accept-encoding": "gzip, deflate, br",
        "accept-language": "ja,en-US;q=0.9,en;q=0.8",
        "content-type": "application/json",
        "Host": "xxx.execute-api.ap-northeast-1.amazonaws.com",
        "origin": "https://twinkangaroos.com",
        "referer": "https://twinkangaroos.com/",
        (省略)
    },
    "multiValueHeaders": {
        "accept": [
            "application/json, text/javascript, */*; q=0.01"
        ],
        (省略)
    },
    "queryStringParameters": null,
    "multiValueQueryStringParameters": null,
    "pathParameters": null,
    "stageVariables": null,
    "requestContext": {
        "resourceId": "31wto5",
        "resourcePath": "/like",
        "httpMethod": "POST",
        (省略)
        },
        "domainName": "xxx.execute-api.ap-northeast-1.amazonaws.com",
        "apiId": "xxx"
    },
    "body": "page_name=test.html",
    "isBase64Encoded": false
}

注目すべきPOST値は以下のように渡ってきていた。

{
  "body": "page_name=test.html",
}

ブラウザでデバッグ

Ajaxで戻ってきた値は以下で確認できる。

}).fail(function(data2){
  console.log('err=' + JSON.stringify(data2));

以下のようにコンソールに出力される。

err={"readyState":4,"responseText":"{\"error\": \"Parameters are missing.\"}","responseJSON":{"error":"Parameters are missing."},"status":501,"statusText":"error"}

API Gateway側でテスト

API Gateway でテストをしてみる。「リソース」→POSTを選択し、「テスト」をクリック。

リクエスト本文に以下を入力して「テスト」ボタンをクリックする。

page_name=show_like.html

Lambda側でテスト

Lambda でテストをしてみる。「イベント JSON」の”body”に以下をセットすればよい。

{
  "body": "page_name=show_like.html",

Lambdaのソースにおいては、event[‘body’] にPOST値 ”page_name=show_like.html” の文字列が入って来るので、urllib.parse.parse_qs()を使い、右辺の値(show_like.html)を取得する。

param = urllib.parse.parse_qs(event['body'])
page_name = param['page_name'][0]

まとめ

HTML(Ajax) → API Gateway → Lambda → DynamoDB の流れを実装できた。それぞれのサービスの実装は比較的穏やかに行えたが、それぞれのサービスが扱うデータがどのように受け渡されるのかをデバッグするのに時間を費やした。また、CORSのエラーにも苦しめられたが、このあたりの基礎知識がなかったのでCORSとは何か?から調べたので時間がかかった。次回はこの likeAPI が呼び出されたら”コンバージョン”としたいため、GTMでイベントを発火させようと思う。

今回想定の検索ワード

”Lambda いいね”