🙆‍♀️

AWS Amplifyで簡単なゲームを作ったのでその感想

2024/12/19に公開

こんにちは

タイトル通りの記事です。

先に結論を言ってしまうと、「Amplifyの謳うような『クラウドへの深い知識がなくてもアプリの開発およびデプロイができる』という特徴は嘘ではないが、けっきょく問題が起きたら知識が要求される場面は出てくる」という感じです。かなり自明な結論ですね。

ではいきましょう。

何を作ったか

「詠み人知らず」というゲームのオンライン版です。
詠み人知らず自体は僕が作ったものでもなんでもなく、すでに世の中にあるやつです。
ルールについて、 https://jellyjellycafe.com/games/yomibito_shirazu より引用します。

最初に配られた紙に、一文字何か書き、裏にします。(例:「き」)
みんなが書き終わったら、移動カードの指示に従ってカードを交換します。
回ってきた紙には既に一文字目が書いてありますので、それに続けてもう一文字書きます。(例:き「つ」)
これを、五・七・五ぶん繰り返していきます。
すると、最後には名作や珍作ができていることでしょう。

ということです。勝手に少し補足すると、n人参加してたらn枚の「5-7-5の空欄のあるカード」があり、各プレイヤーは「手元に新しく来たカードの残り文字の先頭部分に1文字記入し、次の人に渡す」を繰り返す、という感じですね。「次の人」の決め方は固定順番でも毎回ランダムでもいいのですが、今回の実装ではゲーム開始時に定まる固定順番としています。

成果物としてはアプリとソースコードがあるわけですが、アプリは公開していません。
ソースコードはこちらになります。
https://github.com/iwannatto/yomibitoshirazu
動作イメージはこんな感じです。

動画内では2タブでやってますがこれを複数人でやるというイメージですね。

どう作ったか

const schema = a.schema({
  User: a
    .model({
      name: a.string(),
      roomId: a.id(),
      room: a.belongsTo("Room", "roomId"),
      currentSenryu: a.hasOne("Senryu", "currentUserId"),
      characters: a.hasMany("Character", "userId"),
    }),
  Room: a
    .model({
      name: a.string().required(),
      users: a.hasMany("User", "roomId"),
      locked: a.boolean().required(),
      currentIndex: a.integer().required(),
      order: a.id().array().required(),
      completedUserIds: a.id().array().required(),
      senryus: a.hasMany("Senryu", "roomId"),
    }),
  Senryu: a.
    model({
      roomId: a.id().required(),
      room: a.belongsTo("Room", "roomId"),
      characters: a.hasMany("Character", "senryuId"),
      currentUserId: a.id().required(),
      currentUser: a.belongsTo("User", "currentUserId"),
    }),
  Character: a.
    model({
      senryuId: a.id().required(),
      senryu: a.belongsTo("Senryu", "senryuId"),
      index: a.integer().required(),
      character: a.string().required(),
      userId: a.id().required(),
      user: a.belongsTo("User", "userId"),
    }),
}).authorization((allow) => [allow.publicApiKey()]);
  • ロジックの定義
    • サーバーレスな基盤なのでバックエンドでリクエスト受けてどうこうみたいのは主流ではなさそう(一応lambdaを使うことはできるっぽいのでそれで処理することもできそうではある)ということで、今回は全部のロジックをフロント側に載せることにした
    • データ構造の定義の方に書いた辺りのドキュメントを読んだ
    • 最終的にはこうなった https://github.com/iwannatto/yomibitoshirazu/blob/main/src/App.tsx
      • 特にトリッキーなことをしたりせずに素直に実装したつもり

そんなこんなで一応1プレイに耐えうるものは実装できたはずと考えて友人に展開。1ゲーム目は完全にうまくいったが、2ゲーム目で壊れた

何がうまくいかなかったか

開発中、大きく分けて2つの問題にぶち当たりました。しかも結局両方とも根本的な原因の究明には至りませんでした。以下にその内容を記します。

useStateのset関数に渡す値をスプレッドしないと更新が描画されない

