AWS Amplify + AWS CDK で人生初のアプリケーションを作ってみた (第3回)

こんにちは。SCSK渡辺(大)です。

運動不足のせいなのか疲れが取れにくくなってきました。
ストレッチとウォーキングから始めて、徐々に体力を付けないといけないな、などと考えていたはずが、
気が付いたら椅子に座ってPCを開いてブログを書いていました…。

今回は、AWS Amplify+AWS CDKで人生初のアプリケーションを作ってみました。の第3回です。

どんなアプリケーションを構築したのか

こんなアプリケーションを構築しました

動画にしてみました。
※マスキング処理はアプリケーションとは関係ありません。

 

アーキテクチャ

非常にシンプルです。シンプルすぎるため絵は作成しませんでした。
補足するとしたら、Amazon API GatewayとAWS Lambdaは第2回にてAWS CDKで作りました。

ユーザー ⇔ AWS Amplify(Amazon Cognito含む) ⇔ Amazon API Gateway ⇔ AWS Lambda

 

ディレクトリ・ファイルの構成

直接修正していないものや記事の内容として不要なものは外しました。
(作り終えてから記事を書いてて思いましたが、Home.tsxも/src/pagesに移動したほうが綺麗でした。)

.
├── amplify
│ ├── auth
│ │ └── resource.ts
│ ├── data
│ │ └── resource.ts
│ └── backend.ts
├── src
│ ├── pages
│ │ ├── Costcheck.tsx
│ │ ├── Game.tsx
│ │ └── Login.tsx
│ ├── App.css
│ ├── Home.tsx
│ ├── index.css
│ └── main.tsx
├── .env
└── index.html

 

メイン機能の実装について

APIのレスポンス

前提として、第2回で書いたようにAPIのレスポンスは以下の形で返ってきます。

 

画像だと見づらく分かりづらいので、テキストでも置いておきます。

{
  "total_cost": "$[合計コスト]",
  "period": "[コスト確認期間の開始日] to [コスト確認期間の終了日]",
  "top_services": [
    {
      "service": "[コストが1番目に高いサービス名]",
      "cost": "$[上記のコスト]"
    },
    {
      "service": "[コストが2番目に高いサービス名]",
      "cost": "$[上記のコスト]"
    },
    {
      "service": "[コストが3番目に高いサービス名]",
      "cost": "$[上記のコスト]"
    }
  ]
}

 

フロントエンド側のコードと処理内容

上記をフロントエンド側で拾った上で、画面に表示させます。
APIに関する処理にのみコメントを入れました。

 

/src/pages/Costcheck.tsx
import '../App.css'
import { Authenticator } from '@aws-amplify/ui-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

# APIから取得するデータの型を定義
interface CostData {
  total_cost: number;
  period: string;
  top_services: Array<{
    service: string;
    cost: number;
  }>;
}

const Costcheck = () => {
  # 状態管理
  const [costData, setCostData] = useState<CostData | null>(null);   # APIから取得したデータを保持
  const [loading, setLoading] = useState(false);                     # API通信中かどうかを判定
  const [error, setError] = useState<string | null>(null);           # エラー発生時にエラーメッセージを表示するための状態

  const navigate = useNavigate();

  #APIリクエスト
  const fetchCostData = async () => {
    setLoading(true);   # ローディング状態を開始
    setError(null);     # エラー発生時はエラーメッセージを設定
    try {
      const response = await fetch('https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/'); #APIを呼び出す
      const data = await response.json();       # レスポンスをJSONに変換
      setCostData(data);                        # データを保管
    } catch (err) {
      setError('データの取得に失敗しました');    # エラー発生時はエラーメッセージを設定
    } finally {
      setLoading(false);                       # ローディング状態を終了
    }
  };

  return (
    <Authenticator>
      {({ signOut }) => (
        <>
          <h1>コストを確認する</h1>
          <div className="card">
            <button onClick={fetchCostData}>APIを実行</button>
            {loading && <p>読み込み中...</p>}
            {error && <p>{error}</p>}
            {costData && (
              <div>
                <h2>総コスト: {costData.total_cost}</h2>
                <p>期間: {costData.period}</p>
                <h3>トップサービス</h3>
                <ul>
                  {costData.top_services.map((service, index) => (
                    <li key={index}>
                      {service.service}: {service.cost}
                    </li>
                  ))}
                </ul>
              </div>
            )}
          </div>
          <p></p>
          <button onClick={() => navigate('/Home')}>ホームに戻る</button>
          <p></p>
          <button onClick={signOut}>サインアウト</button>
        </>
      )}
    </Authenticator>
  );
};

export default Costcheck;

 

疑問に感じたこと

「レスポンスはJSONで返ってきているように思えるが、なぜ改めて変換する必要があるのか?」と疑問を感じました。

      const data = await response.json(); #レスポンスをJSONに変換

生成AIに確認した結果、必要との回答を貰いました。
理由は、レスポンスはJSONではなくResponse オブジェクトという形で返ってくるため、そのままではJavaScriptのオブジェクトとして扱えません、とのことでした。
色々な情報がくっついているのですね。

 

その他のコードについて

第2回のAWS CDKのプロジェクトは何も変更していません。
また、Amplifyのプロジェクトも変更していません。
その他、第1回から変更したファイルの最終形をご紹介します。

 

/src/main.tsx

React Routerフックの定義(ルート情報)をLogin.tsxからmain.tsxに引っ越しました。
(調べた感じですと、index.tsxで定義するほうが一般的なようです。)

