はじめに
こんにちは、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 の皆様、ありがとうございました!