📝

AppSync でリアルタイム投票アプリを作ってみた

に公開

前提

  • フロントエンドは index.html のみ
  • Amplify や JavaScript フレームワークは不使用

1. AppSync API の作成

以下の内容で作成しました。

  • API タイプ: GraphQL APIs
  • GraphQL API データソース: Design from scratch
  • API 名: VotingApp
  • DynamoDB テーブルを使用する GraphQL タイプを作成: 後で GraphQL リソースを作成

2. DynamoDB テーブルの作成

以下の内容で作成しました。

  • テーブル名: VotingTable
  • パーティションキー: id
    • 文字列
  • テーブル設定: デフォルト設定

3. AppSync API にデータソースを追加

手順 1 で作成した API のデータソースに手順 2 で作成した DynamoDB テーブルを追加します。

  • データソース名: VotingTableDataSource
  • データソースタイプ: AMAZON_DYNAMODB
  • リージョン: AP‐NORTHEAST-1
  • テーブル名: VotingTable
  • 既存のロールを作成または使用する: 新しいロール

4. GraphQL スキーマの定義

AppSync コンソールのスキーマで以下の内容を定義して保存します。

type Vote {
  id: ID!
  question: String!
  options: [String!]!
  votes: [Int!]!
  createdAt: String!
}

type Query {
  getVote(id: ID!): Vote
  listVotes: [Vote]
}

type Mutation {
  createVote(question: String!, options: [String!]!): Vote
  castVote(id: ID!, optionIndex: Int!): Vote
}

type Subscription {
  onVoteUpdated(id: ID!): Vote
    @aws_subscribe(mutations: ["castVote"])
}

5. リゾルバーの設定

リゾルバー > Mutation > createVote(...): Vote のアタッチをクリックします。

手順 3 で作成したデータソースを設定します。

リゾルバーコードに以下の内容を定義して保存します。

import { util } from '@aws-appsync/utils';

export function request(ctx) {
  const id = util.autoId();
  const now = util.time.nowISO8601();
  const { question, options } = ctx.args;
  
  // 投票数を0で初期化(選択肢の数だけ)
  const votes = options.map(() => 0);
  
  return {
    operation: 'PutItem',
    key: util.dynamodb.toMapValues({ id }),
    attributeValues: util.dynamodb.toMapValues({
      question,
      options,
      votes,
      createdAt: now
    })
  };
}

export function response(ctx) {
  return ctx.result;
}

同じ要領で castVote(...): Vote にもデータソースとリゾルバーコードを追加します。

import { util } from '@aws-appsync/utils';

export function request(ctx) {
  const { id, optionIndex } = ctx.args;
  
  return {
    operation: 'UpdateItem',
    key: util.dynamodb.toMapValues({ id }),
    update: {
      expression: 'ADD votes[' + optionIndex + '] :increment',
      expressionValues: util.dynamodb.toMapValues({
        ':increment': 1
      })
    }
  };
}

export function response(ctx) {
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }
  return ctx.result;
}

同じ要領で Query > getVote(...): Vote にもデータソースとリゾルバーコードを追加します。

import { util } from '@aws-appsync/utils';

export function request(ctx) {
  const { id } = ctx.args;
  
  return {
    operation: 'GetItem',
    key: util.dynamodb.toMapValues({ id })
  };
}

export function response(ctx) {
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }
  return ctx.result;
}

6. GraphQL API のテスト

AppSync コンソール上のクエリでテストします。
まずは以下のクエリを実行します。

mutation CreateVote {
  createVote(
    question: "好きなプログラミング言語は?"
    options: ["JavaScript", "Python", "Java", "Go"]
  ) {
    id
    question
    options
    votes
    createdAt
  }
}

実行後、以下の結果が表示されれば投票の対象となる項目の作成が完了します。

{
  "data": {
    "createVote": {
      "id": "a741be93-59e2-49df-a147-e2ab2d4edc20",
      "question": "好きなプログラミング言語は?",
      "options": [
        "JavaScript",
        "Python",
        "Java",
        "Go"
      ],
      "votes": [
        0,
        0,
        0,
        0
      ],
      "createdAt": "2025-09-05T00:31:50.719Z"
    }
  }
}

この時点で DynamoDB にもデータが作成されています。

次に投票機能をテストするために以下のクエリを実行します。
id には上述の createVote で取得した id を指定します。

mutation CastVote {
  castVote(
    id: "a741be93-59e2-49df-a147-e2ab2d4edc20"
    optionIndex: 0
  ) {
    id
    question
    options
    votes
  }
}

実行後、以下の結果が表示されれば投票は成功です。