import './index.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Login from '../src/pages/Login.tsx'
import Home from './Home.tsx'
import Costcheck from '../src/pages/Costcheck.tsx'
import Game from '../src/pages/Game.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Login />} />
        <Route path="/Home" element={<Home />} />
        <Route path="/Costcheck" element={<Costcheck />} />
        <Route path="/Game" element={<Game />} />
      </Routes>
    </BrowserRouter>
  </StrictMode>,
)

 

/src/pages/Login.tsx

main.tsxに引っ越したルート情報をuseNavigateで使用するようにしました。

import '../App.css'
import { Authenticator, translations } from "@aws-amplify/ui-react";
import "@aws-amplify/ui-react/styles.css";
import { I18n } from "aws-amplify/utils"
import { Amplify } from "aws-amplify";
import { ResourcesConfig } from "@aws-amplify/core";
import { useNavigate } from 'react-router-dom';

const userPoolClientId = import.meta.env.VITE_REACT_APP_USER_POOL_CLIENT_ID;
const userPoolId = import.meta.env.VITE_REACT_APP_USER_POOL_ID;
const awsConfig: ResourcesConfig = {
  Auth: {
    Cognito: {
      userPoolClientId: userPoolClientId,
      userPoolId: userPoolId,
    },
  },
};
Amplify.configure(awsConfig);

function Login() {
  const navigate = useNavigate();

  return (
    <Authenticator>
      {({ user }) => {
        if (user) {
          navigate('/Home');
          return <></>;
        }
        return <></>;
      }}
    </Authenticator>
  );
}

I18n.putVocabularies(translations);
I18n.setLanguage("ja");

export default Login;

 

/src/Home.tsx

App.tsxからHome.tsxにリネームしました。
こちらもルート情報をuseNavigateで使用するようにしました。

import './App.css';
import { useNavigate } from 'react-router-dom';
import { Authenticator } from "@aws-amplify/ui-react";

function Home() {
  const navigate = useNavigate();

  return (
    <Authenticator>
      {({ signOut }) => (
        <>
          <h1>アプリケーション</h1>
          <div className="card">
            <button onClick={() => navigate('/Costcheck')}>コスト確認をする</button>
            <p></p>
            <button onClick={() => navigate('/Game')}>ゲームをする</button>
            <p></p>
            <button onClick={signOut}>サインアウト</button>
          </div>
        </>
      )}
    </Authenticator>
  );
}

export default Home;

 

おまけ:Reactだけで簡単なゲームを作ってみた

ゲーム紹介

ランダムで表示される⭐️をクリックするゲームです。
ベースは生成AIに作成してもらいました。
useStateを活用したReactならではのゲームですね。

ちなみに、スマートフォンでも遊べました。
なぜスマートフォンでも違和感なく遊べるのか気になって調べたところ、最低限のフレキシブルデザインがVite+Viteのindex.cssで実装されているためでした。

 

裏ワザ

タイムアップ後でもクリック数はカウントされますので、カウンターストップするまでクリックすることも可能です。
つまりバグです。

 

コード

コードは以下です。

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

const Game = () => {
  const [score, setScore] = useState(0);
  const [molePosition, setMolePosition] = useState(Math.floor(Math.random() * 9));
  const [gameOver, setGameOver] = useState(false);
  const [timeLeft, setTimeLeft] = useState(30);
  const navigate = useNavigate();
  useEffect(() => {
    const moleTimer = setInterval(() => {
      setMolePosition(Math.floor(Math.random() * 9));
    }, 1000);
    const countdownTimer = setInterval(() => {
      setTimeLeft((prevTime) => prevTime - 1);
    }, 1000);
    setTimeout(() => {
      clearInterval(moleTimer);
      clearInterval(countdownTimer);
      setGameOver(true);
    }, 30000); // 30秒でゲーム終了
    return () => {
      clearInterval(moleTimer);
      clearInterval(countdownTimer);
    };
  }, []);
  const handleClick = (index: number) => {
    if (index === molePosition) {
      setScore(score + 1);
    }
  };
  return (
    <div style={{ textAlign: 'center', marginTop: '50px' }}>
      <h1>ゲーム</h1>
      <p>⭐️をクリックしてスコアを増やしましょう!</p>
      <h2>残り時間: {timeLeft}秒</h2>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 100px)', gap: '10px', justifyContent: 'center' }}>
        {Array.from({ length: 9 }).map((_, index) => (
          <div
            key={index}
            onClick={() => handleClick(index)}
            style={{
              width: '100px',
              height: '100px',
              backgroundColor: index === molePosition ? 'brown' : 'lightgrey',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              cursor: 'pointer',
              fontSize: '24px',
            }}
          >
            {index === molePosition && '⭐️'}
          </div>
        ))}
      </div>
      <h2>スコア: {score}</h2>
      {gameOver && <h2>ゲームオーバー!</h2>}
      <button onClick={() => navigate('/home')}>ホームに戻る</button>
    </div>
  );
};
export default Game;

 

まとめ

ハンズオンや研修とは違い、自分で興味があるものを作ることはとても楽しかったです。
ふと思い立ってゲームを作成してみましたが、フレキシブルデザインという言葉に触れることが出来たのは思わぬ副産物でした。
動画のマスキングは地味に大変でした。

色々と手を出す時間の余裕がないので、ひとまずAWS AmplifyとAWS CDKを軸に、次回はAmazon Bedrockを交えたアプリケーションを作ってみたいと考えています。

タイトルとURLをコピーしました