前回のLINE Bot作成に引き続き、AWS関連です。Webアプリ側に寄せています。
- 前回やったこと
- 今回やること
- Amazon Dynamo DB の設定
- AWS Lambda の設定
- Amazon API Gateway の設定
- Webページ(HTML & JavaScript)の作成
- 終わりに
前回やったこと
今回やること
- Lambdaの裏側にDynamoDBを置く(前回はLambda側に全て記載されていた)
- WebページからJavaScriptでAPI GatewayのAPIを叩く
- 新たなデータを作成する/取得した結果を表示する
LINEボットを作成して、 API GatewayからLambda関数を呼び出す流れはわかりました。 前回は、API Gatewayの呼び出しはLINE側で用意されていましたが、今回はWebページからJavaScriptで呼び出してみます。また、 フレームワークを使ったWebアプリを作成した(編集した)経験はあるのですが、クライアントサイドJavaScriptを書いた経験はほぼゼロです。自分でWebアプリを作れるようになるには、クライアントサイドJSは避けて通れませんので、今回はその初歩をやってみました。
図示するほどのものではないのですが、憧れがあったので構成図を。素材は以下から。 ちなみに、PowerPointでペタペタ貼り付けてスクショ撮ったんですけど、色々間違っている気がします。
API Gateway〜DynamoDBの設定は下記の書籍(198頁以下)を参照しました。
実践AWS Lambda ~「サーバレス」を実現する新しいアプリケーションのプラットフォーム~
- 作者: 西谷圭介
- 出版社/メーカー: マイナビ出版
- 発売日: 2017/06/09
- メディア: 単行本(ソフトカバー)
- この商品を含むブログを見る
Amazon Dynamo DB の設定
テーブルを作成します。以上!
・・・
というわけには流石にいきませんが、リレーショナルデータベースと異なりスキーマを設定する必要がないので、確実に設定しなければならないのはテーブル名とプライマリーキーくらいです。
テーブル名はSAMPLE_TASKS
、プライマリーキーはID
としました。普段(リレーショナルDBでの)テーブル名やカラム名は大文字表記の場合が多いので、書籍の指示を無視して大文字にしたのですが、画面との連携を考えると小文字の方が良かったと思います(そして見返して思いましたがtaskname
は小文字にしてますね・・・)
AWS Lambda の設定
Lambdaで設定する内容は以下の2点です。
- DynamoDBFullAccessを付与したロールを割り当てる
- 関数を記載する
今回は、LambdaからDynamoDBにアクセスするので、DynamoDBに対する読み書きの権限を有するロールをLambda関数に割り当てる必要があります。そこで、Lambda関数の作成の前にIAMロールを作成します。ロール作成画面で、「信頼されたエンティティの種類」でLambdaを、次の画面の「アクセス権限ポリシー」でDynamoDBFullAccessを選択しましょう。
上記の手順で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の作成(
sample_crud_tasks
) - リソースの作成(
sample_tasks
) - GETメソッドとPUTメソッドを作成し、Lambda関数と紐づける
- GETメソッドとPUTメソッドの双方の「統合リクエスト」に本文マッピングテンプレートを指定
- ステージにデプロイ
本文マッピングテンプレートを設定するところについては説明が必要と思われます。「統合リクエスト」をクリックして開く画面の最下部に「本文マッピングテンプレート」という項目があるので、Content-Typeの欄にはapplication/json
と記載して追加します。上記の2番目のような画面になります。
本文マッピング・テンプレート(GET, PUT共通)
{ "method": "$context.httpMethod", "body": $input.json('$') }
送られてきたHTTPリクエストの内容を、受け手側のデータにマッピングして渡すもののようです。
$context.httpMethod
は一見してわかるように使用されたHTTPメソッド名で、画面側のJavaScriptのxhr.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操作
- ピュアJavaScriptによる繰り返し処理
- XMLHttpRequestによるAjax
- json形式のデータをHTTPリクエストで送信する
僕は「DOMってなに?モビルスーツ?」というレベルなので、極めて初歩的な内容となっています。また、jQueryなどを用いればAjaxはもっと簡潔に実装できるのだと思うのですが、色々なところから「jQueryはもう・・・」という声が聞こえてきています(例:You-Dont-Need-jQuery)。研修のときにjQueryがモダンなのだという話を聴いた気がしたのですが。
今後の開発のなかでjQueryを使いたくなるようなことがあれば学ぶことはやぶさかではないのですが、今回はJavaScriptの勉強をするという観点からも、ピュアJSで記述しました。また、HTMLファイルとJSファイルを別に作成すべきところなのでしょうが、今回はHTMLファイルに<script>
タグで埋め込んでしまいました。どこかのタイミングで切り離します。ソースコードに重複も多いですし、リファクタをちゃんと考えてみたいです。
なお、今回のコードを実装するにあたっては、下記を参照しました。
改訂新版JavaScript本格入門 ~モダンスタイルによる基礎から現場での応用まで
- 作者: 山田祥寛
- 出版社/メーカー: 技術評論社
- 発売日: 2016/09/30
- メディア: 大型本
- この商品を含むブログを見る
それでは実際のコードです。
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)。この点については下記の記事が参考になるかと思います。
リクエストヘッダーを指定したら、最後に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 }
取得されたデータの個数(Count
)やスキャン対象となったデータの個数(ScannedCount
※今回はフィルター適用していないのでCount
と等しい)も一緒に送られてきていることがわかります。今回欲しいのはそれぞれのデータなので、Items
を順に扱えば足ります。
ローカル変数items
にItems
の内容を代入したあとは、forループを使いながらid="result"
の箇所に挿入する要素を作成しています。古典的なfor文で記載していますが、たぶんもっと近代的な書き方があるのだと思います。
仕上げ(CORSの有効化)
li
、ul
要素を順に作成したあと、最後にresult.replaceChild()
で置き換えて、この無事表示ができました。めでたしめでたし。
と思いきや、この状態でindex.htmlにアクセスして、送信ボタンや取得ボタンを押すとエラーになります。クロスオリジン通信を可能にする必要があります。ブラウザのコンソールに、Access-Control-Allow-Origin
がなんちゃらかんちゃらと出るはずなので、これをコピーしてAPI GatewayのAPIのアクションで「CORSの有効化」を選択し、Access-Control-Allow-Headers
のところに上記のAccess-Control-Allow-Origin
を加えます。この設定をすることで、ようやくAPIをWebページから叩くことができます。
API Gateway リソースの CORS を有効にする - Amazon API Gateway
※XDR(Cross-Domain Request)は、元来セキュリティ上の必要性から制限されたもので、対応手段はCORS(Cross-Origin Resource Sharing)以外にも存在するようです。どの手段が適切であるかは、実践レベルでは要検討でしょう。
JavaScript のクロス ドメイン (Cross-Domain) 問題の回避と諸注意tsmatz.wordpress.com
終わりに
はい。短くまとめるはずだったのに、今回も長くなってしまいました。個々の技術要素については入門的な記事や書籍がいくらでも存在しているので、あえて記事にするとすれば「実現したことベース」にして繋ぎ方を意識したものがいいかなと思ってるのですが、それゆえにこそ色々な要素を詰め込んでしまい、長くなってしまう傾向がありますね。これだけ長くなるとどこに何書いたかも忘れますし、構成や文章もグダグダになりがちです。なんとかしたいですね。技術的には、次はVue.jsとDynamoDBでも繋げてみようかと思っています。乞うご期待。
ソースコードについてはツッコミどころ満載であることは自覚しており、発展させながら直していこうと思っていますが、幾分初心者であるがために気づかないミスや改善点などあると思いますので、pull request・Issue等なげていただけるととても嬉しいです。