はじめに

こんにちは、CADC LP開発チームの平井( did0es )と佐々木( lanberb )です。

皆さん、先日6/28-6/29に開催されたCADC2023をご覧になっていただけましたか?

まだの方は是非セッションをお楽しみください!

https://cadc.cyberagent.co.jp/2023/

当日のダイジェスト動画は▼

Momentoを用いたコメント機能の実装

さて、本題に入ります。

LPサイト内にて、動画配信とコメント機能の実現を検討しました。

今回は Momento Web SDK を用いた、コメント機能を取り上げます。

Momento Topics へメッセージを publish して、Momento Cache からコメントを取得する方法で、コメント機能の実現をしました。

サーバーレスなキャッシュサービスのみならず、トピックなどの機能も提供されていることから、コメント機能に使えそう!と考え、新しいサービスを触ってみたいと言う好奇心に狩られ、触り始めました。

Momentoとは

Momento 社が開発している、オンメモリのKey Value Store(以下、KVS)を用いたプラットフォームです。

Momento の一番の特徴は、サーバーレスです。

他の KVS を用いて、pub/sub でリアルタイム通信を行う場合、インフラの構築・管理が必要ですが、その全てを Momento に任せることができます。

つまり開発者はデータやアプリケーションコードの開発により多くの時間を使えるということですね。

 

また、Momento を用いたリアルタイムな機能開発は非常に少ない手数で行えます。

Momento のリアルタイム通信に用いるトピックは、publish の際に存在しなければ自動で作成されます。

トピックは Momento Cache という、動的にキャッシュを作成・削除する機能をベースに作成されるため、設定無しでスケーリングが可能です。

そのため、あるトピックに対して pub/sub する処理を書くだけで、リアルタイム機能をアプリケーションに取り入れられます。

 

実際に使ってみる

導入はとてもシンプルで、Web SDK をインストールし、キャッシュを作成すると pub/sub でリアルタイム通信ができます。

LP 開発のパッケージ管理には pnpm を用いたので、次のコマンドでインストールします。

pnpm add -w @gomomento/sdk-web

 

はじめに、Momento 側でトークンを発行し、以下のようにキャッシュを作成します。なお、コメント機能は React を用いて実装しました。

// Momento CacheとTopicを作成する Hooks
export const useCacheClient = () => {
  const [cacheClient, setCacheClient] = useState(null);
  useEffect(() => {
    if (!cacheClient) {
      const client = new CacheClient({
        configuration: Configurations.Browser.v1(),
        credentialProvider: CredentialProvider.fromString({
          authToken: context.config.momentoAuthToken, // 作成したトークン
        }),
        defaultTtlSeconds: context.constants.MAX_TTL,
      });
      setCacheClient(client);
    }
  }, [cacheClient]);
  return { cacheClient };
};
export const useTopicClient = () => {
  const [topicClient, setTopicClient] = useState(null);
  useEffect(() => {
    if (!topicClient) {
      const client = new TopicClient({
        configuration: Configurations.Browser.v1(),
        credentialProvider: CredentialProvider.fromString({
          authToken: context.config.momentoAuthToken, // 作成したトークン
        }),
      });
      setTopicClient(client);
    }
  }, [topicClient]);

  return { topicClient };
};

// コメントページのコンポーネント
export const RoomsPage: FC<Pick<RoomsProps, 'data'>> = ({ data }) => {
  // 冒頭の hooks で Momento Cache と Topic のクライアントを作成する。
  const { cacheClient } = useCacheClient();
  const { topicClient } = useTopicClient();

  const createCache = useCallback(async () => {
    // キャッシュ作成。既に同じ名前のキャッシュが存在する場合は、作成されない。
    const res = await cacheClient?.createCache('chat');
    if (res instanceof CreateCache.Error) {
      throw new Error(res.message());
    }
  }, [cacheClient]);
  useEffect(() => {
    if (cacheClient && topicClient) {
      createCache();
    }
  }, [cacheClient, topicClient, createCache]);

  // ~~~
};

 

併せて、コメントのルームを作成・削除する pub/sub も組みました。


// コメントページのコンポーネント

