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

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

TODO:

概要

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

  • メリット
    • Lambda関数自体は1つしかないので、デプロイ等が容易。
    • Lambda関数が Javaでいう所の war に当たるので、既存の運用を大きく変える必要がない?
    • Lambdaの自動スケール機能により、負荷に応じたサーバ運用に悩む必要がない。
  • デメリット
    • コネクションプールが使えない。
    • 機能ごとのデプロイができない。
    • 1関数が大きくなってしまう。(各機能をLambda関数化すれば解消可能)

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

主な機能

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

  • 1つのLambda関数に、各機能の サーバjs はもちろん、HTMLテンプレート、js、cssなど全てを含める。(静的コンテンツは運用次第でS3等に配置しても良い)
  • 各機能は、それぞれ別jsとして作成する。
  • 各機能のjsは、Lambda関数としてもデプロイ可能な形で実装する。(今回は実際にLambda関数にする訳ではないが、Lambdaにしても動作するようにしておく)
  • 通信データ量を減らす為、SPA(シングルページアプリケーション)とする。(Lambda は送出データ量でも課金される為。)
  • とりあえずセッションデータストアは用意せず、暗号化 Cookie のみを使用。(後からRedis等を導入しても良い)※Railsのデフォルトと同じ感じ
  • レンダリングは全てクライアントで行い、テンプレートエンジンには とりあえず Handlebars を採用。(別に他のものでも良いが、軽めのものが良い)
  • ブラウザの戻るは許可しない。※js側で制御(ブラウザの戻る禁止を参照)
  • ブラウザリロードには対応する。
  • ログイン以降にロードしたHTMLテンプレートは極力再利用する。

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

  • アプリケーションキャッシュ等を使用して通信データ量を削減。
  • 同一コンテンツへのリクエスト受信時の 304 返却。

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

今回作成するアプリケーションは、ログイン画面 及び 簡単なマスタメンテ(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:

添付ファイル: fileimage1.png 82件 [詳細] fileapi-gateway-routes.png 82件 [詳細]

トップ   差分 バックアップ リロード   一覧 単語検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2017-12-01 (金) 01:59:03 (381d)