まずはこのコードを見てください。

  useEffect(() => {
    const sub = client.models.Room.observeQuery().subscribe({
      next: ({ items: newRooms }) => {
        console.log("Object.is(rooms, newRooms)", Object.is(rooms, newRooms));
        console.log("newRooms", newRooms);
        setRooms(newRooms);
      },
    });
    return () => {
      sub.unsubscribe();
    };
  }, []);

なんかRoomというモデルをsubscribeしている雰囲気ですね。実際そうで、これはこのページ( https://docs.amplify.aws/react/build-a-backend/data/subscribe-data/#set-up-a-real-time-list-query )に載ってるサンプルコード

import { useState, useEffect } from 'react';
import { generateClient } from 'aws-amplify/data';
import type { Schema } from '../amplify/data/resource';

type Todo = Schema['Todo']['type'];

const client = generateClient<Schema>();

export default function MyComponent() {
  const [todos, setTodos] = useState<Todo[]>([]);

  useEffect(() => {
    const sub = client.models.Todo.observeQuery().subscribe({
      next: ({ items, isSynced }) => {
        setTodos([...items]);
      },
    });
    return () => sub.unsubscribe();
  }, []);

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.content}</li>
      ))}
    </ul>
  );
}

を真似て実装したものです。これにより、Roomに変化が生じたとき(RoomはバックエンドではDynamoDBなので、要はRoomのDynamoDBに行が追加・削除されたときなど)にRoomのlistを持ってきてくれることを期待していました。

ところが、このコードは正常に動作しませんでした。具体的に言うと、Roomに変化を起こした(追加とか)とき、console.logは2つとも走っていて(=setRoomsが呼ばれる所まで来ている)、 newRooms にはアップデートされたroomsの値が入っているにも関わらず、setRoomsが呼ばれたあとも画面の再描画が行われませんでした。

