Reactサンプルアプリ開発 プラニングポーカー (10) Bidの通知

Poker サンプル開発

まず初めに前回の投稿で書いた以下の設定はNGだったので修正する。具体的には、WebSocket通信が一定数で404 Bad requestになってしまう現象が起こった。どうやらgunicornのロードバランシング機能がスティッキーセッションをサポートしていないのが原因らしい。

web: gunicorn app:app --workers 2 --thread 4 --log-file -

代わりにeventletやらをインストールして非同期モードで動作させればよいらしい。

(venv) $ pip install eventlet
(venv) $ pip freeze > requirements.txt

SocketIO初期化時の引数にasync_modeを指定する。

# SocketIO
socketio = SocketIO(app, engineio_logger=app.logger, async_mode="eventlet")

Procfileは以下。

web: gunicorn --worker-class eventlet app:app --workers 1 --thread 1 --log-file -

Bad request問題はこれで解決。

今回のプラニングポーカーの機能実装内容は以下。

  • 自分の手札だけ入札(=ポイントを入れること)できるようにする
  • 入札したら他のプレイヤーに通知する
  • 親プレイヤーが場札をオープンするまでは他のプレイヤーの入札した値は見えないようにする

以下のキャプチャは、他のプレイヤーの入札が親プレイヤーに通知され、?で表示されている様子を表す。

まず、入札はカードのクリックによって開始するため、自分のカードのみクリックイベントを発火するようにする。
PlayerコンポーネントのプロパティPlayerPropsisMe属性を追加し、この値の真偽によって切り替えるようにした。

      <div className={`player-card ${playerCardOpen}`} onClick={props.isMe ? handleModalOpen : undefined}>
        <span className="point">{point}</span>
      </div>

onClick={props.isMe ? handleModalOpen : undefined} という記述により条件分岐するようにしている。

