AWSメモ > 1つのAWS Lambda関数でSPAフレームワークを作成する

1つのLambda関数でSPAフレームワークを作成する

TODO:

概要

1つのAWS Lambda関数で SPAフレーワークを作成してみた。
イメージは Nodejsでサーバを書く感じ。(というか、ほぼそのままでも Nodejsでも動く。)

なんといってもスケールに悩む必要がないのが一番大きい。(Lambdaは自動でスケールされるらしい)
大きなデメリットとして、コネクションプールが使用できない点が挙げられるが、この辺は要件次第で、
全て、または殆どのDBアクセスが NoSQL 向けであれば、それほど気にする必要はないかもしれないが、
VPC内のRDSへのアクセスが多いようであれば、アクセス頻度や多重度によっては別の選択肢も検討した方が良いと思われる。※AWS LambdaでRDS接続時のパフォーマンス調査 参照。
※DBがMySQL系であればまだ良いが、Oracle(RDS for Oracle)を採用するのであれば、Lambda は諦めてEC2インスタンスを立てた方が良いと思う。

主な機能

以下、指針 及び 基本機能。

SPAにしない場合は、以下も考慮する。(今回はやらない)

作成するアプリケーション

今回作成するアプリケーションは、ログイン画面 及び 簡単なマスタメンテ(CRUD)だけを行うシンプルなアプリケーション。

TODO: 絵を貼る

画面表示 及び データ通信のイメージ

・HTMLの取得とデータ通信は完全に分ける。
・データの取得、更新は全てAjaxにて非同期で行い、データの画面への描画もクライアント側で行う。
・AWS側の入り口は API Gateway に1本化する。

image1.png

ファイル構成

全てのリクエストを index.js で受け付けて、リソースの返却や各機能のサーバjsの実行などを行う。
※Node.js のサーバを書く感じ。

以下を 1つの Lambda関数に梱包する

lambda-spa-sample
  /lambda-spa.js         ... Lambda関数(この関数で全てのリクエストを受け付ける)
  /node_modules
    /myconst.js         ... アプリケーション定数
    /myapp.js          ... アプリケーション共通関数
     .
     .
  /css              ... スタイルシート
    /common.css
     .
     .
  /js               ... クライアントjs
    /jquery-x-x-x.min.js
    /common.js
    /xxxxxx.js
     .
     .
  /fonts              ... Webフォントとか(CDN使った方が良いかも)
    /fontawesome-webfont.woff
    /fontawesome-webfont.eot
     .
     .
  /view
    /login.html         ... ログイン画面
    /top.html          ... TOP画面
    /book.html         ... 本の一覧画面
    /book-edit.html       ... 本の登録/更新画面
    /_header.html        ... ヘッダ部分のHTML(未ログイン時用)
    /_header_auth.html     ... ヘッダ部分のHTML(ログイン済みの時用)
    /_footer.html        ... フッタ部分のHTML(未ログイン時用)
    /_footer_auth.html      ...フッタ部分のHTML(ログイン済みの時用)
  /api              ... 各機能毎のjs(こいつらは単独のLambda関数としても動作するように実装する)
    /book.js          ... 本の一覧検索/一意検索/登録/更新/削除
    /login.js          ... ログイン処理
    /logout.js          ... ログアウト処理

URI一覧

メソッドURI説明
GET/ログイン状態に応じて view/login.html または view/top.html を返却する
GET/topview/top.htmlを返却する
GET/bookview/book.html を返却する
GET/book/{id}view/book-edit.html を返却する
GET/api/bookapi/book.js を実行する(本の一覧データを返却する)
POST/api/bookapi/book.js を実行する(本のデータを登録する)
GET/api/book/{id}api/book.js を実行する(指定された本のデータを返却する)
PUT/api/book/{id}api/book.js を実行する(指定された本のデータを更新する)
DELETE/api/book/{id}api/book.js を実行する(指定された本のデータを削除する)
GET/api/loginapi/login.js を実行する(ログイン処理)
DELETE/api/loginapi/logout.js を実行する(ログアウト)
GET/_headview/_header.html を返却する(ログイン済みの時は _header_auth.html を返却する)
GET/_footerview/_footer.html を返却する(ログイン済みの時は _footer_auth.html を返却する)

Lambda関数の作成

リクエスト情報等の取得元が違うだけで、やる事は Node.js でサーバを書くのと変わらない。
※少し変えれば、ほぼそのままでも Nodejs用のサーバとして使用できる。

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は以下の通り構築する。
jQueryMobile等と同じような感じ。

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 風 )
使い方としては、pageinit イベントでボタンクリックなどのイベントを定義等、テンプレート毎に1回だけ実行する処理を行う。

イベント名説明使用例
pageinitページの初回読み込み時に発火するイベントテンプレート毎に1回だけ実行する処理を行う(イベント定義等)
pagebeforeshowページの表示直前に発火するイベント初期表示するデータの取得、描画など
pageshowページ表示直後に発火するイベント必要に応じて使用。メッセージの表示など?

ページ遷移用の共通関数

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");
    }
});

共通(固定)パーツの読み込み

固定部(ヘッダ、フッタ)は、必要なタイミング(ログイン、ログアウト後)でリロードする。
※ただし、各パーツのURIは固定とし、返却するコンテンツの内容はサーバ側に任せる。

/**
 * ヘッダ、フッタの読込
 */
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関数に引っ付ける。
※全部、「統合リクエスト:Lambda」で作成する事。

api-gateway-routes.png
TODO:

トップ   一覧 単語検索 最終更新   ヘルプ   最終更新のRSS