#author("2019-10-25T03:01:35+00:00","","") #mynavi(AWSメモ) #setlinebreak(on); * 概要 [#y70c87af] #html(<div style="padding-left: 10px;">) [[1つのAWS Lambda関数でSPAフレームワーク作成]] の別バージョン。 違いは、Lambda の言語が Python、クライアント側が Vue.js である事と、Cloudformationテンプレート化した事ぐらい。 小さなアプリケーションであれば、恐らくこれで十分だと思う。 (Lambdaは 100万リクエスト/月 は無料なので、リクエスト数が少なければS3ホスティングするより安くなる筈) [補足] - css や js をLambdaに内包しておいて、リクエストURLに応じてファイルを返却する。(S3からの配信はしない) - /api/xxx へのリクエストは対象の api の処理を読み込んで実行する。 - いちおう画像も返却できるように API Gateway の BinaryMediaTypes を指定。 #html(</div>) * 目次 [#z4fb8e28] #contents - 関連 -- [[AWSメモ]] -- [[1つのAWS Lambda関数でSPAフレームワーク作成]] -- [[1つのAWS LambdaでSPA(CloudFront編)]] -- [[API Gateway&Lambda で画像データを返却する]] -- [[Vue.js]] * フォルダ構成 [#ce245159] #html(<div style="margin-left: 10px; padding: 5px; border: 1px solid #333;">) #html(<div style="display: inline-block">) /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 #html(</div>) #html(<div style="display: inline-block; padding-left: 20px; vertical-align: top;">) ・・・ ビルド用シェル ( [[CloudFormation実行用のシェル]] ) ・・・ Vue.jsプロジェクト ・・・ 環境変数(Vue.jsローカル動作確認用) ・・・ 環境変数(本番用) ・・・ Lambda用ソース ・・・ この配下にVue.js ビルド後のファイルをコピー ([[後述>#v3eb8199]]) ・・・ Cloudformation テンプレート #html(</div>) #html(</div>) * サーバ側 [#t9f8a647] #html(<div style="padding-left: 10px;">) #html(</div>) ** Cloudformationテンプレート [#b02b96c2] #html(<div style="padding-left: 10px;">) #mycode2(){{ 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 }} #html(</div>) ** src/myenv.py [#oe07cc72] #html(<div style="padding-left: 10px;">) #mycode2(){{ """共通処理(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" }} #html(</div>) ** src/index.py [#d930bf49] #html(<div style="padding-left: 10px;">) #mycode2(){{ """メイン処理.""" 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 } }} #html(</div>) ** src/api/sample1.py [#za201d5e] #html(<div style="padding-left: 10px;">) #mycode2(){{ """サンプル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, } ) } }} #html(</div>) ** src/api/sample2.py [#h3f8846f] #html(<div style="padding-left: 10px;">) #mycode2(){{ """サンプル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) } }} #html(</div>) ** src/client/* [#sd6ecad8] #html(<div style="padding-left: 10px;">) vue.js のビルド済みファイルをこの配下にコピーする。(後述) #html(</div>) * クライアント周りの作成 [#p47adea4] #html(<div style="padding-left: 10px;">) ** プロジェクトの作成 [#r959dd7a] #myterm2(){{ vue create client-project }} ** 設定ファイル [#o9bf2529] ** client-project/.env.development [#a024c309] #mycode2(){{ NODE_ENV="development" VUE_APP_API_ROOT="http://127.0.0.1:3000/api/" }} ※Vue.js をローカル起動している場合は、SAMローカルで動作中のLambdaにアクセスさせる。 ** client-project/.env.production [#kb182de2] #mycode2(){{ NODE_ENV='production' VUE_APP_API_ROOT='./api/' }} ※ API Gateway & Lambda のデプロイ後は、ルート直下の api でアクセス。 ※ ただし、API Gateweay の Stage 名がURLの頭に付加されるケースを想定し、相対PATHで記述。( &color(red){Vue Router を使用する場合は historyモード はOFFにする事}; ) ** client-project/vue.config.js [#o086254c] #mycode2(){{ module.exports = { publicPath: './' } }} ※ API Gateweay の Stage 名がURLの頭に付加されるケースを想定し、相対PATHで記述。 ** client-project/src/main.js [#h7b83b75] #mycode2(){{ 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 [#qa90542d] #mycode2(){{ 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 [#i111bad6] #mycode2(){{ <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 [#e9c2b819] #mycode2(){{ import Vue from 'vue' const EventBus = new Vue() export default EventBus }} ** client-project/src/Header.vue [#vd8484dd] #mycode2(){{ <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 [#o791a366] #mycode2(){{ <template> <div id="footer"> <div>Footer Content</div> </div> </template> <script> export default { name: 'Footer', } </script> }} ** client-project/src/PageTop.vue [#b93e3001] #mycode2(){{ <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 [#d2b54f02] #mycode2(){{ <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 [#sa5a98f2] #mycode2(){{ <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> }} #html(</div>) * 動作確認 [#od9c1e95] #html(<div style="padding-left: 10px;">) ** SAMローカル起動 [#ua42f476] #myterm2(){{ sam local start-api --template template.yml }} ** Vue.js をローカル起動 [#p9deaab9] #myterm2(){{ cd client-project npm run serve }} http://localhost:8080/ からアクセス確認 #html(</div>) * デプロイ [#v3eb8199] #html(<div style="padding-left: 10px;">) Vue.js ビルド #myterm2(){{ # ビルド cd client-project npm run build # ビルド済みファイルを Lambda 用の src 配下にコピー rm -rf ../src/client && cp -R dist ../src/client }} Lambdaデプロイ #myterm2(){{ ./awsbuild.sh deploy }} ※ awsbuild.sh の内容は [[CloudFormation実行用のシェル]] を参照。 #html(</div>) #html(<div style="height:400px;"></div>)