上記のモーダルダイアログでポイントを選択するとイベントが発火し、親コンポーネントに伝搬する(propsに渡されたコールバックを通じて)。
そのイベントハンドラはTablePageコンポーネントに定義してある。

  const handleBidChange = (bid: string, idx: number) => {
    const ps = players.slice();
    console.log('handleBidChange was called.')
    ps[idx].bid = bid;
    setPlayers(ps);
    setTimeout(() => {
      socket.emit("bidding", idx, bid);
    }, 0);
  }

  return (
    <>
      <header className="header">
        <span className="table-name">{props.tableName}</span>
      </header>
      <main>
        <ParentOperations bidding={bidding}
          onOpen={handleOpen} onNewGame={handleNewGame} />
        <GameResults />
        <Players players={
          players.map((p, idx) => Object.assign(p,
            { onBidChange: (bid: string) => handleBidChange(bid, idx) }))
        } />
      </main>
    </>

22行目、子コンポーネントのonBidChangeプロパティからコールバック関数(1-9行目)を呼ぶようにしている。7行目で、WebSocketのイベントを(非同期で)emit。

このbiddingイベントを受けるFlaskアプリケーションのハンドラは以下。

@socketio.on("bidding")
def handle_bidding(player_id, bid):
    payload = {"playerId": player_id, "bid": bid}
    print(f"Player {player_id} has made a bid of {bid}, broadcasting...")
    emit("bidded", payload, broadcast=True)

Flask-SocketIOemit関数を使って、全クライアントにブロードキャストを行う(5行目)。

ブロードキャストされたbiddedイベントのクライアント側ハンドラは、これもTablePage関数コンポーネント内に定義している。

  useEffect(() => {
    socket.on("bidded", (data: any) => {
      console.log(`Player ${data.playerId} has changed the bid: ${data.bid}`);
      const idx: number = data.playerId;
      const updatedPlayer = { ...players[idx], ...{ bid: data.bid } };
      const updatedPlayers = players.map((p, i) => i === idx ? updatedPlayer : p);
      setPlayers(updatedPlayers);
    });
    return () => {
      socket.off("bidded");
    }
  }, [players]);

まず第一に、WebSocketのイベント受信を受けた画面更新はバリバリの副作用なので、当然のごとくuseEffectを使って記述する。
socket.onの第2引数にイベント受信時の処理を記述(2-8行目)。サーバーから入札したプレイヤーのIDと入札値を受け取るので、プレイヤー情報を更新する。ここでsetPlayersはステート変数更新用の関数であり、useStateにより作成したものである(以下のコード)。

  const [bidding, setBidding] = useState(true);
  const [players, setPlayers] = useState<PlayerProps[]>([]);

さて先程のuseEffectで注意すべき点が2つある。

一つ目。この処理ではステート変数playersを参照し、setPlayersにより更新をかけている。つまりplayersに依存している。この場合、useEffectの第2引数に指定することでReactにその依存性を教えてあげなければならない。

useEffectの第1引数に渡したコールバック関数内で参照するplayersは、コンポーネントのレンダリング時点のplayersであり、最新の状態とは限らない。第2引数の配列にplayersを渡すと、playersが変更されたことをReactが検知してコンポーネントの再レンダリングを行う際に、当該useEffectのコールバック関数が再度実行されることになる。つまり、コールバック関数内のplayersが最新の状態を参照していることが保証されるのだ。

最初に一度だけ実行したい処理の場合はuseEffectの第2引数を空配列にせよ、という理由はこれである。空配列→依存するステート変数やプロパティがなしということで、再レンダリングが走ってもコールバック関数は再実行されないということだ。

さきほどのuseEffectのコードを再掲する。

  useEffect(() => {
    socket.on("bidded", (data: any) => {
      console.log(`Player ${data.playerId} has changed the bid: ${data.bid}`);
      const idx: number = data.playerId;
      const updatedPlayer = { ...players[idx], ...{ bid: data.bid } };
      const updatedPlayers = players.map((p, i) => i === idx ? updatedPlayer : p);
      setPlayers(updatedPlayers);
    });
    return () => {
      socket.off("bidded");
    }
  }, [players]);

先に述べたとおり、コンポーネントの再レンダリングが走り、playersの中身が変更されている場合はuseEffectに渡したコールバック関数が再度実行される。
ということは、2行目のsocket.onが再度呼ばれることになるのだが、socket.ioでは一つのイベントに複数のリスナーが登録可能なため、置き換えではなく追加となってしまう。
このままでは同じ内容の複数のイベントリスナーが登録されてしまう。再レンダリングの度に倍々ゲームで増えていく…。

この現象を回避するために、再度コールバック関数が呼ばれるまえに後始末のコードを実行したい。これは、コールバック関数の戻り値として、後始末用のコールバック関数を返せばよいことになっている。
上記コードの9-11行目がそれだ。socket.offにより、biddedイベントを監視するリスナーが全削除される。
これが二つ目の注意点。必要に応じ、後始末用の関数を返すこと。

最後に、場札がオープンされる前は他のプレイヤーの入札値は見えないようにする対応。これは単なる条件式をつけるだけ。

  // オープン前で入力済みの他ユーザーは?を表示する
  const point = (!props.open && !props.isMe && props.bid) ? "?" : props.bid;

  return (
    <div className={`player ${playerIsMe}`}>
      <div className="player-info">
        <div className={"player-icon-" + props.icon}>
        </div>
        <div className="player-name">{props.name}</div>
      </div>
      <div className={`player-card ${playerCardOpen}`} onClick={props.isMe ? handleModalOpen : undefined}>
        <span className="point">{point}</span>
      </div>
      <Modal isOpen={modalIsOpen} onRequestClose={handleModalClose}
        contentLabel="test" style={customStyles} >
        <Deck points={points} onCardSelected={handleCardSelect} />
      </Modal>
    </div>
  )

今回は少し長くなったが、以上。

現時点のクライント側のコードは次のページに載せておく。

コメント

  1. […] 【2020-5-9追記】gunicornで複数ワーカープロセス設定をすると、Flask-SocketIOで400 Bad Requestエラーが起こることが判明。詳細と対処方法は次回記事に記載。 […]

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