export const RoomsPage: FC<Pick<RoomsProps, "data">> = ({ data }) => {
  // ~~~

  const [rooms, setRooms] = useState<Room[]>([]);
  const getRooms = useCallback(async () => {
    const res = await cacheClient?.setFetch("chat", "chat-room-list");
    // chat というキャッシュに、chat-room-list という key のキャッシュがあった(Hit した)場合
    // 値をパースして rooms に格納する
    if (res instanceof CacheSetFetch.Hit) {
      setRooms(
        res
          .valueArrayString()
          .sort()
          .map((v) => JSON.parse(v))
      );
    } else {
      setShowCreateRoom(true);
      setRooms([]);
    }
  }, [cacheClient]);

  useEffect(() => {
    if (cacheClient && topicClient) {
      createCache();
      // キャッシュ作成後、ルームを取得する
      getRooms();
    }
  }, [cacheClient, topicClient, getRooms, createCache]);

  // ルームの作成・削除を subscribe する
  // Topic に対して、指定した key 名で publish を呼び出すと、Momento から値(item)を取得できる
  useEffect(() => {
    if (topicClient) {
      topicClient.subscribe("chat", "chat-room-created", {
        onItem: async () => {
          await getRooms();
        },
        onError: (error) => {
          throw error;
        },
      });

      topicClient.subscribe("chat", "chat-room-deleted", {
        onItem: async () => {
          await getRooms();
        },
        onError: (error) => {
          throw error;
        },
      });
    }
  }, [topicClient, getRooms]);

  const handleOnSubmitCreateRoom = useCallback(
    async (event: FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      // chat-room-list という key で、シリアライズしたルームのデータを Momento に送る。
      const createRoomRes = await cacheClient?.setAddElement(
        "chat",
        "chat-room-list",
        JSON.stringify({ id: roomId, name: roomName }),
        {
          ttl: new CollectionTtl(context.constants.MAX_TTL),
        }
      );
      if (createRoomRes instanceof CacheSetAddElement.Error) {
        throw new Error(createRoomRes.message());
      }
      // ルーム作成に成功したら、publish を発行する。
      const createRoomPulishRes = await topicClient?.publish(
        "chat",
        "chat-room-created",
        JSON.stringify({ name: roomName })
      );
      if (createRoomPulishRes instanceof TopicPublish.Error) {
        throw new Error(createRoomPulishRes.message());
      }
      toast(
        `「${decodeURIComponent(roomName)}」のコメントルームを作成しました`
      );
      setRoomId("");
      setRoomName("");
    },
    [cacheClient, roomId, roomName, topicClient]
  );

  const handleOnSubmitDeleteRoom = useCallback(
    async (event: FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      const isConfirmed = window.confirm(
        "本当に選択したコメントルームを削除しますか?"
      );
      if (!isConfirmed) {
        return;
      }
      const results = await Promise.all(
        roomsSelected.map(async (room) => {
          // ルーム作成と同じ key で、シリアライズしたルームのデータを送って削除する。
          const cacheRes = await cacheClient?.setRemoveElement(
            "chat",
            "chat-room-list",
            JSON.stringify({ ...room })
          );
          if (cacheRes instanceof CacheSetRemoveElement.Error) {
            throw new Error(cacheRes.message());
          }
          // ルーム削除に成功したら、publish を発行する。
          const topicRes = await topicClient?.publish(
            "chat",
            "chat-room-deleted",
            JSON.stringify({ ...room })
          );
          if (topicRes instanceof TopicPublish.Error) {
            throw new Error(topicRes.message());
          }
          toast(
            `「${decodeURIComponent(room.name)}」のコメントルームを削除しました`
          );
          return { cacheRes, topicRes };
        })
      );
      setRoomsSelected([]);
      return results;
    },
    [roomsSelected, cacheClient, topicClient]
  );

  // ~~~
};

SDK の API を呼び出し、chat-room-list の key に、シリアライズしたルームのデータを送信し、追加や削除をします。

また、ルーム作成は chat-room-created、ルーム削除は chat-room-deleted の key をそれぞれ subscribe することで、publish を検知して画面に反映します。

以上でルーム作成・削除機能が実装できました。

実際の LP では、管理画面にこの機能を組み込み、セッションごとにルームを作成しました。

作成したルームの一覧
作成したルームの一覧画面です。

次に、コメントできるように pub/sub を組みます。

ルーム作成と同様に、コメント用の key を作成し Momento に送信して、その key を subscribe します。