{
  "data": {
    "castVote": {
      "id": "a741be93-59e2-49df-a147-e2ab2d4edc20",
      "question": "好きなプログラミング言語は?",
      "options": [
        "JavaScript",
        "Python",
        "Java",
        "Go"
      ],
      "votes": [
        1,
        0,
        0,
        0
      ]
    }
  }
}

この時点で DynamoDB にもデータも更新されています。

次に Subscription の機能をテストしますが、動作確認のためにクエリページのブラウザのタブを複製しておきます。
片方のタブでは以下のクエリを定義しますが、この時点では実行しません。

mutation CastVote {
  castVote(
    id: "a741be93-59e2-49df-a147-e2ab2d4edc20"
    optionIndex: 1
  ) {
    id
    question
    options
    votes
  }
}

もう 1 つのタブで Subscription 用に以下のクエリを定義します。

subscription OnVoteUpdated {
  onVoteUpdated(id: "a741be93-59e2-49df-a147-e2ab2d4edc20") {
    id
    question
    options
    votes
  }
}

各クエリの定義後、以下の順番で実行します。

  1. Subscription のクエリ実行
  2. CastVote のクエリ実行

2 のクエリが成功したタイミングで 1 のクエリの結果にも以下の内容が表示されれば OK です。

{
  "data": {
    "onVoteUpdated": {
      "id": "a741be93-59e2-49df-a147-e2ab2d4edc20",
      "question": "好きなプログラミング言語は?",
      "options": [
        "JavaScript",
        "Python",
        "Java",
        "Go"
      ],
      "votes": [
        1,
        1,
        0,
        0
      ]
    }
  }
}

7. フロントエンドアプリの作成

テキストエディタなどで index.html を作成し、以下のコードを貼り付けます。

