こまぶろ

技術のこととか仕事のこととか。

WebページからDynamoDBにアクセスしてみる〜はじめてのAjax通信とDOM操作〜

前回のLINE Bot作成に引き続き、AWS関連です。Webアプリ側に寄せています。

ky-yk-d.hatenablog.com

前回やったこと

今回やること

  • Lambdaの裏側にDynamoDBを置く(前回はLambda側に全て記載されていた)
  • WebページからJavaScriptAPI GatewayAPIを叩く
  • 新たなデータを作成する/取得した結果を表示する

LINEボットを作成して、 API GatewayからLambda関数を呼び出す流れはわかりました。 前回は、API Gatewayの呼び出しはLINE側で用意されていましたが、今回はWebページからJavaScriptで呼び出してみます。また、 フレームワークを使ったWebアプリを作成した(編集した)経験はあるのですが、クライアントサイドJavaScriptを書いた経験はほぼゼロです。自分でWebアプリを作れるようになるには、クライアントサイドJSは避けて通れませんので、今回はその初歩をやってみました。

図示するほどのものではないのですが、憧れがあったので構成図を。素材は以下から。 ちなみに、PowerPointでペタペタ貼り付けてスクショ撮ったんですけど、色々間違っている気がします。

f:id:ky_yk_d:20180520201851p:plain
アーキテクチャ構成図(書いてみたかった)

API Gateway〜DynamoDBの設定は下記の書籍(198頁以下)を参照しました。

Amazon Dynamo DB の設定

テーブルを作成します。以上!

・・・

というわけには流石にいきませんが、リレーショナルデータベースと異なりスキーマを設定する必要がないので、確実に設定しなければならないのはテーブル名とプライマリーキーくらいです。

テーブル名はSAMPLE_TASKS、プライマリーキーはIDとしました。普段(リレーショナルDBでの)テーブル名やカラム名は大文字表記の場合が多いので、書籍の指示を無視して大文字にしたのですが、画面との連携を考えると小文字の方が良かったと思います(そして見返して思いましたがtasknameは小文字にしてますね・・・)

f:id:ky_yk_d:20180520233151p:plain
DynamoDB(データを入れた後の状態)

AWS Lambda の設定

Lambdaで設定する内容は以下の2点です。

  • DynamoDBFullAccessを付与したロールを割り当てる
  • 関数を記載する

今回は、LambdaからDynamoDBにアクセスするので、DynamoDBに対する読み書きの権限を有するロールをLambda関数に割り当てる必要があります。そこで、Lambda関数の作成の前にIAMロールを作成します。ロール作成画面で、「信頼されたエンティティの種類」でLambdaを、次の画面の「アクセス権限ポリシー」でDynamoDBFullAccessを選択しましょう。

f:id:ky_yk_d:20180520201842p:plainf:id:ky_yk_d:20180520201846p:plain

上記の手順でIAMロールを作成できたら、Lambda関数の作成です。「一から作成」を選択し、先程のロールを割り当てて作成します。今回は、ランタイムにはNode.js 8.10を選択しました。

Lambda関数の中身は今回はこのファイルひとつです。GETとPUTの両方をこの関数で受け付けます。

index.js

const AWS = require('aws-sdk');
const dynamo = new AWS.DynamoDB.DocumentClient();

exports.handler = (event, context, callback) => {
    const operation = event.method;  // API Gatewayのマッピングテンプレートで指定した"method"要素
    let params;
    switch (operation) {  // いずれのリクエストかによって分岐させる
        case 'GET':
            params = {
                TableName: "SAMPLE_TASKS", 
            };
            dynamo.scan(params, callback);
            break;
        case 'PUT':
            params = {
                TableName: "SAMPLE_TASKS",
                Item: {
                    "ID": event.body.Item.id,  //  "ID"はDynamoDBのカラム(?)名、"Item.id"は送信するJSON内の要素名
                    "taskname": event.body.Item.taskname
                }
            };
            dynamo.put(params, callback);
            break;
        default:
            callback('Unknown operation: ${operation}');
    }
};

Amazon API Gateway の設定

