概要

1つのAWS Lambda関数でSPAフレームワーク作成 の別バージョン。
違いは、Lambda の言語が Python、クライアント側が Vue.js である事と、Cloudformationテンプレート化した事ぐらい。
小さなアプリケーションであれば、恐らくこれで十分だと思う。

[補足]

  • 静的ファイル(html、css、js) は Lambda に内包し、リクエストURI に応じてファイルを返却する。(S3からの配信はしない)
    ※ Lambdaは リクエスト数:100万/月、実行時間:400,000 GB-秒/月 は無料なので、小規模であればS3ホスティングするより安くなる。
  • /api/xxx へのリクエストは対象の api の処理を読み込んで実行する。
  • いちおう画像も返却できるように API Gateway の BinaryMediaTypes を指定。

目次

フォルダ構成

/aws_build.sh
/client-project
 /.env.development
 /.env.production
 /dist
 /node_modules
 /package-lock.json
 /package.json
 /public
 /src
  /App.vue
  /EventBus.js
  /assets
  /components
   /Footer.vue
   /Header.vue
   /PageSample1.vue
   /PageSample2.vue
   /PageTop.vue
  /main.js
/src
 /api
  sample1.py
  sample2.py
 /index.py
 /libs
 /myenv.py
 /client
  /css
   /xxxxx.css
  /js
   /app.xxxxxxxx.js
   /app.xxxxxxxx.js.map
  /index.html
/template.yml

・・・ ビルド用シェル ( CloudFormation実行用のシェル )
・・・ Vue.jsプロジェクト
・・・ 環境変数(Vue.jsローカル動作確認用)
・・・ 環境変数(本番用)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
・・・ Lambda用ソース
 
 
 
 
 
 
・・・ この配下にVue.js ビルド後のファイルをコピー (後述)
 
 
 
 
 
 
・・・ Cloudformation テンプレート

サーバ側

Cloudformationテンプレート

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: "1LambdaでSPAのサンプル"

Globals:
  Api:
    Cors:
      AllowMethods: "'PUT,GET,POST,DELETE,OPTIONS'"
      AllowHeaders: "'Content-Type'"
      AllowOrigin: "'*'"

Resources:
  ImageSupportSpaApi:
    Type: AWS::Serverless::Api
    Properties:
      Name: ImageSupportSpaApi
      StageName: Prod
      BinaryMediaTypes: 
        - "image/*"

  ImageSupportSpaFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: ImageSupportSpaFunction
      CodeUri: ./src
      Handler: index.handler
      Runtime: python3.7
      Timeout: 60
      MemorySize: 128
      Role: !GetAtt ImageSupportSpaFunctionRole.Arn
      Events:
        Root:
          Type: Api 
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /
            Method: get
        Favicon:
          Type: Api 
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /favicon.ico
            Method: get 
        StaticCss:
          Type: Api 
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /css/{filename}
            Method: get
        StaticJs:
          Type: Api 
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /js/{filename}
            Method: get
        StaticImage:
          Type: Api 
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /img/{filename}
            Method: get
        ListActions:
          Type: Api
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /api/{resource}
            Method: get
        GetActions:
          Type: Api
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /api/{resource}/{id}
            Method: get
        PostActions:
          Type: Api
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /api/{resource}
            Method: post
        PutActions:
          Type: Api
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /api/{resource}/{id}
            Method: put
        DeleteActions:
          Type: Api
          Properties:
            RestApiId: !Ref ImageSupportSpaApi
            Path: /api/{resource}/{id}
            Method: delete

  ImageSupportSpaFunctionRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: ImageSupportSpaFunctionRole
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service: "lambda.amazonaws.com"
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: "ImageSupportSpaFunctionPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:                                         # 必要に応じて権限を付与
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                  - s3:GetObject
                Resource: "*"

Outputs:
  ImageSupportSpaApiUrl:
    Value: !Sub "https://${ImageSupportSpaApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
    Export:
      Name: ImageSupportSpaApiUrl

src/myenv.py

"""共通処理(PATHのセットなど)."""
import sys
import os