const Chat: FC<{ roomIds: string[]; thisRoomId: string; thisRoomMetaData?: { slug: string; entity: SessionEntity }; }> = ({ roomIds, thisRoomId, thisRoomMetaData }) => {
  // ~~~

  const [currentRooms, setCurrentRooms] = useState<Room[]>([]);
  const getCurrentRooms = useCallback(async () => {
    const res = await cacheClient?.setFetch('chat', 'chat-room-list');
    if (res instanceof CacheSetFetch.Hit) {
      const rooms: Room[] = res
        .valueArrayString()
        .map((v) => JSON.parse(v))
        .sort((a: Message, b: Message) => {
          if (dayjs(a.date).isSame(b.date)) {
            return 0;
          } else if (dayjs(a.date).isAfter(b.date)) {
            return -1;
          } else {
            return 1;
          }
        });
      const filtered = rooms.filter((room) => roomIds?.includes(room.id));
      setCurrentRooms(filtered);
    }
  }, [cacheClient, roomIds]);
  const getChatHistory = useCallback(
    async (roomId: string) => {
      const res = await cacheClient?.listFetch('chat', roomId);
      if (res instanceof CacheListFetch.Hit) {
        const history = res.valueListString().map((msg) => JSON.parse(msg));
        setMessages(history);
      } else if (res instanceof CacheListFetch.Miss) {
        setMessages([]);
      }
    },
    [cacheClient]
  );

  // ルームと、コメント履歴を取得する。
  const setup = useCallback(async () => {
    await getCurrentRooms();
    await getChatHistory(thisRoomId);
  }, [thisRoomId, getChatHistory, getCurrentRooms]);
  useEffect(() => {
    if (cacheClient && topicClient) {
      setup();
    }
  }, [cacheClient, topicClient, setup]);

  const [messages, setMessages] = useState<Message[]>([]);
  const setMessage = useCallback((message: string) => {
    const values = JSON.parse(message);
    setMessages((prev) => {
      return [values, ...prev].sort((a: Message, b: Message) => {
        if (dayjs(a.date).isSame(b.date)) {
          return 0;
        } else if (dayjs(a.date).isAfter(b.date)) {
          return -1;
        } else {
          return 1;
        }
      });
    });
  }, []);
  const deleteMessage = useCallback((message: string) => {
    const values = JSON.parse(message);
    setMessages((prev) =>
      prev.filter((prevMessage) => prevMessage.id !== values.id)
    );
  }, []);

  // ${ルームのID}-chat、${ルームのID}-chat-removed をそれぞれ subscribe する
  useEffect(() => {
    if (topicClient && thisRoomId) {
      topicClient.subscribe('chat', `${thisRoomId}-chat`, {
        onItem: async (data) => {
          setMessage(data.value() as string);
          setIsPublishedComment(true);
        },
        onError: (error) => {
          throw error;
        },
      });
      topicClient.subscribe('chat', `${thisRoomId}-chat-removed`, {
        onItem: async (data) => {
          deleteMessage(data.value() as string);
        },
        onError: (error) => {
          throw error;
        },
      });
    }
  }, [topicClient, thisRoomId, setMessage, deleteMessage]);

  const [messageBody, setMessageBody] = useState('');
  const handleOnSubmitSendComment = useCallback(
    (event: FormEvent) => {
      event.preventDefault();
      const roomId = event.currentTarget.dataset.roomId!;
      const msg: Message = {
        id: crypto.randomUUID(),
        userId: 'admin',
        body: messageBody,
        roomId,
        date: dayjs().format('HH:mm'),
      };
      const messageStringified = JSON.stringify(msg);
      setMessageBody('');
      // シリアライズしたコメントのデータを ルームのID の key で作成する。
      cacheClient?.listPushFront('chat', roomId, messageStringified, {
        ttl: new CollectionTtl(context.constants.MAX_TTL),
      });
      // ${ルームID}-chat として publish する。
      topicClient?.publish('chat', `${roomId}-chat`, messageStringified);
    },
    [messageBody, topicClient, cacheClient]
  );
  const handleOnChangeMessage = useCallback(
    (event: ChangeEvent) => {
      setMessageBody(encodeURIComponent(event.target.value));
    },
    []
  );
  const handleOnClickDeleteMessage = useCallback(
    (event: MouseEvent) => {
      const msg = event.currentTarget.dataset.msg!;
      const roomId = event.currentTarget.dataset.roomId!;
      // シリアライズしたコメントのデータを ルームのID の key で削除する。
      cacheClient?.listRemoveValue('chat', roomId, msg);
      // ${ルームID}-chat-removed として publish する。
      topicClient?.publish('chat', `${roomId}-chat-removed`, msg);
      const parsed: Message = JSON.parse(msg);
      toast(
        `${parsed.id}の「${decodeURIComponent(parsed.body)}」を削除しました。`
      );
    },
    [cacheClient, topicClient]
  );

  // ~~~
};

コメントはルームの ID の key のキャッシュに、配列として格納します。

コメントの追加は ${roomId}-chat、コメントの削除は ${roomId}-chat-removed の key をそれぞれ subscribe することで、publish を検知して画面に反映します。

以上で完成です。ルームを作成し、リアルタイムにコメントできるようになりました!

実際にチャットを行っている様子
実際にチャットを打って、反映されている様子です。

ちなみに、Momento からも サンプルのChat App が公開されており、今回はこちらと APIリファレンス を参考に、LP に合わせて改変して実装しました。

これからコメント機能を作りたいという方は、ぜひ参考にしてみてください!

おわりに

Momento を活用し、短期間でコメント機能を実装し、無事 CADC2023 の開催までにリリースすることができました。

またイベント当日は、コメント機能も問題なく使えていました。

セッションにいただいたコメントは、即時に反映されており、非常に体験がよかったです。

 

今回、多大なご支援いただきました Momento の皆様、ありがとうございました!

 

 

AI事業本部所属です。Like: aws, serverless, microservices, ddd, agile
2022年新卒入社 Webフロントエンドエンジニア CL事業部で体験設計とWebの開発をしています。
2022年新卒入社のフロントエンドエンジニアです。現在は株式会社CAMでエンタメコンテンツ関連のサービス開発をしています。