吉田 正広

ルームの機能をモジュール化して、もうこれ以上コピペしなくて済むようにした話


はじめまして!

東京スタジオでエンジニアをしている、吉田と申します。

リリース後も、アップデートを繰り返していくオンラインゲーム。
安定したサービスを提供しつつ、常に新しい遊びを創造していくというのは、なかなか難儀なものです。

アップデートで機能追加をする度に、プログラマを悩ませるのが、

  • 安全のために、動作実績のある既存のプログラムはできるだけ変更したくない。
  • でも、コードの保守性や可読性のために、共通機能を関数化/クラス化するなどのリファクタもしたい。

こんなジレンマ。エンジニアのあなたも、身に覚えがありませんか?
それとも、「動いているコードは触ってはいけない」というルールを頑なに守って、リファクタを避けてはいないでしょうか?

でもそれだと、既存の仕組みに縛られて、新しい機能の実装が難しくなるし、アップデートと共に少しずつ大きくなって、今やなんの機能なのかわからないクラスなどがあると、バグの対応にも一苦労ですよね。

その上担当者も変わったりして、もう誰も中身を把握していない・・・、なんて状態になる前に、上手にリファクタしつつ、安全性も保ちながら、コードを保守していきたいものです。

先日4周年を迎えた「幻塔戦記グリフォン」(以下グリフォン)では、C++で書かれた、リアルタイム通信機能を担うサーバーがあります。
やはり4年もたつと、古いソースも多々あり、上記のような問題に常に直面しています。
今回は、新しい機能を実装するにあたって、私が行った取り組みについて紹介します。

現状のソース

グリフォンでは、いろんなタイプのクエストやバトルがあり、それぞれがクラスで表現されています。

CRoomBase          -共通で使う基本的な処理
  +- CQuestRoom         -シナリオなどのクエスト
  +- CPvPRoom           -定期的に開催されるPVP対戦
  +- CMockBattleRoom    -いつでもバトルが試せる、模擬戦
  +- CBattleRoyalRoom   -バトルロイヤル
  +- CColosseumRoom     -コロシアム

+- は継承を意味します。

CRoomBaseには、共通機能(主にゲーム内のオブジェクトの管理)が入っています。

それぞれの子クラスには、大まかに

  1. 入室までの処理
  2. ゲーム開始までの処理
  3. ゲームのルール処理
  4. ゲーム戦績の保存
  5. ゲーム終了後の処理

こんな機能が実装されています。

現状のソースコードを眺めてみると、こんな状態になっていました。

  • CPvPRoomCMockBattleRoomは、入室してゲーム開始するまでの流れが違うが、ゲームルールは一緒
  • CBattleRoyalRoomは、CMockBattleRoomをコピペし、一部機能を修正して作ったっぽい
  • その後、CMockBattleRoomにも独自の機能が追加されてるっぽい
  • その他、同じ様でちょっと違う処理が随所にみられる

だんだんつらくなってきた・・・もう既存のソースは見たくない・・ってなりました。
エンジニアの方なら、こんな気持ちをわかってくれるはず!!

そして今回

新しいゲームを提供するため、新たにルームクラスを作ることになりました。(仮にCNewBattleRoomとします)
その要件は、

  • 入室&ゲームスタートまでの流れはCBattleRoyalRoomとだいたい一緒
  • ゲームルール、戦績保存は、CQuestRoomとだいたい一緒
  • 加えて、今回初めて実装する機能

さてどうしたものか・・・
まず簡単に思いつく方法は、

  1. CBattleRoyalRoomをコピーして、CNewBattleRoomを作る。
  2. できたCNewBattleRoomのうち、いるものだけ残していらない物は削除する(もしくは使わないなら放置)
  3. CQuestRoomの一部機能をコピーしてCNewBattleRoomに入れる
  4. あと、足りない機能を入れる

こんな感じでしょうか。またコピペが増える・・・・
このアップデートをもって、サービス終了! ならこれでいいのですが、グリフォンはまだまだ続くのです・・!!

じゃあ、共通の機能を1つにまとめる? でも、共通化できそうな機能も、それぞれのルームで微妙に違ってたりするし(どうしてこうなったのか、もう分からないし)
これもつらいのです。

—-そして悩んだ結果、
よし、今回だけは、コピペを許そう。ただし、今後はコピペしないで済むようにする。

と決めました。

そのために、各機能をモジュール化して、そのモジュールたちを付け替える事で、ルームを表現できるようにします。

新しいクラス構成は

CRoomBase               -共通で使う基本的な処理
  +- CModularRoomBase        -各モジュールを乗せるためのベースクラス
      +- CNewBattleModule    -新しいルーム特有の機能を実装したモジュール
      +- CPvPCommonModule    -対戦共通モジュール
      +- CCommonModule       -共通モジュール

今回だけは、既存ソースからコピペして、各モジュールを作ります。

そして、モジュールを組み合わせたルームを生成するためのテンプレートクラスを

template <typename... Modules>
class CModularRoom : public virtual CModularRoomBase, public Modules...
{
...
};

こんな風に定義します。

例えば、Common,PvPCommon,NewBattleの3つの機能を実装した CNewBattleRoom を作成するには、

typedef CModularRoom<
    CCommonModule,           //テンプレート引数に、入れたいモジュールを羅列する
    CPvPCommonModule,
    CNewBattleModule>
        CNewBattleRoom;

こんな感じです。

こうしておけば、例えば今後、新しいルームクラス CHogeBattleRoom を作るときには、CHogeBattleModuleを作り、CNewBattleModuleと差し替えて、

typedef CModularRoom<
    CCommonModule,
    CPvPCommonModule,
    CHogeBattleModule>       //←ここだけ差し替える
        CHogeBattleRoom;

とすればいい訳ですね。

もし、CHogeBattleRoomを作るなかで、「CNewBattleRoomのこの部分は共通化したい」となった場合は、新しい CFugaCommonModule を作って、機能を分離すれば良いのです。

typedef CModularRoom<
    CCommonModule,
    CPvPCommonModule,
    CFugaCommonModule,     //←CNewBattleModuleから一部機能を分離
    CNewBattleModule>
        CNewBattleRoom;

typedef CModularRoom<
    CCommonModule,
    CPvPCommonModule,
    CFugaCommonModule,     //←分離して作ったモジュールをこっちにも入れる
    CHogeBattleModule>
        CHogeBattleRoom;

こうなります。

ところで、class CModularRoom がいろんなモジュールを多重継承してるけど、ひし型継承問題が起こるんじゃ?と思いましたか?

そんな時のために、C++には仮想継承という機能があります。

各モジュールに

class CHogeModule : public virtual CModularRoomBase
{
};

こんな感じで virtual 指定で継承すれば、ひし形問題は起こらずに済むんですね。

終わりに

今回の記事では、アップデートを繰り返していると必ず直面する、安全性 vs 保守性 のジレンマに対する弊社での取り組みについて紹介させて頂きました。
予測不能?なプランナーさんの考える企画を、リスクを回避しつつ、最大限の効果が得られるように、どうやって実現するか? エンジニアの腕のみせどころです。
Happy Programming!