しかも、なぜかこのコードは setRooms(newRooms); の部分を setRooms([...newRooms]); にすると動作します。ここで少し考えて、「Reactの仕様的にはオブジェクトのstateの同一性判定は Object.is を使う」という記述( https://react.dev/reference/react/useState#setstate-caveats )までたどり着いたので、「newRoomsが古いroomsと同一なものと判定されてrenderがskipされていたのでは?」という仮説を立てました。ところが、上のコードの console.log("Object.is(rooms, newRooms)", Object.is(rooms, newRooms));Object.is(rooms, newRooms) は常にfalseを返すのでこの仮説が崩壊しました。ここで手詰まりになってしまい、原因究明には至りませんでした。

結局実際はどうしたのかというとgithubにあるコードの通りで、subscribeを一応つけてはいるけど無いものと考え、Roomsの取得についてはRoomを追加/削除した場合は手動でlistしてそれを反映させる、同時にアクセスしている他のユーザーにはRoomの追加/削除をしたらした人がその事実を他の人に伝え、リロードしてもらうというカスの方法で解決したことにしました。どうせ通話しながらやるゲームですしね。スプレッドするだけでsubscribeが有効になるならそれでいいじゃんというのはマジでそうなんですが、実は今回のこの見出しの事実は開発終了後に明らかになったことで、開発中は「subscribeが謎の理由で機能していない」という問題だと思っていたのでこの形の実装のままになっています。

ただ、そんなことより「setRoomsの引数をスプレッドしないとき元々のオブジェクトと比べた Object.is がfalseなのに再レンダリングされず、スプレッドしたときは再レンダリングされる」という挙動の謎が結局解けなかったというのが心残りです。わかる人いたらコメントとか、社の人は社のslackとかで教えてくれるとありがたいです。

2024/12/20追記:
会社の方から「useEffectのdepsにroomsが入っていないせいで Object.is(rooms, newRooms) の比較がroomsの初期状態とnewRoomsを比べているだけになっているのでは」という指摘が入りました。
確かに……
ということでこのコードで動作を確認してみました。

  useEffect(() => {
    const sub = client.models.Room.observeQuery().subscribe({
      next: ({ items: newRooms }) => {
        console.log("Object.is(rooms, newRooms)", Object.is(rooms, newRooms));
        setRooms(newRooms);
      },
    });
    return () => {
      sub.unsubscribe();
    };
  }, [rooms]);

結果はこちらです。

setRoomsでnewRoomsをセットしたのがroomsに反映されるのでループになってますね。
それはさておき、 Object.is(rooms, newRooms) は一度もtrueを返していません。つまり、「newRoomsが古いroomsと同一なものと判定されてrenderがskipされていたのでは?」という仮説は結局棄却されるわけです。じゃあなんでなんだよ
また、これに伴い以下のことがわかりました。

  • このループが起きているときにuseEffectの外側でconsole.logすることで逐一再レンダーが行われていることが確認できた
  • depsにroomsをセットするだけでこのループは起きる
    • これが不可解(setRoomsは効力を発揮しなかったはずでは?setRoomsに本当に効力がないならdepsにroomsを入れようがsetRooms後もroomsが変化しないので再レンダーは起こらないはず)
    • この不可解さから、「setRooms(newRooms)は再レンダーを起こす力はないがroomsを変化させる力だけはあり、2回目のsetRooms(newRooms)の時とかに何かが起こってsetRoomsに再レンダーを起こす力が宿る」という仮説を立てた
      • これを検証するべく下のコードを書いたが、結果としてはsetRooms(newRooms)が連続する箇所はなかった。つまり、上の仮説は棄却されたことになる
    • これにより「depsにroomsを入れるとそれまで効力のなかったsetRoomsが効力を発揮するようになる」という新たな謎が生まれた
 console.log("render");

 useEffect(() => {
   const sub = client.models.Room.observeQuery().subscribe({
     next: ({ items: newRooms }) => {
       setRooms(newRooms);
       console.log("setRooms(newRooms)", newRooms);
     },
   });
   return () => {
     sub.unsubscribe();
   };
 }, [rooms]);

色々あって、結局は謎が増えただけということになりました。まだまだ追加の知見をお待ちしております。

追記おわり

2024/12/21追記:
書くのを忘れてたんですが、実は setRooms(newRooms) が呼ばれるも再レンダーが起こらなかったあとに別口で再レンダーするとnewRoomsがroomsに反映されるということは確認できているんですよね
これ軽く考えてたんだけど実は大事かもしれない
なんかsetStateを溜めておくReactのキューみたいなのがあってそこには入っているけど発火してくれてない、みたいなことが起きている?そしたらこれはReactのせいなのか?そうだったとしてnewRoomsのどういう性質がこの奇妙な現象(キューには入るが発火しない)を引き起こすのか?(普通は「キューに入らないし発火もしない」「キューに入るし発火もする」の二択なはず)
謎は深まるばかり……
追記おわり

2024/12/31追記:

  const [rooms, setRooms] = useState<Room[]>([]);
  const [enteredRoomName, setEnteredRoomName] = useState<string>("");
  const prevRooms = useRef<Room[]>([]);

  useEffect(() => {
    const sub = client.models.Room.observeQuery().subscribe({
      next: ({ items: newRooms }) => {
        console.log(
          "Object.is(newRooms, prevRooms.current)",
          Object.is(newRooms, prevRooms.current)
        );
        prevRooms.current = newRooms;
        setRooms(newRooms);
      },
    });
    return () => {
      sub.unsubscribe();
    };
  }, []);

  useEffect(() => {
    console.log("rooms", rooms);
  }, [rooms]);

useRefとかいうのを学んだのでやってみました。結果はなんとObject.isがtrueになります!
つまりやっぱり「newRoomsが古いroomsと同一なものと判定されてrenderがskipされていたのでは?」が正しかったっぽいですね。
ただまた謎は増えていて、「なぜ上のコードでsetRoomsを削除するとroomsが更新されなくなるのか?」が気になり始めました。
というのもnewRoomsがroomsと常に同じ参照ならsetRoomsの有無は関係なくnext関数が呼ばれる時点で既にroomsがmutateされており、それなら別口で再レンダーしたときroomsへの変更がsetRoomsが挟まれていなかろうと反映されるのではないかという気がするのですが、実際はそうなっていないんですよね。この辺になるとtypescriptのオブジェクトに関する深い理解が必要になってくると思うので、そのへんの知識がつくまでこの疑問はお預けにしておこうと思います。

追記おわり

2025/01/01追記:
追記が多すぎる
それはそれとして解明編です

  const curRooms = useRef<Room[]>(rooms);

  useEffect(() => {
    const sub = client.models.Room.observeQuery().subscribe({
      next: ({ items: newRooms }) => {
        console.log("next");
        if (curRooms.current.length === 0) {
          console.log("setRooms(newRooms)", newRooms);
          setRooms(newRooms);
        }
        curRooms.current = newRooms;
      },
    });
    return () => {
      sub.unsubscribe();
    };
  }, []);

このコードの下でアプリのGUIをポチポチ弄ってみると、「部屋を追加するとnextは呼ばれるけどsetRoomsは呼ばれない、また変更もrenderされない。別口でrerenderすると部屋の追加が画面に反映される」という挙動になりました。
これで謎が解けた形ですね。
つまり、「next関数が呼ばれた時点でroomsはmutateされていた」というわけです。
本質的には、下のコードを書いたときmutateボタンを何回押してもrerenderボタンを押さないとrenderされない、というのと同じ現象が起きていたわけです。

import { useState } from "react";
import "./styles.css";

export default function App() {
  const [obj, setObj] = useState({ a: 0 });
  const [, reRender] = useState(null);

  const mutate = () => {
    obj.a += 1;
  };

  return (
    <>
      <button onClick={mutate}>mutate</button>
      <button onClick={reRender}>reRender</button>
      <p>{JSON.stringify(obj)}</p>
    </>
  );
}

また、setRoomsを消すと動かないのは、単に初回はroomsにnewRoomsをセットしないとroomsがなにも変わらないからというだけでしたね。これは、「2回目以降のsetRoomsは呼ばれなくてもroomsが勝手にmutateされる」という挙動を示すことが実験的に明らかになったことからわかります。

さらに、これで依存配列にroomsを入れるとループするという現象も謎が解けたと考えます。初回のroomsへのsetは有効なのでそれが再度subscribeを呼び、多分subscribe間ではroomsの使い回しをしないので2回目も違うrooms配列をroomsへsetし……というのが繋がるからですね。

これにて解決したつもりです!スッキリしました

追記おわり

DynamoDBからのデータの取得に欠損がある

またもコードを見てください。

  useEffect(() => {
    const fetchSenryu = async () => {
      const { data: senryus } = await room.senryus();
      if (senryus.length === 0) {
        return;
      }
      const senryu = senryus.find((senryu) => senryu.currentUserId === user.id);
      if (senryu == undefined) {
        console.error("senryu == undefined");
        return;
      }
      setSenryu(senryu);
    };
    fetchSenryu();

    const intervalId = setInterval(fetchSenryu, 1000);

    return () => {
      clearInterval(intervalId);
    };
  }, [room, user.id]);

  useEffect(() => {
    const fetchCharacters = async () => {
      if (senryu == null) {
        return;
      }
      const { data: characters } = await senryu.characters();
      setCharacters(characters);
    };
    fetchCharacters();
  }, [senryu]);

何をしているかというと、ゲーム中、各userに紐づいているsenryuを持ってきてsetSenryu(senryu);しているのが1つ目のuseEffectで、senryuが更新されたときに走ってsenryuと紐づいているcharactersを持ってきてsetCharactersしているのが2つ目のuseEffectです。

これの何が問題かというと、2つ目のuseEffect内部のこの部分

      const { data: characters } = await senryu.characters();

で取ってくるcharactersになぜか欠損が生じるというのが問題です。

このコードは、Amplifyのドキュメントであるこれ( https://docs.amplify.aws/react/build-a-backend/data/data-modeling/relationships/#lazy-load-a-has-many-relationship )のこの部分

const { data: team } = await client.models.Team.get({ id: "MY_TEAM_ID"});

const { data: members } = await team.members();

members.forEach(member => console.log(member.id));

を真似して作っていて、記法は間違っていないはずです。

これも調査をしてみたんですが、結局以下のようなことがわかっただけでした。

  • characterのデータの保存エラーではない(直接DynamoDBを覗き、今まで保存した全characterが入っていることを確認した)
  • 表示エラーでもない(取得の直後にconsole.logを挟むとその時点で欠損している)
  • アプリ側からは他の方法でDynamoDBにアクセスしてもcharacterデータが取れない。具体的には、characterを全件検索してfilterする、という実装を試したのだが、この実装をしても同じようにcharacterが欠損する
  • 安定して再現させる方法が見つからない(いけるときは1ゲーム無事に終了したりする)

じゃあどうしたのかというと、見なかったことにしました。まあ正確に言うと、Amplifyにはローカル開発用のサンドボックスがあってその上で開発していたんですが、そのサンドボックス用のインフラを全部一回爆破してやり直して手元で軽くテストしてみたら再現しませんでした。なので、「バックエンドの型定義をちょっとずつ弄りながら開発していたから不整合が起きていたのであって、ゼロから今のデータ構造を持つDynamoDBを立ち上げ直したことにより直ったのかな」と呑気に構えつつ本番デプロイしてみたら実はダメだった、という感じです。

で、結局この現象も原因がわからず、このバグは修正されることなくgithubリポジトリ内に存在し続けています。ここで足を引っ張っているのが僕のGraphQLおよびAppSyncに対する知識の無さです。というのも、このバグはコンソールからDynamoDBにアクセスしたら見れるデータがAPI経由だと見れないという現象であり、明らかにAPI層が怪しいと思うのですが、僕の持っている知識としてGraphQLは「なんかRESTとよく比較されているからそういう何らかのAPI体系なのかなあ」、AppSyncは「初見」だったので、この辺のデバッグがまるでできませんでした。もちろん頑張って調べて解消するのも手ですが、一応バグはあれど完成はして友人にもお披露目できたのでもういいかなという気持ちが勝ってこれ以上調べる気力がなくなってるというのが現状です。ただこれも、詳しい人が出てきて「こういうことをするとAPI層のデバッグができる」みたいなことを言ってくれれば話は変わってくるので、わかる人がいたらコメントとか社の人は社のslackなどで教えていただけるとありがたいです。

まとめ

良かったところ

細かいところは気にせず、コードを書きさえすればリソースの作成からデプロイまで全部一気にやってくれる、という謳い文句はある程度は事実だったと思います。レールに乗れる範囲内では、という但し書きが付きますが。

特に、デプロイ周りは何のトラブルもなかったので、そこは明確に楽できた部分かなと思います。ちゃんと書こうとするとおそらく大変というか、今の僕にはできないところまでやってくれたという感じがありました。

良くなかったところ

レールに乗れない部分のデバッグは普通に知識が要求されるという点には悩まされました。

これ自体は当然っちゃ当然なので別にいいんですが、この規模のアプリですら問題が出てくるのはちょっと厳しいという感じがしました。

公式のドキュメントは機能を一通り紹介はしてくれているのですが、深掘りはしてくれていないため、デバッグとかが必要になってくるとあんまり頼りにならなかったです。

あと余談なんですが、ChatGPTがGen2の情報を一切持っていないっぽくて、何回Gen2でと言ってもGen1の情報を返してくるのがムカつきました。Gen1はコマンドラインで結構色々やるという設計らしく、Gen2の情報ですとか言いつつGen1にしかないコマンドを打つことを指示してくるので最初の方は結構これで混乱したりもしました。途中からChatGPTに頼ることを完全にやめたので混乱は収まりました。

総まとめ

実際知識が全然ない自分でもデプロイまではいけてるわけなので、「深い知識がなくても素早くアプリを作れる」というのは本当だと思います。しかし結局トラブルシューティングは避けて通れず、その際には知識が必要であるのもまた事実だと感じました。
↑自明すぎる

(おわりです)

Discussion