「Applibot Advent Calendar 2021」 4日目の記事になります。
前日は @kazunosuke さんのスマートコントラクト触ってみた という記事でした!
#目次
#1. はじめに
出して終わりではなく、運用や仕様変更などが頻繁に行われるゲームでは、既存のテーブル設計とは少し違う視点で設計する必要が出てくる場合があります。
新規のゲームを立ち上げから5つほど実際に担当した筆者が、大事だと思う点をまとめてみましたので、他では適さないケースもあるかとは思いますが参考にしてもらえると嬉しいです。
※こちらは弊社で行った新卒研修のために準備した、「ゲームにおけるテーブル設計」についての資料を加筆修正して調整したものになります。
#2. 用語定義
ゲームにおけるテーブル設計は、大きく下記の2つの種類に分けられます
- マスターデータ
- ユーザーデータ
##2.1 マスターデータ
キャラクターの名前や情報などの、ユーザーの行動には寄らないゲームで固有のデータのことをマスターデータと呼んでいます。
こちらは運営が入力するデータで、基本的にアプリの更新やイベントの配信などのタイミングでデータダウンロードという形で配信しており、単にマスターと呼ばれることが多いです。
###2.1.1 マスターデータの特徴
プロジェクトや言語によりますが、マスターデータは下記の特徴をもつ場合が多いです。
- データの管理にはDBをもちいるが、データをサーバー用とクライアント用で別々にまとめたものをそれぞれに配布して、起動時にメモリ上に展開して利用されることが多い
- メモリ上にある場合が多いため、主キーなどで取得できる場合マスターの検索コストをあまり気にする必要がない
- 直接DBを参照しない場合が多いため、テーブル変更時のマイグレーションを考慮する必要が少ない
ここではマスターデータはキャッシュされている前提で取り扱いたいと思います。
##2.2 ユーザーデータ
ゲームを遊んでくれている人のことをユーザーと呼び、ユーザーの行動に応じて保存されるデータをユーザーデータと呼んでいます。
プロジェクトによってはマスター以外のデータを合わせて、トランザクションデータと呼ばれることもあります。
###2.2.1 ユーザーデータの特徴
- ユーザーデータはユーザーを特定できるuser_idをもち、基本自分のデータしか追加・更新をしない
- ユーザーデータのテーブルには大量のデータが保存されており、テーブル変更時のマイグレーションを考慮する必要がある
- 定義の追加・変更を行う場合メンテに入れる必要があるなど
- 基本的には後方互換性を保つ必要があり、カラムの追加やテーブルの追加などしか行わない
- 後方互換を保っていない場合、アプリが公開されるまでメンテに入れるなどの対応が必要になる
- 変更したい場合は、カラムを追加後に参照を外してから削除するなどの対応が必要
- Aのカラムを修正したい例
- v1.1でA'を追加する
- v1.1でクライアント側でA->A'に切り替えてもらい、Aへの参照を完全になくす
- v1.1に強制アプデ後、サーバー側でAのカラムを削除する
- Aのカラムを修正したい例
ここではユーザーデータはRDBに保持され、すべてのレコードでuser_idをもっており、user_idでシャーディングも考慮している前提で取り扱いたいと思います。
##2.3 よく使うテーブル構成
###2.3.1 Groupテーブル
複数のレコードに対して1つのID(グループID)で指定したい場合にグループ化したい場合があります。
そのようなときに作るテーブルをGroupテーブルと呼び、suffixに_groupをつけて表現します。
下記のように複数のレコード側にgroup_idをもたせることで、複数のレコードがただ一つのグループIDに所属することなどを表現できます。
character_groupテーブル
id | name |
---|---|
1 | チームA |
2 | チームB |
characterテーブル
id | name | character_group_id |
---|---|---|
1 | アリス | 1 |
2 | ボブ | 2 |
3 | キャロル | 1 |
###2.3.2 Relテーブル
複数のテーブルの複数のレコードを紐付けたい場合に作るテーブルをRelテーブルと呼び、suffixに_relをつけて表現します。
Groupの例の用に複数のレコード側にカラムを追加せずに、参照関係を表すことができます。
複数のレコードが複数のグループIDに所属したい場合などに良く利用します。
character_group_relテーブル
character_group_id | character_id |
---|---|
1 | 1 |
1 | 3 |
2 | 2 |
character_groupテーブル
id | name |
---|---|
1 | チームA |
2 | チームB |
characterテーブル
id | name |
---|---|
1 | アリス |
2 | ボブ |
3 | キャロル |
また、下記のように、character_group_relにチームAにボブを追加すれば、ボブはチームA,Bの両方に所属することが可能になります。
このように複数所属可能なことを表現する際に利用することことが多いです。
character_group_relテーブル(変更後)
character_group_id | character_id |
---|---|
1 | 1 |
1 | 2 |
1 | 3 |
2 | 2 |
#3. 設計
マスターデータやユーザーデータにおいて、テーブルの設計は異なってきます。
ここでは共通な命名規則や、それぞれの基本的な設計方針を記載しますが、厳密ではなく状況に応じてある程度柔軟に変更する必要があります。
下記に個人的に推奨している命名規則をいくつか上げてみました。
##3.1 命名規則
プログラミング全般において命名規則は重要なものですが、特にテーブル設計において命名規則にブレがあると混乱の元になってしまいます。
そこで、プロジェクトごとにルールを作り、できる限り準拠することが望ましいです。
下記に個人的に推奨している命名規則をいくつか上げてみました。
###3.1.1 基本方針
- 同内容の命名は統一する
- わかりやすい命名を心がける
- 難しい英単語を避ける
###3.1.2 テーブル名
テーブル名にかぎったことではないのですが、関係性がわかりやすいように、枠組みが大きい順に命名することを推奨しています。
また、マスターのテーブルかユーザーのテーブルかわかりやすいようにprefixを付けることも推奨しています。(マスター:master_、ユーザー:user_など)。
例えばログインボーナスのテーブルを作成する場合、下記のようにlogin_bonus -> event or normalといった命名になります。
- event_login_bonus -> master_login_bonus_event
- user_event_login_bonus -> user_login_bonus_event
- normal_login_bonus -> master_login_bonus_normal
- user_normal_login_bonus -> user_login_bonus_normal
このような命名をしておくと、event_login_bonusとlogin_bonus_eventがeventの枠組みの中のlogin_bonusという機能なのか、login_bonusの枠組みの中のeventという機能なのかを判断できるようになります。
上記のような命名はあまり起こらないかもしれないですが、わかりやすい名前をつけようとした結果全然別の機能なのに似たような名前になり、結果非常に混乱しやすい命名になってしまうパターンがあります。
###3.1.3 カラム名
テーブルのカラム名についてですが、下記のように自身のテーブルに関する情報の場合テーブル名を省略することを推奨しています。
- character.character_id -> character.id
- character.character_name -> character.name
ただし、自身以外のテーブルに関する情報を参照する場合は、テーブル名をprefixにつけるようにします。
例えばcharacterテーブルからskillテーブルのidを参照する場合は下記のようになります。
- character.skill_id
さらに追加情報付与したい場合は、追加でprefixをつけて表現することも可能です。
下記は初期スキルのIDをskillマスターから設定する例になります。
- character.default_skill_id
別テーブルを参照する場合基本idを指定することになると思いますが、id系以外のカラムの場合も同様のルールで設計します。
###3.1.4 汎用カラム名
テーブル設計において頻出する仕組みなどが存在する場合、命名がぶれないように汎用カラム名を事前に定義しておくことで、混乱をある程度回避することができます。
下記によく使われるサンプルをいくつか示します。
id
- idは一意のレコードを指す
- 0は無効値として取り扱う(idに限らず例外を除き、基本0値は無効とする)
no
- 途中抜け可能なユニークな数字
- 基本1から順番に振られる
idx
- 0からはじまる連番(0を許容する数少ない例外)
- 順番が重要なものはidxを使う(大本が配列のようなデータなど)
order_asc
- 昇順の並び順を表す
- 重複不可
- 後からの追加が常に末尾ならこちら
order_desc
- 降順の並び順を表す
- 重複不可
- 後からの追加が常に先頭ならこちら
priority
- 重要度を表し降順の並び順を表す
- 重複可
- 第二ソートキーとしてレコード作成日時を指定する場合が多い
- 基本はレコード作成日でソートするが、優先度を高く設定したものがあればそちらを優先して表示する等の場合に用いる
###3.1.5 suffix
カラムの型だけでは特定できない、単位を補完する情報など、特定のルールでsuffixを定義しておくと混乱をある程度回避することができます。
下記のように分による期間を表す場合duration_min、秒による期間を表す場合duration_secといったようにすることで、単位で迷うことはなくなります。
時間の場合は単位をつけていない場合ミリ秒として表すといったような、プロジェクト固有の決めの単位をもつなどすると、カラム名がシンプルになり便利です。
suffixの例
suffix | 意味 |
---|---|
date | 日付 |
time | 時刻 |
datetime | 日付と時刻 |
percent | 百分率(%) |
permil | 千分率(‰) |
min / minute | 分 |
sec / second | 秒 |
ms | ミリ秒 |
ns | ナノ秒 |
##3.2 マスターデータの設計方針
運用時においてマスターの入力ミスを起因として発生する不具合は非常に多いです。
そのためマスターがわかりやすかったり、データ追加時に使い回しができる設計であり、入力レコード数が少なければ、不具合も発生しにくくなります。
また設計次第では、クライアント・サーバー双方のパフォーマンスにも影響するため、常に注意して設計する必要があります。
下記に基本的な設計方針を列挙しました。
###3.2.1 重複データを作らない
同じデータを重複して別テーブルに持つと、片方を変更した際にもう片方の変更漏れが発生するなど、非常に不具合が発生しやすくなります。
正規化で回避できるものもありますが、正規化するほどのデータでもない場合や、結果重複データになってしまったもの、他にもどうしても重複データとして持ちたい場合なども存在します。
状況に応じて対応策は異なりますが、ロジックで自動生成するなどできる限り回避策を検討しましょう。
1つ例を出すと、イベント限定で特定の期間だけクエストやミッション、ログインボーナスを有効にしたいといった場合を考えてみましょう。
普通に実装するとそれぞれのマスターに開始時刻、終了時刻を持ったりする場合が多いと思いますが、イベントの開催期間が変更された際に、ミッションだけ変更しそびれてしまった場合、ミッションだけ早期終了してしまうなどの意図していない動作に繋がります。
例えば期間マスターなどをつくり、イベント開催期間のレコードのidを発行し、各テーブルでそのIDを参照させれば、期間マスターのイベント開催期間のレコードを修正することで一括で変更できるようになり上記のケースは回避可能です(typeなどでイベントなどの識別ができればロジックで回避することも可能)。
このような、運用の都合で同じデータが入力されやすい箇所などは、設計段階では判断しにくいため特に注意しておくと良いでしょう。
###3.2.2 入力ミスが発生しにくい設計にする
入力ミスが発生しにくくなるよう、複雑な構成を避け、できるだけシンプルな設計を推奨しています。
また、手動で入力する数が多ければ多いほどミスが発生しやすくなると考えています。
そのため、自動入力や検証機能のサポートなどが重要であると考えています。
###3.2.3 各種設定を使い回せるようにする
運用で変更が起こらないような設定・データは別テーブルに定義して参照できるようにすることを推奨しています。
また、デバッグデータなども同じ設定を使い回すことで簡単に複製できたりするなどのメリットもあります。
デバッグデータの追加のしやすさは意外と恩恵があり、簡単にデータが増やせることでデータ増加時などのテストの工数が減少し、結果テストが取りこぼされづらく、素早くできるので不具合が発見しやすくなります。
###3.2.4 運用で変更があるものは、修正ではなく追加で対応できるようにする
運用で変更が起こるようなものは、現在参照しているレコードのカラムを直接修正するのではなく、新規にレコードを追加して、特定期間まで旧レコードを参照し、特定期間後に新規レコードを参照できるように切り替えられる仕組みにしましょう。
###3.2.5 リクエスト・レスポンス・ユーザーデータに関わるようなマスターの場合、主キーは1つが好ましい
クライアント側からの通信部分や、ユーザーデータにおいて、できる限りid1つで状況を特定できると好ましい場合が多いです。
そのため、上記で利用されるテーブルの場合、サロゲートキーなどを発行したりして、主キーを1つに保てると良いでしょう。
主キーの追加などの構成が変わる場合の変更コストは特に大きく、主キーを複数設定している場合のほうが、主キーを1つに制限しているものよりも変更に弱い場合が多いです。
もちろん例外も存在するため、利用想定などを十分に確認して実装しましょう。
##3.3 ユーザーデータの設計方針
運用時にカラムの追加や修正など、定義を変更したい場合、メンテナンスや強制アップデートなどのコストがかかる場合があります。
主キーの変更なども多大なコストがかかるため、単数なのか複数なのかなど、仕様をしっかり把握・調整して設計する必要があります。
###3.3.1 原則主キーはuser_idのみか、user_idとmaster_idの2つの主キーにする
マスター側でも触れましたが、マスターの方がテーブル定義の変更に強いため、参照したmaster_id側で定義を修正できるように、
マスターにサロゲートキーを貼ってできる限り主キーがuser_idとmaster_idの2つになるようにできると良いでしょう。
###3.3.2 主キーはauto_incrementをできる限り避けてmaster_idにする
シャーディングなども考えるとそもそもDBの機能を用いたauto_increment自体を避けたほうが良いのですが、idをユーザーで発行するパターンは避けたほうが良いでしょう。
これは仕様の目的が満たせるなら、仕様を変更してでも対応したほうが良いと考えています。
master_idでレコードが管理できる状態にできると、いくつ所持してもmaster_id数に応じて最大のレコード数がきまるため、レコードが制限なく増加することを防げます。
必要最小限のものに絞るとそこまで自身でidを発行するケースは多くないため、ぜひ気をつけてみてください。
また、idを発行する場合は最大所持数や期限などの制限を仕様に盛り込んで、一定数以上増加しないように注意しましょう。
###3.3.3 自身のユーザーデータのみ更新する
ゲームにおいてフレンド機能など自身以外のデータを更新したい場合がありますが、その際でも他人のデータを更新しないようにしましょう。
これは仕様による影響も大きいため、エンジニアだけでなく仕様決定者も巻き込んで調整する必要がある場合もあります。
例えばフレンドの場合、申請・承認パターンではなく、フォロー・フォロワーパターンに変更するなどでも対応可能であり、またログイン時にフレンドのデータを参照して状態を更新することでも回避が可能です。
どうしても自身以外のデータを更新したい場合はロックを掛けたり、インサートやINCなどを使うことで、データが巻き戻らないような仕組みで行なう必要があります。パフォーマンスにも非常に影響が出やすいポイントなので、十分な負荷試験や注意が必要です。
###3.3.4 バッチが必要になるような設計を避ける
どちらかと言うと設計よりはロジック側の依存度が高いのですが、apiの呼ばれるタイミングやレコードの初期化タイミングによっては、バッチ処理を当てる必要が出てくる場合がでてきます。
ロジックミスやマスター入力不備などの不具合が発生した際にバッチが必要になるような設計を避けましょう。
###3.3.5 マスター側で参照できるデータはユーザーデータに保持しない
マスターから引けるデータをユーザーデータに保存しないようにしましょう。
もし保持してしまった場合、マスターを変更した際にバッチ処理が必要になるなどのデメリットが生じます。
こちら3.3.4のケースの一部ではあるのですが、マスター入力ミスによる不具合は非常に多いので別途記載とさせていただきました。
常に意識しましょう。
逆にマスター変更の影響を与えたくないなどの、仕様として持つ必要がある場合も存在します。
###3.3.6 レコードが存在する場合所持しているというような判定はさける
レコードを生成した上で所持している個数、またはフラグを持っている方が、後々所持していないが別の状態を保つ必要が出てきた際などの仕様変更に強いです。
他にもフラグではなくenum値を使ったりすることで、後の仕様変更への影響を最低限に抑えることができる場合があります。
また、クライアント側の話になりますが、リストに表示する際にレコードの所持ではなく、マスターが有効かどうかで表示を行なうようにしてもらったほうが良いでしょう。もしサーバーが返したレコードのみ表示する場合、新たに全員に表示する必要があるマスターが追加された場合に全員にバッチ処理で入力する必要出てきたり、クライアント側に表示するためだけにから大量のレコードの挿入などが必要になったりなどしてしまいます。
もちろんロジックで回避もできますが、クライアント側も巻き込んで、不必要にレコードを増やさずに必要になったタイミングでレコードを増やすような設計を心がけましょう。
###3.3.7 運用・仕様変更を常に意識する
いくつかの項目とかぶるのと、ただの心構えの問題となってしまいますが、設計するに当たり常に「どのように運用するか」、「どのような仕様変更が入りそうか」などのことを意識しましょう。
これは他のソーシャルゲームをやり込んだり、UIのデザインなどから方向性を多少ながら推測可能になるため、話題のゲームや新規のゲームなどは一通り触っておくと良いでしょう。
これは、設計を選択する際に追加仕様が発生した際に破綻しないことが担保されていれば、実装自体はなくて問題ないです。
もし破綻する恐れがある場合は、仕様の方向性を責任者とできる限り握りましょう。
これは事前に予測していくつかの仕様をサポートするわけではなく、あくまで破綻しないかを参考にするだけで大丈夫です。
実際の実装自体はあらかじめ修正が入ることがほぼ決まっている場合でなければ、基本的には仕様変更が決まってから実装しましょう。
事前に実装してしまった場合いらないロジックがあることによるメンテ性の低下や複雑度の向上につながります。
そのときそのときで最善でできる限りシンプルな方法が取れると望ましいです。
#4 おわりに
今回挙げた設計についてはすべてを絶対に守る必要があるというものではありません。一番大事なことは「面白いゲームを提供できること」だと自分は思っており、それを実現するために尽力できたらと考えています。また、ゲームを前提に考えていますが、他のサービスでも共通する部分もあるかと思いますので、少しでも参考になれば幸いです。