【AWS×Java】SAMで冷蔵庫管理アプリを構築 #4 CloudFront + S3編
はじめに
当記事の最終ゴールについては以下の記事をご確認ください。
今回のゴール:CloudFront + S3 でWebアプリケーションを配信できる
前提読者:AWS初心者、開発初心者
また、今回は以下「【AWS×Java】SAMで冷蔵庫管理アプリを構築 #3 Cognito編」を実施済みの前提で進めます。
以下を実施することで従量課金が発生します。その点については自己責任でお願いします。無料枠があるが上限があります。削除することで課金を止めることが可能です。
また、本記事は学習ログです。詳細は公式ドキュメントを適宜参照してください。
アーキテクチャ概要
今回は以下赤枠部分を完成させます。
図1: サーバレス構成の全体像
CloudFront + S3を設定
前回まではApplication Composerを中心に構築しましたが、今回はマネジメントコンソール上で設定を進めていきます。当記事はAWSの解説を中心とするため、フロント側のHTML・css・JavaScriptの解説は割愛します。(私も生成AIをふんだんに使用してフロント画面を構築しています)
まずは今回使用するAWSの概要を説明します。
Amazon Simple Storage Service (S3) は、AWS が提供するオブジェクトストレージサービスです。今回S3を使って静的ウェブサイトのホスティングを実施しますが、それ以外にもバックアップやログ保存などの用途でも使用できます。
Amazon CloudFront は、AWSのコンテンツ配信ネットワーク(CDN)サービスです。デフォルトで HTTPS配信に対応しており、キャッシュを利用してS3へのアクセス回数を削減できるなどのメリットがあります。
平たく言うとS3は「保存場所」、CloudFrontは「配信の窓口+高速化+セキュリティ」を担っています。
S3バケットを作成/ファイルアップロード
AWSマネジメントコンソールの検索窓から「S3」を検索します。開くと画面の左側にある汎用バケットを押下してください。
その後、「バケットを作成」を押下してください。
その後、バケット名の設定をしてください。名前は自由ですが、今回は「fridge-app2-frontend」としました。
デフォルトのままであれば問題ないはずですが、「ACL無効」「パブリックアクセスをすべてブロック」になっていることを確認してください。
バケット作成ができると空のバケットができていると思いますので必要なファイルをアップロードします。
改めてgithubに整理して載せますが、当記事執筆時点で整理できていないので当記事にアップロード対象の内容を直接記載します。
フォルダ構成は以下の通りです。
backend/
frontend/
index.html
js/app.js
css/style.css
config/env.js
各ファイルの詳細は以下の通りです。
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>冷蔵庫アプリ</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<!-- Font Awesome for icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="mb-0 text-primary">冷蔵庫アプリ</h1>
<button id="signOutButton" class="btn btn-outline-secondary btn-sm">ログアウト</button>
</div>
<div id="appSection" class="card p-4 shadow-sm">
<h2 class="card-title mb-4 text-center">
<!-- <span id="usernameDisplay"></span> さんの冷蔵庫 -->
</h2>
<div class="row g-4">
<div class="col-12 col-md-5">
<div class="card p-4 shadow-sm h-100">
<h3 class="card-title mb-3">新しいアイテムを追加</h3>
<div id="addMessage" class="message"></div>
<div class="mb-3">
<label for="itemName" class="form-label">アイテム名</label>
<input type="text" class="form-control" id="itemName" placeholder="例: 牛乳" />
</div>
<div class="mb-3">
<label for="itemCount" class="form-label">数量</label>
<input type="number" class="form-control" id="itemCount" value="1" min="0" />
</div>
<button id="addItemBtn" class="btn btn-success w-100 mt-2">アイテムを追加</button>
</div>
</div>
<div class="col-12 col-md-7">
<div class="card p-4 shadow-sm h-100 d-flex flex-column">
<h3 class="card-title mb-3 text-center">冷蔵庫の中身</h3>
<div class="d-flex justify-content-between align-items-center mb-3">
<div id="listMessage" class="message"></div>
<button id="refreshItemsBtn" class="btn btn-secondary btn-sm"><i class="fas fa-sync-alt"></i> 一覧を更新</button>
</div>
<!-- Scroll buttons for the table -->
<div class="d-flex justify-content-center mb-2">
<button id="scrollUpBtn" class="btn btn-outline-primary btn-sm me-2"><i class="fas fa-chevron-up"></i></button>
<button id="scrollDownBtn" class="btn btn-outline-primary btn-sm"><i class="fas fa-chevron-down"></i></button>
</div>
<!-- Table Container with custom scroll -->
<div class="table-container flex-grow-1">
<table class="table table-striped mb-0" id="fridgeItemsTable">
<thead>
<tr>
<th>アイテム名</th>
<th>数量</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<!-- Item rows will be inserted here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<div id="edit-item-section" class="card p-4 mt-4 shadow-sm" style="display: none;">
<h3 class="card-title mb-3">アイテムを編集</h3>
<div id="editMessage" class="message"></div>
<input type="hidden" id="editItemId" />
<div class="mb-3">
<label for="editItemName" class="form-label">アイテム名</label>
<input type="text" class="form-control" id="editItemName" />
</div>
<div class="mb-3">
<label for="editItemCount" class="form-label">数量</label>
<input type="number" class="form-control" id="editItemCount" min="0" />
</div>
<div class="d-flex justify-content-end">
<button id="saveEditBtn" class="btn btn-primary me-2">変更を保存</button>
<button id="cancelEditBtn" class="btn btn-secondary">キャンセル</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/config/env.js"></script>
<script src="js/app.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tableContainer = document.querySelector('.table-container');
const scrollUpBtn = document.getElementById('scrollUpBtn');
const scrollDownBtn = document.getElementById('scrollDownBtn');
scrollUpBtn.addEventListener('click', function() {
tableContainer.scrollTop -= 50; // Adjust scroll amount as needed
});
scrollDownBtn.addEventListener('click', function() {
tableContainer.scrollTop += 50; // Adjust scroll amount as needed
});
});
</script>
</body>
</html>
style.css
body {
font-family: Arial, sans-serif;
background-color: #f8f9fa;
color: #495057;
margin: 0;
padding: 0;
}
.container {
max-width: 900px;
}
.card {
border-radius: 12px;
border: none;
}
h1, h2, h3 {
color: #343a40;
font-weight: bold;
}
/* Flexbox for the table container to make it a scrollable area */
.table-container {
overflow-y: auto;
max-height: 40vh; /* Adjust height as needed, using viewport height for responsiveness */
border: 1px solid #e9ecef;
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px 15px;
text-align: left;
vertical-align: middle;
}
th {
background-color: #f1f3f5;
position: sticky;
top: 0;
z-index: 10;
}
/* Mobile-first approach for tables */
@media (max-width: 767.98px) {
th, td {
padding: 8px 10px;
font-size: 14px;
}
}
.btn {
border-radius: 50rem;
padding: 10px 20px;
font-weight: bold;
}
.btn-success {
background-color: #28a745;
border-color: #28a745;
}
.btn-success:hover {
background-color: #218838;
border-color: #1e7e34;
}
.btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
}
.btn-secondary:hover {
background-color: #5a6268;
border-color: #545b62;
}
.btn-primary {
background-color: #007bff;
border-color: #007bff;
}
.btn-primary:hover {
background-color: #0069d9;
border-color: #0062cc;
}
.btn-sm {
padding: 6px 12px;
font-size: 14px;
}
.message {
padding: 10px;
border-radius: 8px;
margin-bottom: 1rem;
display: none;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
display: block;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
display: block;
}
app.js
// --- 設定値 ---
const { COGNITO_DOMAIN, CLIENT_ID, REDIRECT_URI, API_ENDPOINT } = window.ENV;
const LOGOUT_URI = REDIRECT_URI;
const SCOPE = 'openid email';
// --- PKCEユーティリティ ---
function base64URLEncode(str) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function sha256(buffer) {
const digest = await crypto.subtle.digest('SHA-256', buffer);
return new Uint8Array(digest);
}
function generateRandomString(length) {
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
return Array.from(array, dec => ('0' + dec.toString(16)).substr(-2)).join('');
}
// --- PKCEコード生成・保存 ---
async function createPKCECodes() {
const codeVerifier = generateRandomString(64);
const codeChallenge = base64URLEncode(await sha256(new TextEncoder().encode(codeVerifier)));
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
return codeChallenge;
}
function getPKCEVerifier() {
return sessionStorage.getItem('pkce_code_verifier');
}
// --- 認証状態管理 ---
function getIdToken() {
return sessionStorage.getItem('id_token');
}
function setIdToken(token) {
sessionStorage.setItem('id_token', token);
}
function clearAuth() {
sessionStorage.removeItem('id_token');
sessionStorage.removeItem('pkce_code_verifier');
}
// --- Hosted UIリダイレクト ---
async function redirectToLogin() {
const codeChallenge = await createPKCECodes();
const url = `https://${COGNITO_DOMAIN}/oauth2/authorize?response_type=code&client_id=${CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
`&scope=${SCOPE}` +
`&code_challenge_method=S256&code_challenge=${codeChallenge}`;
window.location.href = url;
}
function redirectToLogout() {
clearAuth();
const url = `https://${COGNITO_DOMAIN}/logout?client_id=${CLIENT_ID}&logout_uri=${encodeURIComponent(LOGOUT_URI)}`;
window.location.href = url;
}
// --- トークン取得(認可コード→IDトークン) ---
async function exchangeCodeForToken(code) {
const codeVerifier = getPKCEVerifier();
const params = new URLSearchParams();
params.append('grant_type', 'authorization_code');
params.append('client_id', CLIENT_ID);
params.append('code', code);
params.append('redirect_uri', REDIRECT_URI);
params.append('code_verifier', codeVerifier);
const res = await fetch(`https://${COGNITO_DOMAIN}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
if (!res.ok) throw new Error('トークン取得失敗');
const data = await res.json();
setIdToken(data.id_token);
// 認証後はクエリパラメータを消してリロード
window.history.replaceState({}, document.title, REDIRECT_URI);
}
// --- API呼び出し ---
async function callApi(path, method = 'GET', body = null) {
const idToken = getIdToken();
if (!idToken) throw new Error('未認証です');
const headers = { 'Authorization': `Bearer ${idToken}`, 'Content-Type': 'application/json' };
const res = await fetch(API_ENDPOINT + path, {
method,
headers,
body: body ? JSON.stringify(body) : undefined
});
if (!res.ok) throw new Error(await res.text());
return await res.json();
}
// --- UIロジック ---
const itemNameInput = document.getElementById('itemName');
const itemCountInput = document.getElementById('itemCount');
const addItemBtn = document.getElementById('addItemBtn');
const addMessage = document.getElementById('addMessage');
const refreshItemsBtn = document.getElementById('refreshItemsBtn');
const fridgeItemsTableBody = document.querySelector('#fridgeItemsTable tbody');
const listMessage = document.getElementById('listMessage');
const editItemSection = document.getElementById('edit-item-section');
const editItemIdInput = document.getElementById('editItemId');
const editItemNameInput = document.getElementById('editItemName');
const editItemCountInput = document.getElementById('editItemCount');
const saveEditBtn = document.getElementById('saveEditBtn');
const cancelEditBtn = document.getElementById('cancelEditBtn');
const editMessage = document.getElementById('editMessage');
const appSection = document.getElementById('appSection');
const signOutButton = document.getElementById('signOutButton');
const usernameDisplay = document.getElementById('usernameDisplay');
function showMessage(element, msg, type) {
element.textContent = msg;
element.className = `alert ${type === 'success' ? 'alert-success' : 'alert-danger'} d-block`;
setTimeout(() => {
element.className = 'alert d-none';
element.textContent = '';
}, 5000);
}
// --- 認証状態チェック・初期化 ---
async function checkAndInitializeAuth() {
const params = new URLSearchParams(window.location.search);
if (params.has('code')) {
try {
await exchangeCodeForToken(params.get('code'));
} catch (e) {
alert('認証エラー: ' + e.message);
return redirectToLogin();
}
}
if (!getIdToken()) {
return redirectToLogin();
}
appSection.style.display = 'block';
usernameDisplay.textContent = 'ユーザー'; // JWTからデコードしてemail等を表示したい場合は要実装
fetchAndDisplayItems();
}
signOutButton.addEventListener('click', () => {
redirectToLogout();
});
addItemBtn.addEventListener('click', async () => {
const name = itemNameInput.value.trim();
const count = parseInt(itemCountInput.value, 10);
if (!name) {
showMessage(addMessage, 'アイテム名を入力してください。', 'error');
return;
}
if (isNaN(count) || count < 0) {
showMessage(addMessage, '数量は0以上の数値を入力してください。', 'error');
return;
}
try {
await callApi('/items', 'POST', { name, count });
showMessage(addMessage, `アイテム「${name}」を追加しました!`, 'success');
itemNameInput.value = '';
itemCountInput.value = '1';
fetchAndDisplayItems();
} catch (error) {
showMessage(addMessage, `追加エラー: ${error.message}`, 'error');
}
});
async function fetchAndDisplayItems() {
fridgeItemsTableBody.innerHTML = '<tr><td colspan="3">読み込み中...</td></tr>';
showMessage(listMessage, '', '');
try {
const payload = await callApi('/items', 'GET');
const items = Array.isArray(payload) ? payload : (payload.items || []);
fridgeItemsTableBody.innerHTML = '';
if (items.length === 0) {
fridgeItemsTableBody.innerHTML = '<tr><td colspan="3">冷蔵庫にアイテムはありません。</td></tr>';
} else {
items.forEach(item => {
const row = fridgeItemsTableBody.insertRow();
row.insertCell().textContent = item.name;
row.insertCell().textContent = item.count;
const actionsCell = row.insertCell();
const editButton = document.createElement('button');
editButton.textContent = '編集';
editButton.className = 'edit-btn btn btn-sm btn-primary me-2';
editButton.dataset.id = item.id;
editButton.dataset.name = item.name;
editButton.dataset.count = item.count;
actionsCell.appendChild(editButton);
const deleteButton = document.createElement('button');
deleteButton.textContent = '削除';
deleteButton.className = 'delete-btn btn btn-sm btn-danger';
deleteButton.dataset.id = item.id;
actionsCell.appendChild(deleteButton);
});
}
showMessage(listMessage, 'アイテム一覧を更新しました。', 'success');
} catch (error) {
showMessage(listMessage, `ネットワークエラー: ${error.message}`, 'error');
fridgeItemsTableBody.innerHTML = '<tr><td colspan="3">ネットワークエラーにより読み込みできませんでした。</td></tr>';
}
}
saveEditBtn.addEventListener('click', async () => {
const itemId = editItemIdInput.value;
const name = editItemNameInput.value.trim();
const count = parseInt(editItemCountInput.value, 10);
if (!name) {
showMessage(editMessage, 'アイテム名を入力してください。', 'error');
return;
}
if (isNaN(count) || count < 0) {
showMessage(editMessage, '数量は0以上の数値を入力してください。', 'error');
return;
}
try {
await callApi(`/items/${itemId}`, 'PUT', { name, count });
showMessage(editMessage, `アイテム「${name}」を更新しました!`, 'success');
editItemSection.style.display = 'none';
fetchAndDisplayItems();
} catch (error) {
showMessage(editMessage, `ネットワークエラー: ${error.message}`, 'error');
}
});
cancelEditBtn.addEventListener('click', () => {
editItemSection.style.display = 'none';
showMessage(editMessage, '', '');
});
fridgeItemsTableBody.addEventListener('click', async (event) => {
if (event.target.classList.contains('delete-btn')) {
const itemId = event.target.dataset.id;
const itemName = event.target.closest('tr').children[0].textContent;
confirmAndDeleteItem(itemId, itemName);
} else if (event.target.classList.contains('edit-btn')) {
const itemId = event.target.dataset.id;
const itemName = event.target.dataset.name;
const itemCount = event.target.dataset.count;
editItemIdInput.value = itemId;
editItemNameInput.value = itemName;
editItemCountInput.value = itemCount;
editItemSection.style.display = 'block';
window.scrollTo(0, document.body.scrollHeight);
}
});
async function confirmAndDeleteItem(itemId, itemName) {
if (confirm(`「${itemName}」を本当に削除しますか?`)) {
try {
await callApi(`/items/${itemId}`, 'DELETE');
showMessage(listMessage, `アイテム「${itemName}」を削除しました!`, 'success');
fetchAndDisplayItems();
} catch (error) {
showMessage(listMessage, `削除エラー: ${error.message}`, 'error');
}
}
}
document.addEventListener('DOMContentLoaded', checkAndInitializeAuth);
refreshItemsBtn.addEventListener('click', fetchAndDisplayItems);
env.js
window.ENV = {
COGNITO_DOMAIN: "<your-domain>.auth.ap-northeast-1.amazoncognito.com",
CLIENT_ID: "<APP_CLIENT_ID>",
REDIRECT_URI: "https://<front-domain>/index.html",
API_ENDPOINT: "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod"
};
「env.js」の設定内容は後ほどお伝えします。
上記のファイルをアップロードします。(frontend配下のファイルをフォルダも含めてドラッグアンドドロップでアップロードできます)
アップロードが問題なければ右下の「アップロードボタン」を押下してください。
CloudFrontディストリビューションを作成
①Get started
AWSマネジメントコンソールの検索窓から「CloudFront」を検索します。開くと画面の左側にあるディストリビューションを押下してください。
右上にある「ディストリビューションを作成」を押下してください。
「Distribution name」の入力をしてください。今回は「FridgeApp2」としました。
それ以外はデフォルトの設定のままで、右下の「Next」を押下してください。
②Specify origin
ここで配信対象のコンテンツが保存されている場所を選択します。今回はS3を選択しています。
「Browse S3」を押下します。
先ほど作成した「fridge-app2-frontend」を選択し、右下のChooseを押下します。
それ以外はデフォルトの設定のままで、右下の「Next」を押下してください。
③Enable security
ここでWeb Application Firewall (WAF) の設定をします。WAFを有効にするとリクエストに対して料金が発生します。ただ、有効にすることで悪意のあるリクエストがブロックされます。
この点を設定するかどうかは読者の皆様にお任せします。
(長期的な運用を検討されている場合は有効にするほうがいいかと思います。個人学習用ですぐ廃止する場合などは有効にする必要はないかもしれません。)
4Review and create
上記①~③の最終確認を行い、問題なければ右下の「Create distribution」を押下してください。
作成後、以下のDomain nameをコピーしておいてください。
URLの修正
上記で作成したDomain nameがWebアプリケーションにアクセスするためのURLになります。上記URLを必要なところに設定します。
env.jsの修正
以下4つの環境変数を読者の皆様の環境に合わせて設定する必要があります。
window.ENV = {
COGNITO_DOMAIN: "<your-domain>.auth.ap-northeast-1.amazoncognito.com",
CLIENT_ID: "<APP_CLIENT_ID>",
REDIRECT_URI: "https://<front-domain>/index.html",
API_ENDPOINT: "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod"
};
COGNITO_DOMAINとCLIENT_IDはマネジメントコンソールの「Cognito」から確認できます。
・Cognitoドメイン(「https://」は不要です)
・クライアントID
REDIRECT_URIはCloudFrontでコピーした「Domain name」を設定してください。
・リダイレクトURI
API_ENDPOINTはAPI Gatewayの「ステージ」から確認できます。
・APIエンドポイント
これらが正しく設定できたら再度「env.js」をS3にアップロードしてください。
(2025/10/5追記)
キャッシュの削除
キャッシュの削除
今後もS3上のファイルを修正することがあると思います。S3上のファイルを修正してもCloudFront上にキャッシュが残っていると修正前のファイル状態になっています。そのためキャッシュを削除する方法をお伝えします。
AWSマネジメントコンソールの検索窓から「CloudFront」を検索します。開くと画面の左側にあるディストリビューションを押下してください。その後、先ほど作成したディストリビューションを選択してください。
キャッシュ削除を選択し、「キャッシュ削除を作成」を押下してください。
オブジェクトパスを追加し、「キャッシュ削除を作成」を押下してください。
(今回は「/config/env.js」を設定していますが、複数対象がある場合は複数指定してください)
template.yamlとApp.javaの修正
前回はローカル環境で検証したので「http://localhost:5173」が設定されている箇所があるかと思います。そこにCloudFrontでコピーした「Domain name」を設定してください。
Globals:
Function:
Timeout: 20
MemorySize: 512
Api:
Cors:
- AllowOrigin: '''http://localhost:5173'''
+ AllowOrigin: '''https://<front-domain>'''
AllowHeaders: '''Content-Type,Authorization'''
AllowMethods: '''GET,POST,PUT,DELETE,OPTIONS'''
(省略)
CallbackURLs:
- - http://localhost:5173/callback
+ - https://<front-domain>/index.html
LogoutURLs:
- - http://localhost:5173
+ - https://<front-domain>
public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) {
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/json");
headers.put("X-Custom-Header", "application/json");
- headers.put("Access-Control-Allow-Origin", "http://localhost:5173");
+ headers.put("Access-Control-Allow-Origin", "https://<front-domain>");
これでtemplate.yamlとApp.javaの修正は完了です。
ビルド&デプロイ
backendフォルダ配下でビルド&デプロイしてください。
cd backend
sam build
sam deploy
動作確認
ブラウザでREDIRECT_URIにアクセス
「https://<front-domain>/index.html」にアクセスするとログイン画面が出てくると思います。前回の記事でアカウント登録していない方はアカウント登録してください。もしアカウント登録済みならログインしてください。
問題なくログインできれば以下のような画面が表示されると思います。
アイテム追加
データベースへの追加・変更・削除が正しく機能するか確認します。まずはアイテム追加。
今回は牛乳を2つ追加します。アイテム名と数量を入力した後に「アイテムを追加」を押下します。
正しくアイテムが追加されました。
アイテム変更
牛乳を編集します。編集ボタンを押下します。
くさった牛乳を3つにします。
正しくアイテムが変更されました。
アイテム削除
くさった牛乳を削除します。削除ボタンを押下します。
正しく削除されました。
アイテム読込
今回は実施しませんが、気になる方は以下のような方法で確認してみてください。
例えばスマホとPCでログインし、スマホ側でアイテム追加したあと、PC側で「一覧を更新」を押下するとアイテムが読み込めると思います。
今後の予定
次回は未知の領域ですが、CI/CDにチャレンジしてみます!
Discussion