チャットアプリの実例でDynamoDBのモデリングを考えてみる
DynamoDB むずい。具体的には
- テーブル設計がむずい
- カラムのrenameやテーブル名のrenameがむずい
- テーブル設計にも関わるが、検索がパーティションキーとソートキーを通してしかできない。もしくは、別にindexテーブルをつくるGSIやLSI機能を利用する。
RDBのように正規化して保存するのではなく、欲しいデータをほしい形で保存するみたいに言われるけど、結局データの持ち方の理想形がわからない。
また、既存のカラムのrenameができなかったり、テーブル名のrenameができなかったりする。
これらのrenameがしたい場合は、dynamodbのstreamという機能を使ってデータを新しいカラム(もしくはテーブル)に流しつつ、既存のデータをコピーしないと行けない。しんどい。
既存のテーブルの形の修正がしにくいから、結局最初に設計した未熟なテーブルの良くない部分をアプリケーションによって吸収しないといけない。
チャットアプリケーションのメッセージ取得がioが激しいので、dynamodbを選択したが、設計能力が未熟であれば修正がしやすいRDBを選択すればよかったかも。もしくは、検索まわりがやさしいmongodbとか(AWSはmongodb互換のdocumentDBを提供しています。結構高い)
以下は自分の設計スキル不足をさらすようで嫌ですが、とりあえず流れを全部見せようと思います。
最初の設計
とりあえずdynamoDBの設計を見直そうと思うので、いい機会なのでこのスクラップで流れを共有していく。
設計しているのはチャットアプリ。既存のテーブルはつぎのようになっている。
DynamoDBはDate型をサポートしていないので、時間はtimestampで保存する。
また、roomやuserテーブルはRDBで管理している。
roomIdがハッシュキー。createdAtをソートキーにしている。
roomId | createdAt | content | updatedAt | postUserId |
---|---|---|---|---|
1 | 1606645200456 | Hello from Zenn! | 1606645200456 | 1 |
1 | 1606645201000 | Good evening from Zenn! | 1606645201000 | 2 |
2 | 1606645200456 | こんちには! | 1606645200456 | 1 |
2 | 1606645201000 | こんばんは! | 1606645201000 | 2 |
このように設計することで、ソートキーは自動的にソートしてテーブルに保存してくれるので、ハッシュキーを指定すれば、該当の部屋のメッセージがすべて取得できる。
チャットアプリの種類
LINE型のチャットアプリ
上記のテーブルはLINE型のチャットアプリしか実現できない。どういうことかというと
roomId | createdAt | content | updatedAt | postUserId |
---|---|---|---|---|
2 | 1606645200456 | こんちには! | 1606645200456 | 1 |
2 | 1606645201000 | こんばんは! | 1606645201000 | 2 |
2 | 1606645202000 | まだ昼だよ! | 1606645202000 | 1 |
2 | 1606645203000 | こっちは夕方だよ! | 1606645203000 | 2 |
2 | 1606645204000 | おじゃまします! | 1606645204000 | 1 |
2 | 1606645205000 | あっ、時差があるのね。 | 1606645205000 | 2 |
user1: こんにちは!
user2: こんばんは!
user1: まだ昼だよ!
user2: こっちは夕方だよ!
user3: おじゃまします!
user1: あっ、時差があるのね。
のように、メッセージの返信が一番下に来る。
Slack型のチャットアプリ
ちなみに、LINE型に対して、こちらの返信があるメッセージにひもづくのをSlack型と呼ぼうと思います。
user1: こんにちは!
user2: こんばんは!
user1: まだ昼だよ!
user2: こっちは夕方だよ!
user1: あっ、時差があるのね。
user3: おじゃまします!
Reddit型
無限にネストするのをとりあえずReddit型と名前付けします。
user1: こんにちは!
user2: こんばんは!
user1: まだ昼だよ!
user2: こっちは夕方だよ!
user1: あっ、時差があるのね。
user3: おじゃまします!
Slack型を実現したい
ビジネスサイドと話す中で、LINE型よりもSlack型のほうが会話しやすいという事になりました。そこでSlack型を実現しなければなりません。
replyId保持方式
Slack型を最初のテーブルに2つのカラムを追加して実現しました。
roomId | createdAt | content | updatedAt | postUserId | id | replyToId |
---|---|---|---|---|---|---|
2 | 1606645200456 | こんちには! | 1606645200456 | 1 | random1 | |
2 | 1606645201000 | こんばんは! | 1606645201000 | 2 | random2 | |
2 | 1606645202000 | まだ昼だよ! | 1606645202000 | 1 | random3 | |
2 | 1606645203000 | こっちは夕方だよ! | 1606645203000 | 2 | random4 | random3 |
2 | 1606645204000 | おじゃまします! | 1606645204000 | 1 | random5 | |
2 | 1606645205000 | あっ、時差があるのね。 | 1606645205000 | 2 | random6 | random3 |
idという複合キーとは別にシステムで勝手につけたユニークキーをつけます。
idはランダム文字列。replyToIdに返信先のメッセージのidを格納することで、アプリケーション側で対応しました。
const messages = Message.findMessagesByRoomId(2) // 裏側にdynamodbのdocClientがある。
for(let i =0; i < messages.lenght; i ++){
// もしreplyToIdにidがあればはけておき、あとから該当のidをもつメッセージを取得して、subMessagesとして持たせておく。
}
みたいな感じです。
あと、idという複合キーとは別に単一のプライマリーキーをもたせることで、削除や編集apiがスッキリしました。このidにはindexを貼ってます(GSI)
Thread方式
1つ前では replyToIdというカラムを使って、Slack型を実現しました。ただ、これは予想なのですが、slackのDBはそうはなっていないと思います。
Threadという考えを取り入れているからです。
便宜的にSlack型でインデントしていないメッセージを「メインツリー」。インデントしてある返信メッセージを「サブツリー」と呼ぶことにします。ここでメインツリーのメッセージはひとつひとつがThreadになります。そして返信が始まると、そのThreadに返信メッセージが格納されます。
Threadのjsonのイメージはこんな感じです。
[
{
threadId: rundom1,
main: {
content: 'こんにちは!',
postUesrId: 1,
createdAt: 1606645200456
}
sub: []
},
{
threadId: rundom2,
main: {
content: 'こんばんは!',
postUesrId: 2,
createdAt: 1606645201000
}
sub: []
},
{
threadId: rundom3,
main: {
content: 'まだ昼だよ!',
postUesrId: 1,
createdAt: 1606645202000
}
sub: [
{
content: 'こっちは夕方だよ!',
postUserId: 2,
createdAt: 1606645203000
},
{
content: 'あっ、時差があるのね。,
postUserId: 1,
createdAt: 1606645205000
}
]
}
]
これをDBに再現するとしたら次のようでしょうか。
roomId | threadCreatedAt_createdAt | content | updatedAt | postToUserId |
---|---|---|---|---|
2 | 1606645200456_1606645200456 | こんにちは! | 1606645200456 | 1 |
2 | 1606645201000_1606645201000 | こんばんは! | 1606645200456 | 2 |
2 | 1606645202000_1606645202000 | まだ昼だよ! | 1606645202000 | 1 |
2 | 1606645202000_1606645203000 | こっちは夕方だよ!' | 1606645204000 | 2 |
2 | 1606645202000_1606645205000 | あっ、時差があるのね。 | 1606645205000 | 1 |
2 | 1606645204000_1606645204000 | おじゃまします! | 1606645204000 | 3 |
ソートキーが変わりました。threadCreatedAt_createdAt
になっています。これはthreadの作成時間とメッセージの作成時間を組み合わせることで、
- スレッドは作成順に並ぶ
- 返信メッセージはスレッドに紐付いて作成順に並ぶ
- 部屋単位でメッセージを取得したい
といった要求をソートキーの仕組みを使って満たしています。
特定のthreadだけを取得したい場合は、ソートキーの検索の際にbegins_with
という機能を使えば、1606645202000
を指定して、1606645202000
に紐づくthreadとメッセージだけ取得することもできます。
ただ、この当時はThreadという概念を知らなかったのと、dynamodbの扱いになれていないのもあって、単純にreplyToIdで実現できるやり方を採用しました。
ソートキーのベストプラクティス的にはこれが正解だと思います。
json方式
ハッシュキーとソートキーのみ外だしして、あとはjsonに入れるパターンです。jsonないにはいっているメッセージひとつひとつにはインデックスがはれない(検索できない)ので一旦親経由でsubはまるごと更新する必要があります。
また、dynamodbはソートキー以外では自動的にソートしてくれないので、json以下の配列は自身でソートして入れる必要があります。
main0 = {
content: 'こんにちは!',
postUesrId: 1,
createdAt: 1606645200456
}
main1 = {
content: 'こんばんは!',
postUesrId: 2,
createdAt: 1606645201000
}
main2 = {
content: 'まだ昼だよ!',
postUesrId: 1,
createdAt: 1606645202000
}
main3 = {
content: 'おじゃまします!',
postUesrId: 3,
createdAt: 1606645204000
}
sub2 = [
{
content: 'こっちは夕方だよ!',
postUserId: 2,
createdAt: 1606645203000
},
{
content: 'あっ、時差があるのね。',
postUserId: 1,
createdAt: 1606645205000
}
]
roomId | createdAt | main | sub |
---|---|---|---|
2 | 1606645200456 | main0 | [] |
2 | 1606645201000 | main1 | [] |
2 | 1606645202000 | main2 | sub2 |
2 | 1606645204000 | main3 | [] |
第3の返信ネスト(インデント)をどう実現するか。
ビジネスサイドと会話しているうちに、第3のネストが必要なことがわかってきました。
それはスタンプ機能を実現するためです。スタンプや画像もメッセージの一部として作っていました。
roomId | createdAt | content | type |
---|---|---|---|
2 | 1606645200456 | こんにちは! | message |
2 | 1606645201000 | https://example.com/hogehoge.jpg | stamp |
みたいな感じです。上のセクションでモデリングした状態だとこuser2の「こっちは夕方だよ」というメッセージに対してこうなっちゃいます。
user1: こんにちは!
user2: こんばんは!
user1: まだ昼だよ!
user2: こっちは夕方だよ!
user1: あっ、時差があるのね。
user3: [スタンプ]
user3: おじゃまします!
user3はuser2に対して返信スタンプを押したはずなのに、user1に押したみたいになってます。スタンプはuser2のコメントの下に来てほしいのです。
user1: こんにちは!
user2: こんばんは!
user1: まだ昼だよ!
user2: こっちは夕方だよ!
user3: [スタンプ]
user1: あっ、時差があるのね。
user3: おじゃまします!
設計から見直すパターン
そもそもスタンプをメッセージとして設計しているのが悪いので、type=stampではなく、他にカラムを作りスタンプの設計をします。
replyToId方式から拡張するパターン
roomId | createdAt | content | updatedAt | postUserId | id | replyToId | type |
---|---|---|---|---|---|---|---|
2 | 1606645200456 | こんちには! | 1606645200456 | 1 | random1 | message | |
2 | 1606645201000 | こんばんは! | 1606645201000 | 2 | random2 | message | |
2 | 1606645202000 | まだ昼だよ! | 1606645202000 | 1 | random3 | message | |
2 | 1606645203000 | こっちは夕方だよ! | 1606645203000 | 2 | random4 | random3 | message |
2 | 1606645206000 | https://example.com/hogehoge.jpg | 1606645206000 | 3 | random7 | random4 | stamp |
2 | 1606645204000 | おじゃまします! | 1606645204000 | 1 | random5 | message | |
2 | 1606645205000 | あっ、時差があるのね。 | 1606645205000 | 2 | random6 | random3 | message |
アプリケーション側の実装を拡張して第3層を実現します。
スタンプのreplyToIdがrandom4になっているのがわかると思います。
raondom3 -> random4 -> random7
のように、メッセージがつながっています。
アプリケーション側のコードは省きます。
しかしこの設計だと、randome -> random4 -> random7 とつなげるのに、基本3重ループ以上が必要になり、アプリケーション側のその部分のコードだけめちゃくちゃ大きく、メンテしづらいものになりました。もっと素直にDBで表現したいところです。
Thread方式から拡張するパターン
単純に考えるとこんなかんじでしょうか。
roomId | threadCreatedAt_createdAt | content | updatedAt | postUserId | type |
---|---|---|---|---|---|
2 | 1606645200456_1606645200456_0000000000000 | こんにちは! | 1606645200456 | 1 | message |
2 | 1606645201000_1606645201000_0000000000000 | こんばんは! | 1606645200456 | 2 | message |
2 | 1606645202000_1606645202000_0000000000000 | まだ昼だよ! | 1606645202000 | 1 | message |
2 | 1606645202000_1606645203000_0000000000000 | こっちは夕方だよ!' | 1606645204000 | 2 | message |
2 | 1606645204000_1606645204000_1606645205000 | https://example.com/hogehoge.jpg | 1606645205000 | 3 | stamp |
2 | 1606645202000_1606645205000_0000000000000 | あっ、時差があるのね。 | 1606645205000 | 1 | message |
2 | 1606645204000_1606645204000_0000000000000 | おじゃまします! | 1606645204000 | 3 | message |
ソートキーにbegins_with
を使えば、そのスレッドのメッセージ一覧、もしくは第2層の特定のメッセージに紐づく第3層のメッセージ一覧を取得できそうです。
ただ、すでに既存のテーブルが存在する場合、データを上記の様に加工しなおして、入れ直す必要があります。
json方式から拡張するパターン
jsonの値が一つ増える感じです。素直ですね。
main0 = {
content: 'こんにちは!',
postUesrId: 1,
createdAt: 1606645200456
}
main1 = {
content: 'こんばんは!',
postUesrId: 2,
createdAt: 1606645201000
}
main2 = {
content: 'まだ昼だよ!',
postUesrId: 1,
createdAt: 1606645202000
}
main3 = {
content: 'おじゃまします!',
postUesrId: 3,
createdAt: 1606645204000
}
sub2 = [
{
content: 'こっちは夕方だよ!',
postUserId: 2,
createdAt: 1606645203000,
stamps: [
{
url: 'https://example.com/hogehoge.jpg'
}
]
},
{
content: 'あっ、時差があるのね。',
postUserId: 1,
createdAt: 1606645205000
}
]
roomId | createdAt | main | sub |
---|---|---|---|
2 | 1606645200456 | main0 | [] |
2 | 1606645201000 | main1 | [] |
2 | 1606645202000 | main2 | sub2 |
2 | 1606645204000 | main3 | [] |
ドキュメントからみる設計ベストプラクティス
長い間このドキュメントの意味がよくわからなかったのですが、ようやく理解できました。
やり方なのですが、まずはRDBにおけるテーブルに位置するやつ(Entity)をエンティティ図ではなくグラフで再現します。
例えばチャットアプリの場合は、
- ルームにいくつかユーザーが参加している
- ユーザーはそれぞれメッセージを送ることができる
- メッセージは返信ができる
- 返信メッセージに対しても返信ができる
とします。これをグラフで再現するとこんな感じでしょうか?
DynamoDBでは、これを隣接関係リスト設計パターンを使って実装します。やり方はかんたんです。
ハッシュキーとソートキーにそれぞれのノード(丸いやつ)を設定するのです。
どういうことかというと、
room1は user1 と user2 と message1 につながってますよね。dynamodb上ではこう表現します。
hashkey | sortkey | data |
---|---|---|
room1 | user1 | 太郎 |
room1 | user2 | 花子 |
room1 | message1 | これはメッセージです |
つぎに、message1 をみますと、 user1, reply1, reply2 とつながっています。これを加えます。
hashkey | sortkey | data |
---|---|---|
room1 | user1 | 太郎 |
room1 | user2 | 花子 |
room1 | message1 | これはメッセージです |
message1 | user1 | 太郎 |
message1 | reply1 | 返信メッセージ1 |
message1 | reply2 | 返信メッセージ2 |
従来のスキーマという概念のあるRDBになれると同じカラムに異なる属性がはいるのは気持ち悪いですが、このノード同士の関係をどんどん行として追加していきます。隣り合うノードどうしを表現するので隣接関係リスト
設計パターンなわけですね。
ここで message1 に room1 をもたせるかどうか悩みました。矢印の向きはroomが親、messageが子になりそうだったので、room1 -> message1 にしています。もしmessage1 に room1 をもたせると、複数の同じデータがはいり冗長になりそうだったので子から親の列はなくしておきました(例えば message2があったとき message2にroom1の行を追加すると同じ情報になる。)
のこり。
hashkey | sortkey | userName | messageContent |
---|---|---|---|
room1 | user1 | 太郎 | |
room1 | user2 | 花子 | |
room1 | message1 | これはメッセージです | |
message1 | user1 | 太郎 | |
message1 | reply1 | 返信メッセージ1 | |
message1 | reply2 | 返信メッセージ2 | |
reply1 | user2 | 花子 | |
reply1 | rereply1 | 返信1の返信メッセージ1 | |
reply1 | rereply2 | 返信1の返信メッセージ2 | |
reply2 | user2 | 花子 | |
rereply2 | user1 | 太郎 |
あとはroom1自体の情報がないので追加しておきます。
hashkey | sortkey | userName | messageContent | roomName |
---|---|---|---|---|
room1 | room1 | 5組 | ||
room1 | user1 | 太郎 | ||
room1 | user2 | 花子 | ||
room1 | message1 | これはメッセージです | ||
message1 | user1 | 太郎 | ||
message1 | reply1 | 返信メッセージ1 | ||
message1 | reply2 | 返信メッセージ2 | ||
reply1 | user2 | 花子 | ||
reply1 | rereply1 | 返信1の返信メッセージ1 | ||
reply1 | rereply2 | 返信1の返信メッセージ2 | ||
reply2 | user2 | 花子 | ||
rereply2 | user1 | 太郎 |
完成です。これで
- room1 に参加しているユーザー
- room1に投稿されたmessage
が検索できます。
ただ、ベストプラクティス的に隣接関係リストつくりましたが、「room1にひもづくメッセージを返信とそのまた返信含めてほしい」ときは複数のクエリを投げることになりそうですね ^^;
今度は、どうすれば「room1にひもづくメッセージを返信とそのまた返信含めてほしい」を実現できるか考えてみます。
追記、
数カ月間運用してみてDynamodbには複合ソートキーが一番拡張しやすいなと思いました。
HK(ハッシュキー)はstring型 or number型。そのテーブルの大本のidを保持。例えばチャットアプリであればチャットページidなど
SK(ソートキー)はstring型。ここをどう設計していくかがきも。
例えば次のように一つのテーブルでコメントとお気に入り機能を実現できる。ちなみに複合ソートキーの分け目は#が良いと思う。_を最初採用していたがハッシュキーを利用していると混同してしまう可能性あり。
HK | SK | content | favorite_commentIds |
---|---|---|---|
1 | comment#thread作成日時#コメント作成日時 | コメントだよ | |
1 | favorite#ユーザーid | {comment#thread作成日時1#コメント作成日時1, comment#thread作成日時1#コメント作成日時2} |
こんな動画あったんだ。。。