AWSメモ > 1つのLambda関数でSPAフレームワークを作成する †TODO:
概要 †1つのAWS Lambda関数で SPAフレーワークを作成してみた。
なんといってもスケールに悩む必要がないのが一番大きい。 主な機能 †以下、指針 及び 基本機能。
SPAにしない場合は、以下も考慮する。(今回はやらない)
作成するアプリケーション †今回作成するアプリケーションは、ログイン画面 及び 簡単なマスタメンテ(CRUD)だけを行うシンプルなアプリケーション。 TODO: 絵を貼る
画面表示 及び データ通信のイメージ †・HTMLの取得とデータ通信は完全に分ける。 ![]() TODO: 要修正: アクセス先が DynamoDBなので LambdaはVPC内に配置する必要はない(ENIアタッチが発生する為、かえって遅くなる)
ファイル構成 †全てのリクエストを index.js で受け付けて、リソースの返却や各機能のサーバjsの実行などを行う。 以下を 1つの Lambda関数に梱包する lambda-spa-sample URI一覧 †
Lambda関数の作成 †リクエスト情報等の取得元が違うだけで、やる事は Node.js でサーバを書くのと変わらない。 lambda-spa.js var aws = require('aws-sdk'); var fs = require('fs'); var mysql = require("mysql"); var http = require('http'); var querystring = require('querystring'); var url = require('url'); var crypto = require("crypto"); // 定数の読み込み const myconst = require("myconst.js"); global.myconst = myconst; // アプリケーション共通処理の読み込み const myapp = require("myapp.js"); global.myapp = myapp; // レスポンス返却関数(Lambda統合リクエスト) const createResponse = function(callback, statusCode, headers, content){ var res = { "statusCode": statusCode, "headers" : headers, "body": content } callback(null, res); } exports.handler = function(event, context, callback){ // 例) event から抜ける情報 //event.resource :"/api/{resource}/{id}" //event.headers.Host = "xxxxx.xxxxxxxxxxx.amazonaws.com" //event.httpMethod :"GET" //event.path : "/api/book/123" //event.requestContext.path : "/dev/api/book/123" //event.requestContext.stage : "dev" // セッションデータ var session = {}; // ログインチェック var isLogin = false; if (event.headers && event.headers.Cookie) { var cookieAry = event.headers.Cookie.split("; "); for (var i in cookieAry){ var keyVal = cookieAry[i].split("="); if (keyVal[0] == "session" && keyVal.length == 2) { try { var sessionText = myapp.decrypt(keyVal[1]); var tmpSession = JSON.parse(sessionText); if (tmpSession.user && tmpSession.loggedIn) { session = tmpSession; loggedIn = true; } break; } catch (e) { } } } } var apiName = null; var fileName = null; var isApiCall = event.path.match(/^\/api\/([^\/]+)(\/|)([^\/]*)/); var isStaticFile = event.path.match(/^\/(css|js|fonts|image)\/([^\/\?]+)(\?.+|)/); var isResourcePath = event.path.match(/^\/([^\/\?\.]+)(\/|)([^\/\?]*)/);; if (event.path == "/") { fileName = "view/index.html"; } else if (isApiCall) { apiName = isApiCall[1]; } else if (isStaticFile) { fileName = isStaticFile[1] + "/" + isStaticFile[2]; } else if (isResourcePath) { fileName = "view/" + isResourcePath[1] + ".html"; if (isResourcePath[3]) { fileName = "view/" + isResourcePath[1] + "-edit.html"; } } // API呼出 if (apiName && false) { // TODO: // 未ログインの時 if (!isLogin) { var uri = "/"; if (event.requestContext && event.requestContext.path && event.requestContext.path.indexOf("/"+event.requestContext.stage) == 0) { uri = "/" + event.requestContext.stage; } createResponse(callback, 200, null, JSON.stringify({ "Location" : uri })); return; } // 子Lambdaを呼び出す場合は、だいたい以下の情報をそのまま渡して挙げれば必要な情報は全部揃う var innerEvent = {}; innerEvent.headers = event.headers || {}; innerEvent.queryStringParameters = event.queryStringParameters || null; innerEvent.pathParameters = event.pathParameters || null; innerEvent.requestContext = event.requestContext || null; innerEvent.body = event.body || {}; innerEvent = JSON.stringify(innerEvent); // API呼出(擬似Lambda関数) if (myconst.isLocalApi) { var jsFile = "api/" + apiName + ".js"; if (myapp.fileExists()){ var module = require(jsFile); module.handler(innerEvent, context, function(apiErr, apiRes){ //module.handler(event, context, callback); var statusCode = apiRes.statusCode ? apiRes.statusCode : 502; var headers = apiRes.headers ? apiRes.headers : {}; var content = apiRes.body ? apiRes.body : JSON.stringify(apiRes); createResponse(callback, statusCode, headers, content); }); } else { var content = "<!doctype html><html><body><h1 style='color:red;'>403 Forbidden.</h1></body></html>"; createResponse(callback, 403, null, content); } // API呼出(Lambda関数) } else { var lambda = new aws.Lambda({apiVersion: '2015-03-31'}); var params = { FunctionName: "childFunc", //InvocationType: "Event", // 非同期呼出 InvocationType: "RequestResponse", //同期呼出 Payload: innerEvent }; // Lambda関数の実行 lambda.invoke(params, function(err, data){ if (err) { context.done(err, err); // TODO: } else { // 子から受け取ったレスポンスをそのまま返す var result = { "statusCode" : 502 , "body" : "" }; var childRes = data.Payload || data || {}; if (childRes && typeof(childRes) === "string"){ childRes = JSON.parse(childRes); } result.statusCode = childRes.statusCode ? childRes.statusCode : 502; result.headers = childRes.headers ? childRes.headers : null; result.body = childRes.body ? childRes.body : ""; //console.log("data: "); //console.log(data); //console.log("result: "); //console.log(result); createResponse(callback, result.statusCode, result.headers, result.body); } }); } // 静的ファイル読み込みの場合 } else if (fileName) { // 未ログイン かつ リソースPATHの場合はトップページにリダイレクト if (!isLogin && fileName.match(/^view\/.+/) && !fileName.match(/^view\/(index\.html|_header\.html|_footer\.html)/)) { var uri = "/"; if (event.requestContext && event.requestContext.path && event.requestContext.path.indexOf("/"+event.requestContext.stage) == 0) { uri = "/" + event.requestContext.stage; } createResponse(callback, 302, { "Location" : uri }, ""); return; } // Content-Type の判定 var contentType = "text/html"; if (fileName.match(/^css\/.+/)) { contentType = "text/css"; } else if (fileName.match(/^js\/.+/)) { contentType = "application/javascript"; } else if (fileName.match(/^image\/.+/)) { contentType = "image/" + fileName.replace(/^.+\./, ""); } else if (fileName.match(/^fonts\/.+/)) { if (fileName.match(/\.woff(\?*.*)$/)) { contentType = "application/font-woff"; } else if (fileName.match(/\.(ttf|otf)(\?*.*|)$/)) { fileName.replace(/^.+\.(ttf|otf)(\?.*|)$/, "$1"); contentType = "application/x-font-" + fileName.replace(/^.+\.(ttf|otf)(\?*.*|)$/, "$1"); } else if (fileName.match(/\.svgf(\?*.*|)$/)) { contentType = "image/svg+xml"; } else if (fileName.match(/\.eot(\?*.*|)$/)) { contentType = "application/vnd.ms-fontobject"; } } // レスポンスヘッダ var headers = { "Content-Type" : contentType }; // ファイルが存在する場合は内容を取得して返却 if (myapp.fileExists(fileName)){ if (isLogin && fileName.match(/^view\/(_header\.html|_footer\.html)/)) { fileName = fileName.replace(/\.html$/, "-auth.html"); } var data = fs.readFileSync(fileName); var content = data.toString('ascii', 0, data.length) createResponse(callback, 200, headers, content); } else { // 存在しない場合は 404エラー var content = "<!doctype html><html><body><h1 style='color:red;'>404 Not Found</h1></body></html>"; createResponse(callback, 404, headers, content); } } else { // どのハンドラーにも引っかからなかった時は 403 var content = "<!doctype html><html><body><h1 style='color:red;'>403 Forbidden.</h1></body></html>"; createResponse(callback, 403, headers, content); } } SPAの仕組み †今回作成するSPAは以下の通り構築する。 HTMLの基本構成 †
<!doctype html> <html lang="ja"> <head> <meta charset="utf-8" > </head> <body> <div id="page-header">ページヘッダ</div> <div id="page-contents"> <!-- ログインページのコンテンツ --> <div id="page-login" class="page-content"> ... </div> <!-- ○○ページのコンテンツ(必要になった際に非同期で取得) --> <div id="page-hoge" class="page-content" style="display:none;"> ... </div> <!-- △△ページのコンテンツ(必要になった際に非同期で取得) --> <div id="page-fuga" class="page-content" style="display:none"> ... </div> </div> <div id="page-footer">ページフッタ</div> </body> </html> クライアント共通処理の作成 †クライアント用の共通処理( js/common.js )は以下の通り作成する。 共通処理の名前空間 †共通処理用のの名前空間を定義し、定数、変数、関数などは全てこの名前空間配下に定義する。 myapp = window.myapp || {}; // 共通変数 myapp.var1 = "123"; // 共通関数 myapp.hogeFinction = funtion(){ ... }; ページ遷移 †ページ遷移時に行われる処理は以下の通り
尚、各ページ用のイベントとして、とりあえず以下を用意した。( jQuery Mobile 風 )
ページ遷移用の共通関数 myapp.movePage = function(url, callback){ var templateUrl = url; templateUrl = templateUrl.replace(/^\/([a-zA-Z0-9_\-]+)\/.+$/, "$1-edit"); var $pages = $("#page-contents").find("[data-url=\""+templateUrl+"\"]"); if ($pages.size() > 0) { $(".page-content").hide(); // 履歴追加 myapp.pushHistory(url); // History API で履歴を追加する関数(ブラウザバックの抑制等も行う) // 読み込み完了イベント発火 var id = $pages.eq(0).attr("id"); $("#"+id).trigger("pagebeforeshow"); $pages.eq(0).show(); $("#"+id).trigger("pageshow"); return; } myapp.getContents(url, {}, "GET", function(res){ // 読み込まれていないjsのload TODO: 開発時のみ有効とする(本番時は 1ファイルに minify する) $("<div>"+res+"</div>").find("script").each(function(){ var src = $(this).attr("src"); if (!myapp.loadedJs[src]) { $("head").append("<script src='"+src+"'></script>"); myapp.loadedJs[src] = src; } }); // コンテンツを追加 var contentText = $("<div>"+res+"</div>").find("#page-contents").eq(0).html(); var nextContentId = $("<div>"+contentText+"</div>").find(".page-content").eq(0).attr("id"); $(".page-content").hide(); $("#page-contents").append(contentText); if (nextContentId) { $(".page-content").hide(); $("#"+nextContentId).attr("data-url", templateUrl); // 履歴追加 myapp.pushHistory(url); // 読み込み完了イベント発火 $("#"+nextContentId).trigger("pageinit"); $("#"+nextContentId).trigger("pagebeforeshow"); $("#"+nextContentId).show(); $("#"+nextContentId).trigger("pageshow"); } }); }; /** * 非同期でコンテンツを取得する. */ myapp.getContents = function(url, params, method, callback){ var templateUrl = myapp.contextRoot + url; templateUrl = templateUrl.replace(/\/\//, "/"); method = method || "GET"; params = params || {}; $.ajax({ "url" : templateUrl ,"data" : params ,"type" : method ,"success" : function(res){ if (callback && typeof (res) == "string") { callback(res); } else { console.error("response is not string!"); console.error(res); } } ,"error" : function(a1){ console.error("error!"); console.error(a1); } }); }; リンククリック時の処理 †リンククリック時のデフォルト挙動は無効化し、上記のページ遷移処理を行うようにする。 jQuery(function($){ // リンクをクリック時 $(document).on("click", "a", function(e){ var url = $(this).attr("href") || $(this).data("href"); if (url) { myapp.movePage(url); } if ($(this).attr("target") != "_blank") { e.stopPropagation(); e.preventDefault(); } return false; }); }); 最初のページを読み込み後の処理 †一番最初のページを読み込んだ後は、対象ページの初期処理イベント(pageinit)、表示前イベント(pagebeforeshow)、表示後イベント(pageshow)をそれぞれ発火させる。 // 最初のページコンテンツを読み込み時 $(window).load(function(){ // var templateUrl = location.pathname; templateUrl = templateUrl.replace(/^\/([a-zA-Z0-9_\-]+)\/.+$/, "$1-edit"); $(".page-content").eq(0).attr("data-url", templateUrl ); // ページ読み込みイベントの発火(最初のページのみ) var $page = $(".page-content").eq(0); if ($page.attr("id")) { var id = $page.attr("id"); console.log(id + " : trigger pageinit"); $("#"+id).trigger("pageinit"); console.log(id + " : trigger pagebeforeshow"); $("#"+id).trigger("pagebeforeshow"); console.log(id + " : trigger pageshow"); $("#"+id).trigger("pageshow"); } }); 共通(固定)パーツの読み込み †固定部(ヘッダ、フッタ)は、必要なタイミング(ログイン、ログアウト後)でリロードする。 /** * ヘッダ、フッタの読込 */ myapp.loadFixedParts = function(){ $("#page-header").load(myapp.contextRoot+"_header"); $("#page-footer").load(myapp.contextRoot+"_footer"); }; 各ページのクライアント処理作成 †各ページ用の処理では、初期読込時イベント(pageinit)、表示前イベント(pagebeforeshow)、表示後イベント(pageshow) 及び 対象ページで必要な処理の実装を行う。 ログインページの例 †#TODO TOPページの例 †#TODO 一覧画面の例 †view/book.html(コンテンツ部以外は省略) . . <script src="/js/book.js"></script> . . <!-- 本の一覧画面 --> <div id="page-books" class="page-content"> <h2>本の一覧画面</h2> <div class="ib"> <div style="text-align:right;"> <span id="books-refresh-btn" class="fa fa-refresh link _button"> 更新</span> </div> <table id="books-table" class="my-table hoverable"> <thead> <tr> <th>ID</th> <th>ISBN</th> <th>タイトル</th> <th>価格</th> <th>読込日時(テスト用)</th> </tr> </thead> <tbody> <tr id="books-table-templ" class="my-tmpl"> <td><span class="book-id">{{id}}</span></td> <td>{{isbn}}</td> <td>{{title}}</td> <td>{{price}}</td> <td>{{loaded_at}}</td> </tr> </tbody> </table> </div> </div> . . js/book.js(本番時は 1ファイルに minify ) jQuery(function($){ // 一覧の初回読込時 $(document).on("pageinit", "#page-books", function(){ // 一覧画面の表示前処理 $("#page-books").on("pagebeforeshow", function(){ // 一覧データ読込 myapp.getData("/api/book", {}, "GET", function(res){ // リストの描画 var listData = res; myapp.renderList("books-table-templ", listData); }, function(res){ console.error("error!"); console.error(res); } ); }); // 一覧から任意の行をダブルクリック時 $(document).on("dblclick", "#books-table tbody tr", function(){ var id = $(this).find(".book-id").text(); myapp.movePage("/book/"+id); }); }); }); API Getewayの作成 †以下の通り作成し、全てのメソッドを上記で作成した Lambda関数に引っ付ける。 ![]() TODO:
|