API Gatewayでは以下の手順で作業を行います。

  • APIの作成(sample_crud_tasks
  • リソースの作成(sample_tasks
  • GETメソッドとPUTメソッドを作成し、Lambda関数と紐づける
  • GETメソッドとPUTメソッドの双方の「統合リクエスト」に本文マッピングテンプレートを指定
  • ステージにデプロイ

f:id:ky_yk_d:20180520205451p:plainf:id:ky_yk_d:20180520220121p:plain
作成したAPI(GETメソッド) ※OPTIONはのちの過程で自動的に作成される

本文マッピングテンプレートを設定するところについては説明が必要と思われます。「統合リクエスト」をクリックして開く画面の最下部に「本文マッピングテンプレート」という項目があるので、Content-Typeの欄にはapplication/jsonと記載して追加します。上記の2番目のような画面になります。

本文マッピング・テンプレート(GET, PUT共通)

{
    "method": "$context.httpMethod",
    "body": $input.json('$')
}

送られてきたHTTPリクエストの内容を、受け手側のデータにマッピングして渡すもののようです。 $context.httpMethod は一見してわかるように使用されたHTTPメソッド名で、画面側のJavaScriptxhr.open()の第一引数で指定しているものです。Lambda側ではevent.methodとして参照されています。どのメソッドを使用したかによってLambda側で処理を変えるためのものですね。

$input.json('$')は入力されたxhr.send()の引数で渡した内容(=リクエスト本文)をJSON形式で読み取っています。こちらも、Lambda側でevent.bodyとして参照され、DynamoDBに登録するデータの取り出しに使われています。

API Gatewayマッピングテンプレートのリファレンスはこちら

API Gateway のマッピングテンプレートリファレンス - Amazon API Gateway

ここまでで、DynamoDBのためのAPIは完成です。

Webページ(HTML & JavaScript)の作成

さて、ここまでは専らAWSのお話(『実践AWS Lambda』に記載の内容)でした。ここからは作成したAPIをWebページから呼び出します。呼び出しただけではしょうがないので、以下の2つを実装します。

  • 簡単な入力フォームを用いたデータの追加(PUTなので同じIDなら更新になる)
  • DynamoDBのデータのリスト表示

使用する技術としては、以下のようなものです。

僕は「DOMってなに?モビルスーツ?」というレベルなので、極めて初歩的な内容となっています。また、jQueryなどを用いればAjaxはもっと簡潔に実装できるのだと思うのですが、色々なところから「jQueryはもう・・・」という声が聞こえてきています(例:You-Dont-Need-jQuery)。研修のときにjQueryがモダンなのだという話を聴いた気がしたのですが。

今後の開発のなかでjQueryを使いたくなるようなことがあれば学ぶことはやぶさかではないのですが、今回はJavaScriptの勉強をするという観点からも、ピュアJSで記述しました。また、HTMLファイルとJSファイルを別に作成すべきところなのでしょうが、今回はHTMLファイルに<script>タグで埋め込んでしまいました。どこかのタイミングで切り離します。ソースコードに重複も多いですし、リファクタをちゃんと考えてみたいです。

f:id:ky_yk_d:20180520233428p:plainf:id:ky_yk_d:20180520233431p:plainf:id:ky_yk_d:20180520233434p:plainf:id:ky_yk_d:20180520233436p:plain
こんな感じになる(大きさバラバラ・・・)

なお、今回のコードを実装するにあたっては、下記を参照しました。

tomosoft.jp

それでは実際のコードです。

index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>Page Title</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
    <form>
        <input id="ID" type="text" placeholder="IDを入力">
        <input id="taskname" type="text" placeholder="タスク名を入力">
        <input id="submit" type="button" value="送信">
    </form>
    <hr/>
    <div id="status"></div>
    <form>
        <input id="btn" type="button" value="取得">
    </form>
    <hr/>
    <div id="result"></div>
    <script>
        document.addEventListener('DOMContentLoaded', function(){
            document.getElementById('submit').addEventListener('click',function(){
                var status = document.getElementById('status');
                var xhr = new XMLHttpRequest();
                xhr.onreadystatechange = function(){
                    if (xhr.readyState === 4){ // 通信完了
                        if (xhr.status === 200) { // 通信成功
                            status.textContent = '送信しました';
                        } else {
                            status.textContent = 'サーバーエラーが発生しました';
                        }
                    } else {
                        status.textContent = '送信中';
                    }
                };
                var obj = {
                    Item : {
                        id: document.getElementById('ID').value,
                        taskname: document.getElementById('taskname').value
                    }
                };
                var json = JSON.stringify(obj);
                xhr.open('PUT', 'https://l3uk6hufcf.execute-api.ap-northeast-1.amazonaws.com/prod/sample-tasks', true);
                xhr.setRequestHeader('Content-Type', 'application/json');
                console.log(json);
                xhr.send(json);
            })
        })
    </script>

    <script>
        document.addEventListener('DOMContentLoaded', function(){
            // 取得ボタンクリック時に実行される
            document.getElementById('btn').addEventListener('click', function(){
                var result = document.getElementById('result');
                var xhr = new XMLHttpRequest();
                // 非同期通信時の処理
                xhr.onreadystatechange = function(){
                    if (xhr.readyState === 4){ // 通信完了
                        if (xhr.status === 200) { // 通信成功
                            //var data = JSON.parse(xhr.responseText);
                            var data = xhr.response;
                            // 結果からキーにアクセス
                            if (data === null) {
                                // ない場合はメッセージ
                                result.textContent = 'データが存在しません';
                            } else {
                                // 取得できた場合
                                console.log(typeof(data));
                                console.log(data)
                                var items = data.Items;
                                var ul = document.createElement('ul');
                                for (var i = 0; i < items.length; i++){
                                    var li = document.createElement('li');
                                    var text = document.createTextNode(items[i].ID + ' ' + items[i].taskname);
                                    // 組み立て
                                    li.appendChild(text);
                                    ul.appendChild(li);
                                }
                                // <div id="result">の配下を置き換える
                                result.replaceChild(ul, result.firstChild);
                            }
                        } else {
                            result.textContent = 'サーバエラーが発生';
                        }
                    } else { // 通信中
                        result.textContent = '通信中'
                    }
                };
                // 非同期通信を開始
                xhr.responseType = 'json';
                xhr.open('GET', 'https://l3uk6hufcf.execute-api.ap-northeast-1.amazonaws.com/prod/sample-tasks', true);
                xhr.send(null);
            }, false );
        } ,false);
    </script>
</body>
</html>

HTMLの部分はまぁいいとして、JSの部分を丁寧にみてみようと思います。

送信ボタンの処理

document.addEventListener('DOMContentLoaded', function(){
        / * 中略 */
        })

ページロード時に実行されるイベントリスナーを登録しています。/* 中略 */の部分に記載した内容が初期化処理になります。次は中身をみてみます。

document.getElementById('submit').addEventListener('click',function(){
        /* 中略 */
            })

submitボタンにイベントリスナーを登録しています。さきほどDOMContentLoadedを渡していた第一引数は、今回はclickとなっています。イベントリスナーの種類を指定しているのですね。クリックしたときに、第二引数で渡している関数オブジェクトが実行されるわけです。さらに括弧の中をみていきます。

var status = document.getElementById('status');     // HTMLのid="status"の要素を取得
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(){                       // 通信の状態が変化したときに呼び出されるイベントハンドラ
    if (xhr.readyState === 4){ // 通信完了
        if (xhr.status === 200) { // 通信成功
            status.textContent = '送信しました';
        } else {
            status.textContent = 'サーバーエラーが発生しました';
        }
    } else {
        status.textContent = '送信中';
    }
};
var obj = {
    Item : {
        id: document.getElementById('ID').value,
        taskname: document.getElementById('taskname').value
    }
};
var json = JSON.stringify(obj);
xhr.open('PUT', 'https://l3uk6hufcf.execute-api.ap-northeast-1.amazonaws.com/prod/sample-tasks', true);
xhr.setRequestHeader('Content-Type', 'application/json');
console.log(json);
xhr.send(json);

非同期通信を管理するXMLHttpRequestが登場しています。onreadystatechangeプロパティには、通信の状態が変化したときに呼び出されるイベントハンドラを格納します。この例の場合、HTTP通信の状態(readyState)とHTTPステータスコードstatus)によって通信の状態を判断し、status.textContentにメッセージを代入することで通信状態を画面に表示しています。

後半では、PUTリクエストでJSONを送付する処理を記述しています。まず、送信したいJSONデータの元となるJavaScriptのオブジェクトをobjに代入し、それをJSON.stringify()で文字列形式に変換しています。次に、XMLHttpRequestオブジェクトのopen()メソッドで、HTTPリクエストの初期化を行います。第一引数がHTTPメソッド、第二引数がアクセス先URLです。第三引数以降はオプションで、ここでは第三引数にtrueを渡すことで非同期通信であることを明示しています(第三引数はデフォルトがtrueなので書いても書かなくても変わらないはずですが)。

重要なのは、xhr.setRequestHeader('Content-Type', 'application/json');の部分です。リクエストヘッダーを指定しています。API Gatewayで本文マッピングテンプレートを設定した際、「Content-Typeの欄にはapplication/jsonと記載して〜〜」と書いたことに対応しています。この処理の記述を省いてしまうと、リクエストは拒否されます(ステータスコード415)。この点については下記の記事が参考になるかと思います。

dev.classmethod.jp

リクエストヘッダーを指定したら、最後にJSON文字列をリクエストの本文としてsend()メソッドに渡して送信します。ここまでの処理が、すべて`「送信」ボタンをクリックした際に実行されます。

取得ボタンの処理

リクエスト送信時のJSON関係の処理がないこと以外、基本は送信ボタンの処理と同じです。ただ、こちらでは取得したデータをHTML側に埋め込むための処理が加わっています。

var data = xhr.response;
// 結果からキーにアクセス
if (data === null) {
    // ない場合はメッセージ
    result.textContent = 'データが存在しません';
} else {
    // 取得できた場合
    console.log(typeof(data));
    console.log(data)
    var items = data.Items;
    var ul = document.createElement('ul');
    for (var i = 0; i < items.length; i++){
        var li = document.createElement('li');
        var text = document.createTextNode(items[i].ID + ' ' + items[i].taskname);
        // 組み立て
        li.appendChild(text);
        ul.appendChild(li);
    }
// <div id="result">の配下を置き換える
result.replaceChild(ul, result.firstChild);

API Gateway(の背後にいるLambda)からはオブジェクト形式でデータが送られてきます。responseプロパティがレスポンスの本体です。それは下記のような形のデータです(ブラウザでAPIのエンドポイントに直接アクセスすると見られます)。

{
    "Items":[
        {"taskname":"test2","ID":"secondData"},
        {"taskname":"test","ID":"firstData"},
        {"taskname":"変更後!!!","ID":"aaa"}
    ],
    "Count":3,
    "ScannedCount":3
}

(参考)AWS-SDKのscanの仕様(英語)

取得されたデータの個数(Count)やスキャン対象となったデータの個数(ScannedCount※今回はフィルター適用していないのでCountと等しい)も一緒に送られてきていることがわかります。今回欲しいのはそれぞれのデータなので、Itemsを順に扱えば足ります。

ローカル変数itemsItemsの内容を代入したあとは、forループを使いながらid="result"の箇所に挿入する要素を作成しています。古典的なfor文で記載していますが、たぶんもっと近代的な書き方があるのだと思います。

仕上げ(CORSの有効化)

liul要素を順に作成したあと、最後にresult.replaceChild()で置き換えて、この無事表示ができました。めでたしめでたし。

と思いきや、この状態でindex.htmlにアクセスして、送信ボタンや取得ボタンを押すとエラーになります。クロスオリジン通信を可能にする必要があります。ブラウザのコンソールに、Access-Control-Allow-Originがなんちゃらかんちゃらと出るはずなので、これをコピーしてAPI GatewayAPIのアクションで「CORSの有効化」を選択し、Access-Control-Allow-Headersのところに上記のAccess-Control-Allow-Originを加えます。この設定をすることで、ようやくAPIをWebページから叩くことができます。

f:id:ky_yk_d:20180521001112p:plain

API Gateway リソースの CORS を有効にする - Amazon API Gateway

※XDR(Cross-Domain Request)は、元来セキュリティ上の必要性から制限されたもので、対応手段はCORS(Cross-Origin Resource Sharing)以外にも存在するようです。どの手段が適切であるかは、実践レベルでは要検討でしょう。

dev.classmethod.jp

JavaScript のクロス ドメイン (Cross-Domain) 問題の回避と諸注意tsmatz.wordpress.com

終わりに

はい。短くまとめるはずだったのに、今回も長くなってしまいました。個々の技術要素については入門的な記事や書籍がいくらでも存在しているので、あえて記事にするとすれば「実現したことベース」にして繋ぎ方を意識したものがいいかなと思ってるのですが、それゆえにこそ色々な要素を詰め込んでしまい、長くなってしまう傾向がありますね。これだけ長くなるとどこに何書いたかも忘れますし、構成や文章もグダグダになりがちです。なんとかしたいですね。技術的には、次はVue.jsとDynamoDBでも繋げてみようかと思っています。乞うご期待。

ソースコードについてはツッコミどころ満載であることは自覚しており、発展させながら直していこうと思っていますが、幾分初心者であるがために気づかないミスや改善点などあると思いますので、pull request・Issue等なげていただけるととても嬉しいです。

github.com