index.html
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>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .container {
            background: white;
            padding: 30px;
            border-radius: 10px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        .vote-option {
            margin: 15px 0;
            padding: 15px;
            border: 1px solid #ddd;
            border-radius: 5px;
            background: #f9f9f9;
        }
        .vote-button {
            background: #007bff;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 16px;
        }
        .vote-button:hover {
            background: #0056b3;
        }
        .vote-bar {
            height: 20px;
            background: #007bff;
            margin-top: 10px;
            border-radius: 3px;
            transition: width 0.3s ease;
        }
        .vote-count {
            font-weight: bold;
            margin-left: 10px;
        }
        .status {
            padding: 10px;
            margin: 10px 0;
            border-radius: 5px;
        }
        .status.success {
            background: #d4edda;
            color: #155724;
            border: 1px solid #c3e6cb;
        }
        .status.error {
            background: #f8d7da;
            color: #721c24;
            border: 1px solid #f5c6cb;
        }
        .status.info {
            background: #d1ecf1;
            color: #0c5460;
            border: 1px solid #bee5eb;
        }
        .input-section {
            margin-bottom: 30px;
            padding: 20px;
            background: #f8f9fa;
            border-radius: 5px;
        }
        input[type="text"] {
            width: 300px;
            padding: 8px;
            margin: 5px;
            border: 1px solid #ddd;
            border-radius: 3px;
        }
        button {
            background: #28a745;
            color: white;
            border: none;
            padding: 10px 15px;
            border-radius: 5px;
            cursor: pointer;
            margin: 5px;
        }
        button:hover {
            opacity: 0.8;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>🗳️ リアルタイム投票システム</h1>
        
        <!-- 設定セクション -->
        <div class="input-section">
            <h3>AppSync設定</h3>
            <div>
                <input type="text" id="apiEndpoint" placeholder="AppSync API エンドポイント" />
            </div>
            <div>
                <input type="text" id="apiKey" placeholder="API キー" />
            </div>
            <button onclick="saveConfig()">設定保存</button>
            <div id="configStatus"></div>
        </div>

        <!-- 投票作成セクション -->
        <div class="input-section">
            <h3>新しい投票を作成</h3>
            <div>
                <input type="text" id="questionInput" placeholder="質問を入力" />
            </div>
            <div>
                <input type="text" id="option1" placeholder="選択肢1" />
                <input type="text" id="option2" placeholder="選択肢2" />
            </div>
            <div>
                <input type="text" id="option3" placeholder="選択肢3" />
                <input type="text" id="option4" placeholder="選択肢4" />
            </div>
            <button onclick="createVote()">投票作成</button>
        </div>

        <!-- 既存投票読み込みセクション -->
        <div class="input-section">
            <h3>既存の投票を読み込み</h3>
            <input type="text" id="voteIdInput" placeholder="投票ID" />
            <button onclick="loadVote()">読み込み</button>
        </div>

        <!-- ステータス表示 -->
        <div id="status"></div>

        <!-- 投票表示エリア -->
        <div id="voteArea" style="display: none;">
            <h2 id="voteQuestion"></h2>
            <div id="voteOptions"></div>
            <div id="connectionStatus" class="status info">
                🔄 リアルタイム接続: 準備中...
            </div>
        </div>
    </div>

    <script>
        // グローバル変数
        let config = {
            endpoint: '',
            apiKey: ''
        };
        let currentVote = null;
        let websocket = null;

        // 設定保存
        function saveConfig() {
            const endpoint = document.getElementById('apiEndpoint').value.trim();
            const apiKey = document.getElementById('apiKey').value.trim();
            
            if (!endpoint || !apiKey) {
                showStatus('エンドポイントとAPIキーを入力してください', 'error');
                return;
            }
            
            config.endpoint = endpoint;
            config.apiKey = apiKey;
            
            // ローカルストレージに保存
            localStorage.setItem('appSyncConfig', JSON.stringify(config));
            
            showStatus('設定が保存されました', 'success');
        }

        // 設定読み込み
        function loadConfig() {
            const saved = localStorage.getItem('appSyncConfig');
            if (saved) {
                config = JSON.parse(saved);
                document.getElementById('apiEndpoint').value = config.endpoint;
                document.getElementById('apiKey').value = config.apiKey;
            }
        }

        // GraphQL リクエスト送信
        async function sendGraphQLRequest(query, variables = {}) {
            if (!config.endpoint || !config.apiKey) {
                throw new Error('AppSync設定が必要です');
            }

            const response = await fetch(config.endpoint, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'x-api-key': config.apiKey
                },
                body: JSON.stringify({
                    query: query,
                    variables: variables
                })
            });

            const result = await response.json();
            
            if (result.errors) {
                throw new Error(result.errors[0].message);
            }
            
            return result.data;
        }

        // 投票作成
        async function createVote() {
            try {
                const question = document.getElementById('questionInput').value.trim();
                const options = [
                    document.getElementById('option1').value.trim(),
                    document.getElementById('option2').value.trim(),
                    document.getElementById('option3').value.trim(),
                    document.getElementById('option4').value.trim()
                ].filter(option => option !== ''); // 空の選択肢を除外

                if (!question || options.length < 2) {
                    showStatus('質問と最低2つの選択肢を入力してください', 'error');
                    return;
                }

                showStatus('投票を作成中...', 'info');

                const query = `
                    mutation CreateVote($question: String!, $options: [String!]!) {
                        createVote(question: $question, options: $options) {
                            id
                            question
                            options
                            votes
                            createdAt
                        }
                    }
                `;

                const data = await sendGraphQLRequest(query, { question, options });
                currentVote = data.createVote;
                
                showStatus(`投票が作成されました!ID: ${currentVote.id}`, 'success');
                displayVote();
                startRealtimeConnection();
                
                // 入力フィールドをクリア
                document.getElementById('questionInput').value = '';
                document.getElementById('option1').value = '';
                document.getElementById('option2').value = '';
                document.getElementById('option3').value = '';
                document.getElementById('option4').value = '';

            } catch (error) {
                showStatus(`エラー: ${error.message}`, 'error');
            }
        }

        // 投票読み込み
        async function loadVote() {
            try {
                const voteId = document.getElementById('voteIdInput').value.trim();
                if (!voteId) {
                    showStatus('投票IDを入力してください', 'error');
                    return;
                }

                showStatus('投票を読み込み中...', 'info');

                const query = `
                    query GetVote($id: ID!) {
                        getVote(id: $id) {
                            id
                            question
                            options
                            votes
                            createdAt
                        }
                    }
                `;

                const data = await sendGraphQLRequest(query, { id: voteId });
                
                if (!data.getVote) {
                    showStatus('投票が見つかりませんでした', 'error');
                    return;
                }

                currentVote = data.getVote;
                showStatus('投票を読み込みました', 'success');
                displayVote();
                startRealtimeConnection();

            } catch (error) {
                showStatus(`エラー: ${error.message}`, 'error');
            }
        }

        // 投票実行
        async function castVote(optionIndex) {
            try {
                showStatus('投票中...', 'info');

                const query = `
                    mutation CastVote($id: ID!, $optionIndex: Int!) {
                        castVote(id: $id, optionIndex: $optionIndex) {
                            id
                            question
                            options
                            votes
                        }
                    }
                `;

                const data = await sendGraphQLRequest(query, { 
                    id: currentVote.id, 
                    optionIndex: optionIndex 
                });

                showStatus('投票しました!', 'success');
                // リアルタイム更新で自動的に画面が更新されます

            } catch (error) {
                showStatus(`投票エラー: ${error.message}`, 'error');
            }
        }

        // 投票表示
        function displayVote() {
            if (!currentVote) return;

            document.getElementById('voteQuestion').textContent = currentVote.question;
            
            // 投票IDを表示するための要素を追加
            const voteArea = document.getElementById('voteArea');
            let idDisplay = document.getElementById('voteIdDisplay');
            if (!idDisplay) {
                idDisplay = document.createElement('div');
                idDisplay.id = 'voteIdDisplay';
                idDisplay.style.cssText = 'background: #e9ecef; padding: 10px; margin: 10px 0; border-radius: 5px; font-family: monospace;';
                voteArea.insertBefore(idDisplay, document.getElementById('voteOptions'));
            }
            idDisplay.innerHTML = `<strong>投票ID:</strong> ${currentVote.id}`;
            
            const optionsContainer = document.getElementById('voteOptions');
            optionsContainer.innerHTML = '';

            const totalVotes = currentVote.votes.reduce((sum, count) => sum + count, 0);
            const maxVotes = Math.max(...currentVote.votes);

            currentVote.options.forEach((option, index) => {
                const voteCount = currentVote.votes[index];
                const percentage = totalVotes > 0 ? (voteCount / totalVotes * 100).toFixed(1) : 0;
                const barWidth = maxVotes > 0 ? (voteCount / maxVotes * 100) : 0;

                const optionDiv = document.createElement('div');
                optionDiv.className = 'vote-option';
                optionDiv.innerHTML = `
                    <div>
                        <button class="vote-button" onclick="castVote(${index})">
                            ${option}
                        </button>
                        <span class="vote-count">${voteCount} 票 (${percentage}%)</span>
                    </div>
                    <div class="vote-bar" style="width: ${barWidth}%"></div>
                `;
                optionsContainer.appendChild(optionDiv);
            });

            document.getElementById('voteArea').style.display = 'block';
        }

        // リアルタイム接続開始
        function startRealtimeConnection() {
            if (!currentVote) return;

            document.getElementById('connectionStatus').innerHTML = '🟡 リアルタイム接続: ポーリング方式で監視中';
            
            // 3秒ごとに投票データを更新
            const pollInterval = setInterval(async () => {
                try {
                    const query = `
                        query GetVote($id: ID!) {
                            getVote(id: $id) {
                                id
                                question
                                options
                                votes
                                createdAt
                            }
                        }
                    `;

                    const data = await sendGraphQLRequest(query, { id: currentVote.id });
                    
                    if (data.getVote) {
                        // 投票数が変更されたかチェック
                        const oldVotes = JSON.stringify(currentVote.votes);
                        const newVotes = JSON.stringify(data.getVote.votes);
                        
                        if (oldVotes !== newVotes) {
                            currentVote = data.getVote;
                            displayVote();
                            showStatus('🔄 投票が更新されました', 'info');
                            document.getElementById('connectionStatus').innerHTML = '🟢 リアルタイム接続: 最新データ取得';
                        } else {
                            document.getElementById('connectionStatus').innerHTML = '🟡 リアルタイム接続: 監視中';
                        }
                    }
                } catch (error) {
                    console.error('ポーリングエラー:', error);
                    document.getElementById('connectionStatus').innerHTML = '🔴 リアルタイム接続: エラー';
                }
            }, 3000); // 3秒間隔

            // ページを離れる時にポーリングを停止
            window.pollInterval = pollInterval;
        }

        // ステータス表示
        function showStatus(message, type = 'info') {
            const statusDiv = document.getElementById('status');
            statusDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
            
            // 3秒後に自動で消去(エラーメッセージ以外)
            if (type !== 'error') {
                setTimeout(() => {
                    statusDiv.innerHTML = '';
                }, 3000);
            }
        }

        // ページ読み込み時の初期化
        window.onload = function() {
            loadConfig();
            showStatus('AppSyncの設定を入力して開始してください', 'info');
        };

        // ページを離れる時にWebSocket接続を閉じる
        window.onbeforeunload = function() {
            if (window.pollInterval) {
                clearInterval(window.pollInterval);
            }
        };
    </script>
</body>
</html>

index.html を zip ファイル化します。

8. Amplify アプリの作成

Amplify コンソールから「Git なしでデプロイ」を選択し、手順 7 で作成した zip ファイルをアップロードします。

9. 動作確認

Amplify アプリのデプロイ完了後、ドメインにアクセスします。

以下のページが表示されるので、まずは AppSync API のエンドポイントと API キーを設定します。
AppSync API のエンドポイントと API キーは AppSync コンソールから確認可能です。

設定の保存後、質問と選択肢を入力して投票を作成します。

投票作成後、票を入れて値が変わることを確認します。

DynamoDB にも投票のデータが保存されていることが確認できます。

これでリアルタイム投票アプリの完成です。

まとめ

今回は AppSync でリアルタイム投票アプリを作ってみました。
どなたかの参考になれば幸いです。

Discussion