def add_path(path):
    """PATH追加."""
    sys.path.append(os.path.abspath(os.path.dirname(__file__)) + path)


def is_local(event):
    """ローカルでの動作確認用かどうかの判断."""
    return event["requestContext"]["identity"]["sourceIp"] == "127.0.0.1"

src/index.py

"""メイン処理."""
import base64
import importlib
import json
import os
import re
import myenv

# PATH追加
myenv.add_path("/libs")


def handler(event, context):
    """メインハンドラ."""
    print(json.dumps(event, indent=4))
    status_code = 200
    response_body = ""
    headers = {"Content-Type": "text/html"}
    if myenv.is_local(event):
        headers["Access-Control-Allow-Origin"] = "*"
        headers["Access-Control-Allow-Methods"] = "PUT,POST,DELETE,OPTIONS"

    is_base64_encoded = False
    if event["path"] == "/":
        with open("client/index.html") as f:
            response_body = f.read()
    elif event["path"] == "/favicon.ico":
        file_path = "client/favicon.ico"
        if os.path.exists(file_path):
            headers["Content-Type"] = "image/x-icon"
            with open(file_path, "rb") as f:
                response_body = base64.b64encode(f.read()).decode("utf-8")
                is_base64_encoded = True
    elif re.match(r"^/(css|js)/.+", event["path"]):
        file_path = "client/" + event["path"][1:]
        if re.match(r"^/css/.+", event["path"]):
            headers["Content-Type"] = "text/css"
        else:
            headers["Content-Type"] = "application/javascript"
        if os.path.exists(file_path):
            with open(file_path) as f:
                response_body = f.read()
    elif re.match(r"^/img/.+", event["path"]):
        file_path = "client/" + event["path"][1:]
        headers["Content-Type"] = "image/png"
        if os.path.exists(file_path):
            with open(file_path, "rb") as f:
                response_body = base64.b64encode(f.read()).decode("utf-8")
                is_base64_encoded = True
    elif re.match(r"^/api/.+", event["path"]):
        headers["Content-Type"] = "application/json"
        resource = event["pathParameters"]["resource"]
        # print("api call " + event["path"])
        # print("./api/" + resource + ".py")
        if os.path.exists("./api/" + resource + ".py"):
            m = importlib.import_module("api." + resource)
            result = m.handler(event, context)
            if isinstance(result, dict):
                if "statusCode" in result:
                    status_code = result["statusCode"]
                if "headers" in result:
                    headers = {**headers, **result["headers"]}
                if "body" in result:
                    response_body = result["body"]
                if "isBase64Encoded" in result:
                    is_base64_encoded = result["isBase64Encoded"]
            else:
                status_code = 500
                response_body = {"message": "Invalid API Response Format!"}
        if response_body == "":
            status_code = 501
            response_body = {"message": "API Not Found!"}
    else:
        status_code = 501
        response_body = {"message": "Not Implemented!"}

    if isinstance(response_body, dict):
        response_body = json.dumps(response_body)

    return {
        "statusCode": status_code,
        "headers": headers,
        "isBase64Encoded": is_base64_encoded,
        "body": response_body
    }

src/api/sample1.py

"""サンプル1."""
import json


def handler(event, context):
    """メインハンドラ."""
    request_body = {}
    if event["body"] and isinstance(event["body"], str):
        request_body = json.loads(event["body"])
    elif event["body"] and isinstance(event["body"], dict):
        request_body = event["body"]

    return {
        "body": json.dumps(
            {
                "message": "This is sample1 api message!",
                "httpMethod": event["httpMethod"],
                "path": event["path"],
                "resource": event["resource"],
                "pathParameters": event["pathParameters"],
                "queryStringParameters": event["queryStringParameters"],
                "request_body": request_body,
            }
        )
    }

src/api/sample2.py

"""サンプル2."""
import json


def handler(event, context):
    """メインハンドラ."""
    response_body = {
        "items": [{"name": "item1", "price": 1000},
                  {"name": "item2", "price": 2000},
                  {"name": "item3", "price": 3000},
                  {"name": "item4", "price": 4000}]
    }

    return {
        "body": json.dumps(response_body)
    }

