CDKで一行掲示板を作ってみた。ブラウザ ⇔ index.html@S3 ⇔ API Gateway ⇔ lambda(POST,GET) ⇔ DyanamoDB(id,message,created_at)
1, CDKのスタック(AWSサービスの塊)の定義は、TypeScript(mini-cdk-board-stack.ts)
2, 投稿テキストの書込・読込を行うlambdaは、python(handler.py)
3, フロントエンドはhtml+css+js(index.html)
で、実装されている。
こうやってみると広範囲の知識・技術が必要なのが、よく分かる。
フルスタック(バックエンド・フロントエンド) × サーバーレス × IaC(Infrastructure as Code)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
# 任意の作業ディレクトリで mkdir mini-cdk-board && cd mini-cdk-board # CDKプロジェクト生成 cdk init app --language typescript npm i aws-cdk-lib constructs # lambdaファイル作成 mkdir -p src/app touch src/app/handler.py mkdir -p frontend touch frontend/index.html #フォルダ構成 mini-cdk-board/ ├── bin/mini-cdk-board.ts ├── lib/mini_cdk_board-stack.ts ← CDK本体 ├── src/ │ └── app/ │ └── handler.py ← Lambda処理 └── frontend/ └── index.html ← フロントUI |
CDKスタック(lib/mini_cdk_board-stack.ts)
ブラウザ ⇔ index.html@S3 ⇔ API Gateway ⇔ lambda(POST,GET) ⇔ DyanamoDB(id,message,created_at)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
// コメントはすべて日本語で記載 import { Stack, StackProps, Duration, RemovalPolicy, CfnOutput, } from "aws-cdk-lib"; // CDKの基本モジュールをインポート import { Construct } from "constructs"; // Constructクラスを利用するためにインポート import * as lambda from "aws-cdk-lib/aws-lambda"; // Lambda関連 import * as apigw from "aws-cdk-lib/aws-apigateway"; // API Gateway関連 import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; // DynamoDB関連 import * as s3 from "aws-cdk-lib/aws-s3"; // S3関連 import * as s3deploy from "aws-cdk-lib/aws-s3-deployment"; // S3へのデプロイ用モジュール import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; // CloudFront関連 import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; // CloudFrontのオリジン設定 import * as path from "path"; // ファイルパス操作用モジュール // MiniCdkBoardStackクラスを定義(1つのCloudFormationスタックとして構築) export class MiniCdkBoardStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); // ---------------- DynamoDB ---------------- // 投稿データを保存するDynamoDBテーブルを作成 const table = new dynamodb.Table(this, "PostsTable", { partitionKey: { name: "id", type: dynamodb.AttributeType.STRING }, // 主キーを「id」として設定 removalPolicy: RemovalPolicy.DESTROY, // 学習用。削除時にテーブルも削除(本番ではRETAIN推奨) }); // ---------------- Lambda ---------------- // 投稿の作成(POST)と取得(GET)を処理するLambda関数 const fn = new lambda.Function(this, "BoardLambda", { runtime: lambda.Runtime.PYTHON_3_12, // Python 3.12を使用 handler: "handler.handler", // Lambdaハンドラ(src/app/handler.pyのhandler関数を指定) code: lambda.Code.fromAsset(path.join(__dirname, "../src/app")), // コードのディレクトリ指定 timeout: Duration.seconds(5), // タイムアウト5秒 environment: { TABLE_NAME: table.tableName }, // 環境変数にテーブル名を渡す description: "一行掲示板のAPI(POST/GET)", // 関数の説明 }); table.grantReadWriteData(fn); // LambdaにDynamoDBの読み書き権限を付与 // ---------------- API Gateway ---------------- // Lambdaをバックエンドに持つREST APIを作成 const api = new apigw.LambdaRestApi(this, "BoardApi", { handler: fn, // LambdaをAPIのエントリポイントに設定 restApiName: "mini-board-api", // APIの名前 proxy: false, // ルートリソースを明示的に定義するモード defaultCorsPreflightOptions: { allowOrigins: apigw.Cors.ALL_ORIGINS, // すべてのオリジンからアクセス許可 allowMethods: apigw.Cors.ALL_METHODS, // すべてのHTTPメソッド許可 }, }); // postsエンドポイント(例: /posts)を追加 const posts = api.root.addResource("posts"); posts.addMethod("GET"); // GETメソッド(一覧取得) posts.addMethod("POST"); // POSTメソッド(新規投稿) // ---------------- フロントエンド(S3 + CloudFront) ---------------- // 1️⃣ S3バケット作成:静的Webサイトのホスティング用 const websiteBucket = new s3.Bucket(this, "FrontendBucket", { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, // パブリックアクセスを完全にブロック encryption: s3.BucketEncryption.S3_MANAGED, // S3マネージド暗号化を使用 removalPolicy: RemovalPolicy.DESTROY, // 学習用:削除時にバケットも削除 autoDeleteObjects: true, // 学習用:削除時に中のファイルも削除 }); // 2️⃣ CloudFrontとOAI(Origin Access Identity)を設定 // OAIを通してのみS3にアクセスできるようにする const oai = new cloudfront.OriginAccessIdentity(this, "FrontendOAI"); websiteBucket.grantRead(oai); // OAIにS3の読み取り権限を付与 // CloudFrontディストリビューションを作成 const distribution = new cloudfront.Distribution( this, "FrontendDistribution", { defaultBehavior: { origin: new origins.S3Origin(websiteBucket, { originAccessIdentity: oai, // 上記で作成したOAIを使用 }), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, // HTTPアクセスをHTTPSへリダイレクト }, defaultRootObject: "index.html", // デフォルトドキュメントをindex.htmlに設定 errorResponses: [ { httpStatus: 403, // 403エラー時もindex.htmlを返す(SPA対策) responseHttpStatus: 200, responsePagePath: "/index.html", ttl: Duration.seconds(0), }, { httpStatus: 404, // 404エラー時もindex.htmlを返す responseHttpStatus: 200, responsePagePath: "/index.html", ttl: Duration.seconds(0), }, ], } ); // 3️⃣ フロントエンドのHTML/JS/CSSをS3にデプロイ new s3deploy.BucketDeployment(this, "DeployFrontend", { sources: [s3deploy.Source.asset("frontend")], // frontendディレクトリをアップロード destinationBucket: websiteBucket, // アップロード先バケット distribution, // CloudFrontディストリビューションを指定 distributionPaths: ["/*"], // デプロイ後にキャッシュを無効化 }); // --- APIのURLを書き込んだconfig.json を生成してアップロード --- // API Gatewayのエンドポイントを動的に埋め込んだ設定ファイルを作成 const configContent = JSON.stringify({ apiUrl: api.url, // デプロイ後のAPI URLを自動埋め込み }); new s3deploy.BucketDeployment(this, "DeployFrontendAndConfig", { destinationBucket: websiteBucket, sources: [ s3deploy.Source.asset("frontend"), s3deploy.Source.data("config.json", configContent), // 同梱 ], distribution, distributionPaths: ["/*"], // まとめて無効化 cacheControl: [ s3deploy.CacheControl.fromString("public, max-age=31536000"), // 静的資産は長め ], // config.json だけ無キャッシュにしたいときは、別DeploymentでAのやり方を追加 }); // ---------------- 出力 ---------------- // デプロイ後にターミナル上に出力する値(CloudFormation Outputs) new CfnOutput(this, "ApiUrl", { value: api.url ?? "", // API GatewayのURL description: "掲示板APIのURL", // 出力の説明 }); new CfnOutput(this, "FrontendUrl", { value: `https://${distribution.domainName}`, // CloudFront経由の公開URL description: "CloudFrontで配信される掲示板フロントURL", // 出力の説明 }); } } |
Lambda(src/app/handler.py)
単純に、POSTで1行テキストをDynamoDBに格納して、GETで投稿済みテキストを全部取得
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
# 必要な標準・外部モジュールをインポート import json # JSONのエンコード/デコードに使用 import os # 環境変数(TABLE_NAME)取得に使用 import boto3 # AWS SDK for Python(DynamoDB操作に使用) import uuid # 一意なID生成に使用 import datetime # 現在時刻(作成日時)を取得するために使用 # DynamoDBリソースを初期化 dynamodb = boto3.resource("dynamodb") # 環境変数に設定されたテーブル名を取得して、DynamoDBテーブルオブジェクトを生成 table = dynamodb.Table(os.environ["TABLE_NAME"]) # Lambda関数のエントリポイント def handler(event, context): # HTTPメソッド(GET or POSTなど)を取得 method = event["httpMethod"] # ------------------- POSTメソッド(新規投稿) ------------------- if method == "POST": # リクエストボディをJSONとしてパース body = json.loads(event["body"]) # 登録するアイテムを作成 item = { "id": str(uuid.uuid4()), # 一意のIDを生成 "message": body.get("message", ""), # 投稿内容(空文字対策あり) "createdAt": datetime.datetime.utcnow().isoformat() # 現在時刻(UTC)をISOフォーマット文字列で保存 } # DynamoDBに新しい投稿を登録 table.put_item(Item=item) # 登録した内容をそのままレスポンスとして返す return _res(200, item) # ------------------- GETメソッド(投稿一覧の取得) ------------------- elif method == "GET": # テーブル全件をスキャンして取得(学習用の簡易実装) resp = table.scan() # createdAt(作成日時)で降順ソート(新しい投稿が上に来る) items = sorted(resp["Items"], key=lambda x: x["createdAt"], reverse=True) # ソート済みのリストを返す return _res(200, items) # ------------------- その他のHTTPメソッドは拒否 ------------------- else: # サポート外メソッドの場合は405エラーを返す return _res(405, {"error": "Method Not Allowed"}) # 共通のレスポンス整形関数 def _res(code, body): return { "statusCode": code, # HTTPステータスコード(例: 200, 400, 405など) "headers": { "content-type": "application/json", # レスポンスの形式 "access-control-allow-origin": "*" # CORS対応(全オリジン許可) }, "body": json.dumps(body, ensure_ascii=False) # JSON文字列として返す(日本語対応) } |
フロントエンド(frontend/index.html)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8" /> <!-- 日本語文字化け防止 --> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- スマホ対応(画面幅に合わせる) --> <title>一行掲示板</title> <style> /* 全体の基本スタイル */ body { font-family: sans-serif; max-width: 600px; /* 中央寄せ+幅制限 */ margin: 2em auto; padding: 1em; background: #fafafa; } h1 { text-align: center; color: #333; } form { display: flex; margin-bottom: 1em; } input[type="text"] { flex: 1; /* 横幅を残り全部に広げる */ padding: 0.5em; font-size: 1em; } button { padding: 0.5em 1em; margin-left: 0.5em; } ul { list-style: none; /* 箇条書きマークを消す */ padding: 0; } li { background: white; border: 1px solid #ddd; margin-bottom: 0.5em; padding: 0.5em; border-radius: 4px; /* 角を丸くする */ } .time { font-size: 0.8em; color: #666; } </style> </head> <body> <h1>一行掲示板</h1> <!-- 投稿フォーム --> <form id="postForm"> <input id="messageInput" type="text" placeholder="投稿内容を入力..." required /> <button type="submit">送信</button> </form> <!-- 投稿一覧表示用のリスト --> <ul id="posts"></ul> <script> // ---------------------------- // 1️⃣ APIのURLを格納する変数 // ---------------------------- let API_URL = ""; // ---------------------------- // 2️⃣ 設定ファイル(config.json)からAPI GatewayのURLを取得 // ---------------------------- fetch("config.json") .then(res => res.json()) // JSONとして読み取る .then(config => { // 取得したAPIエンドポイントを変数に格納 API_URL = config.apiUrl + "/posts"; // 投稿一覧を初回読み込み loadPosts(); }) .catch(err => { // config.jsonが存在しない or 読み込み失敗時 alert("設定ファイル(config.json)の読み込みに失敗しました"); console.error(err); }); // ---------------------------- // 3️⃣ 投稿フォームの送信イベント // ---------------------------- document.getElementById("postForm").addEventListener("submit", async (e) => { e.preventDefault(); // ページリロードを防止 // 入力欄から投稿内容を取得 const message = document.getElementById("messageInput").value.trim(); if (!message || !API_URL) return; // 未入力 or 設定なしなら終了 // APIにPOSTリクエストを送信 await fetch(API_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message }) // 投稿データをJSON化 }); // 入力欄を空に戻す document.getElementById("messageInput").value = ""; // 投稿一覧を再読み込み await loadPosts(); }); // ---------------------------- // 4️⃣ 投稿一覧を読み込む関数 // ---------------------------- async function loadPosts() { if (!API_URL) return; // 投稿一覧をAPIから取得 const res = await fetch(API_URL); const posts = await res.json(); // 一覧表示部分を初期化 const ul = document.getElementById("posts"); ul.innerHTML = ""; // 各投稿をリストに追加 for (const p of posts) { const li = document.createElement("li"); // UTCで保存された日時をJST(日本時間)に変換 const date = new Date(p.createdAt); const jst = new Date(date.getTime() + 9 * 60 * 60 * 1000); // UTC→JST(+9時間) // 投稿内容と日時をHTMLとして埋め込み li.innerHTML = ` <div>${p.message || "(空白)"}</div> <div class="time">{jst.toLocaleString("ja-JP")}</div> `; // liをulに追加 ul.appendChild(li); } } </script> </body> </html> |
|
1 2 3 4 5 6 7 8 |
cdk bootstrap cdk deploy ..... MiniCdkBoardStack.ApiUrl = https://aaaaaaaaaaa.execute-api.ap-northeast-1.amazonaws.com/prod/ MiniCdkBoardStack.BoardApiEndpoint827529B2 = https://aaaaaaaaaaa.execute-api.ap-northeast-1.amazonaws.com/prod/ MiniCdkBoardStack.FrontendUrl = https://xxxxx.cloudfront.net |
出力されたURLでindex.html@S3にアクセスできる。
FrontendUrl = https://xxxxx.cloudfront.net
生成されたAPIを使ってPOST,GETも出来る
|
1 2 3 4 |
# 投稿 curl -X POST -H "content-type: application/json" -d '{"message":"test post"}' https://aaaaaaaaaaa.execute-api.ap-northeast-1.amazonaws.com/prod/posts # 一覧取得 curl https://aaaaaaaaaaa.execute-api.ap-northeast-1.amazonaws.com/prod/posts |