src/client/*

vue.js のビルド済みファイルをこの配下にコピーする。(後述)

クライアント周りの作成

プロジェクトの作成

vue create client-project

設定ファイル

client-project/.env.development

NODE_ENV="development"
VUE_APP_API_ROOT="http://127.0.0.1:3000/api/"

※Vue.js をローカル起動している場合は、SAMローカルで動作中のLambdaにアクセスさせる。

client-project/.env.production

NODE_ENV='production'
VUE_APP_API_ROOT='./api/'

※ API Gateway & Lambda のデプロイ後は、ルート直下の api でアクセス。
※ ただし、API Gateweay の Stage 名がURLの頭に付加されるケースを想定し、相対PATHで記述。( Vue Router を使用する場合は historyモード はOFFにする事

client-project/vue.config.js

module.exports = {
      publicPath: './'
}

※ API Gateweay の Stage 名がURLの頭に付加されるケースを想定し、相対PATHで記述。

client-project/src/main.js

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

client-project/src/main.js

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

client-project/src/App.vue

<template>
  <div id="app">
    <Header />
    <component v-bind:is="currentView"></component>
    <Footer />
  </div>
</template>

<script>
import EventBus from './EventBus.js'
import Header from './components/Header.vue'
import PageTop from './components/PageTop.vue'
import PageSample1 from './components/PageSample1.vue'
import PageSample2 from './components/PageSample2.vue'
import Footer from './components/Footer.vue'

export default {
  name: 'app',
  components: {
    Header,
    Footer,
    PageTop,
    PageSample1,
    PageSample2,
  },
  data: function(){
    return {
      currentView: "PageTop"
    }
  },
  methods: {
    switchView(viewName) {
      this.currentView = viewName;
    }   
  },  
  created: function(){
    EventBus.$on('switchView', this.switchView)
  }
}
</script>

<style>
html, body {
  margin: 0;
}
h1 {
  margin: 0;
  font-size: 120%;
}
#header {
  padding: 2px 0;
}
#main {
  padding: 5px 10px;
}
#footer {
  position: fixed;
  padding: 5px;
  bottom: 0;
  width: 100%;
  background: #eee;
}
</style>

client-project/src/EventBus.js

import Vue from 'vue'
const EventBus = new Vue()
export default EventBus

client-project/src/Header.vue

<template>
  <div id="header">
    <h1>Header Content</h1>
    <ul id="header-menu">
      <li v-on:click="menuClicked('PageTop')">TOP</li>
      <li v-on:click="menuClicked('PageSample1')">サンプル1</li>
      <li v-on:click="menuClicked('PageSample2')">サンプル2</li>
    </ul>
  </div>
</template>

<script>
import EventBus from '../EventBus.js'
export default {
  name: 'Header',
  methods: {
    "menuClicked" : function(page){
      EventBus.$emit('switchView', page)
    },  
  }
}
</script>

<style scoped>
#header h1 {
  padding: 0 5px;
}
#header-menu {
  list-style-type: none;
  margin: 0;
  padding: 0;
  background: #333;
  color: #fff;
}
#header-menu.ul,#header-menu li {
  display: inline-block;
}
#header-menu li {
  padding: 10px;
  border-right: 1px dotted #fff;
  cursor: pointer;
}
#header-menu li:hover {
  opacity: 0.8;
  text-decoration: underline;
}
</style>

client-project/src/Footer.vue

<template>
  <div id="footer">
    <div>Footer Content</div>
  </div>
</template>

<script>
export default {
  name: 'Footer',
}
</script>

client-project/src/PageTop.vue

<template>
  <div id="main">
    <div>
      Top Content<br />
      <button v-on:click="listAction()">get</button>
      <button v-on:click="postAction()">post</button>
      <button v-on:click="putAction(1)">put</button>
      <button v-on:click="deleteAction(1)">delete</button>
      <hr/>
      <img src="img/sample.png" width="500"/>
      <hr/>
      <pre>{{ result }}</pre>
    </div>
  </div>
</template>

<script>
import axios from 'axios';
export default {
  name: 'PageTop',
  data: function(){
    return {
      msg: process.env.VUE_APP_API_ROOT,
      result: ""
    }
  },
  created: function(){
  },
  methods: {
    listAction: function(){
      this.result = "loading.."
      axios
        .get(process.env.VUE_APP_API_ROOT + "sample1" + "?param1=123")
        .then(response => {
          console.log(response)
          this.result = response.data
      })
    },
    postAction: function(){
      this.result = "loading.."
      axios
        .post(process.env.VUE_APP_API_ROOT + "sample1", {"var1": "abc"})
        .then(response => {
          console.log(response)
          this.result = response.data
      })
    },
    putAction: function(){
      this.result = "loading.."
      axios
        .put(process.env.VUE_APP_API_ROOT + "sample1/1", {"var2": "def"})
        .then(response => {
          console.log(response)
          this.result = response.data
      })
    },
    deleteAction: function(){
      this.result = "loading.."
      axios
        .delete(process.env.VUE_APP_API_ROOT + "sample1/1")
        .then(response => {
          console.log(response)
          this.result = response.data
      })
    },
  }
}
</script>

client-project/src/PageSample1.vue

<template>
  <div id="main">
    <div>
      サンプル1<br />
      <hr/>
      <button v-on:click="callApi()">API呼び出し</button>
      <div style="margin: 20px;" v-if="status != ''">{{ status }}</div>
      <div style="margin:20px;">{{ message }}</div>
    </div>
  </div>
</template>

<script>
import axios from 'axios';
export default {
  name: 'Sample1',
  data: function(){
    return {
      status: "",
      message: "",
    }
  },
  methods: {
    callApi: function(){
      this.status = "loading.."
      this.message = ""
      let url = process.env.VUE_APP_API_ROOT + "sample1"
      let self = this
      axios
        .get(url)
        .then(response => {
          console.log(response)
          self.status = ""
          self.message = response.data["message"]
        }).catch(error => {
          console.log(error)
          self.status = "api call error!"
        })
    },
  }
}
</script>

client-project/src/PageSample2.vue

<template>
  <div id="main">
    <div>
      サンプル2<br />
      <hr/>
      <button v-on:click="callApi()">API呼び出し</button>
      <div style="margin: 20px;" v-if="status != ''">{{ status }}</div>
      <table id="sample2_table" v-if="items.length > 0">
      <thead>
      <tr>
          <th>Name</th><th>Price</th>
      </tr>
      </thead>
      <tbody>
      <tr v-for="item in items">
        <td>{{ item.name }}</td><td>{{ item.price }}</td>
      </tr>
      </tbody>
      </table>
    </div>
  </div>
</template>

<script>
import axios from 'axios';
export default {
  name: 'Sample2',
  data: function(){
    return {
      status: "",
      items: [],
    }
  },
  methods: {
    callApi: function(){
      this.status = "loading.."
      this.items = []
      let url = process.env.VUE_APP_API_ROOT + "sample2"
      let self = this
      axios
        .get(url)
        .then(response => {
          console.log(response)
          self.status = ""
          self.items = response.data["items"]
        }).catch(function (error) {
          self.status = "api call error!"
        })
    },
  }
}
</script>

<style>
#sample2_table {
  margin-top: 20px;
  border-collapse: collapse;
}
#sample2_table th {
  background: #ccc;
  color: #333;
}
#sample2_table th,
#sample2_table td{
  padding: 5px;
  border: 1px solid #333;
}
#sample2_table tbody tr:nth-child(even) td{
  background: #efe;
}
</style>

動作確認

SAMローカル起動

sam local start-api --template template.yml

Vue.js をローカル起動

cd client-project
npm run serve

http://localhost:8080/ からアクセス確認

デプロイ

Vue.js ビルド

# ビルド
cd client-project
npm run build

# ビルド済みファイルを Lambda 用の src 配下にコピー
rm -rf ../src/client && cp -R dist ../src/client

Lambdaデプロイ

./awsbuild.sh deploy

※ awsbuild.sh の内容は CloudFormation実行用のシェル を参照。


トップ   差分 バックアップ リロード   一覧 単語検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2019-11-07 (木) 19:06:05 (1629d)