スマホゲームのサーバサイドアーキテクチャについてモヤモヤと考えていること

とりとめもなく書き散らかします。

テスタビリティ

真面目に単体テストを書く人がチームに入ったのでテスタビリティについて真面目に考えています。特にインゲームロジックについてはテストを組めるようにすることの価値が非常に大きいので、手間暇かけてテスト可能にしています。

身も蓋もないことをいうと中核のロジック以外はとどのつまりはIOみたいなものなので(ビューやタイマーとかも含む)、IOのレイヤーを差し替え可能にすることがテスタビリティを担保します。動的言語ではレイヤーなどは差し替え自由自在ですが、C#ではこのための最も有力な手段はDIになると思います。

DI考

副作用、寿命、実行時パラメータ、ポリモーフィズム

実製品レベルでDIを組んでみると寿命の問題が難しさの中心にくることがわかります。

ServiceA → ServiceB → ServiceC のような依存がある場合、一番寿命の短いものに全体がひきずられる、ということが良くあります。

public class ServiceA
{
    private ServiceB _serviceB;
    pubilc ServiceA(ServiceB serviceB) => _serviceB = serviceB;
}

public class ServiceB
{
    private ServiceC _serviceC;
    pubilc ServiceA(ServiceC serviceC) => _serviceC = serviceC;
}

services.AddSingleton<ServiceA>();
services.AddSingleton<ServiceB>();
services.AddTransient<ServiceC>(); //NG: 寿命が短いものが寿命が長いものに使われている!

この手の寿命問題を簡素にするためには、DIコンテナ管理化に置かれるオブジェクトの多くを状態をもたない関数にしてしまうのが最も良いでしょう。状態をもたなければ、TransientだろうがSingletonだろうがそのオブジェクト自体は同じ動作をするので、自分が依存サービスに合わせて自分の寿命をいかようにも落とせます。ステートレスなWebサービス内なら仮に全部TransientにしたとしてもDIに生成されるオブジェクトが1リクエストで何万も生成されるわけないのでallocのコストは無視して良いレベルに収まります。
※リアルタイムなゲームサーバで1サーバマルチインスタンスで毎秒何十フレームも回しているケースではallocのコストも考えたほうがいいとは思います

状態を持たないオブジェクトを主体にすると、ポリモーフィズムの適用範囲もDI管理下のオブジェクト群に寄っていくことになります。

つまり

//サービスのクライアントコード
var obj = _factory.Create(type, param);
obj.DoPolymorphic(); //objの型に応じた処理

から

//サービスのクライアントコード
_service.DoPolymorphic(type, param);

という、パラメータをコンストラクタで詰めて後で命令だけ出すような可能な限り駆逐されていく方向に向かいます。これはもう必然というか、DIをやってくと前者のようなオブジェクトは使いにくく感じるはずです。DIが、合成のルート(ASP.NET MVCのControllerみたいな)に対して一発Resolveすることで連鎖的に生まれるもろもろは生成の時点では柔軟にパラメータを渡せないことがほとんどなので、パラメータ渡しのためにFactoryを作る羽目になるのが嫌だなぁと、自然になっていくと思います。

生成の重たいサービス

public class HogeController
{
    private readonly IServiceA _serviceA;
    private readonly IServiceB _serviceB;

    //コンストラクタは省略

    public IAsyncResult DoA()
    {
        _serviceA.DoNantoka();
        ...
    }
    
    public IAsyncResult DoB()
    {
        //DoBが呼ばれたときだけしか使われないサービス
        //実は生成が重たいので、コンストラクタインジェクションは勿体ない
        _serviceB.DoNantoka();
        ...
    }
}

コメントにかいてあるとおり、コンストラクタで注入はするものの使うかどうかは状況次第の重たいサービスみたいなのがあるとしたら勿体ない。ので、IServiceBを取得するためのブツだけ注入して、状況に応じて使うということが考えられます。

public class HogeController
{
    ...
    private readonly IServiceBFactory _serviceBFactory;

    //コンストラクタは省略

    ...

    public IAsyncResult DoB()
    {
        var serviceB = _serviceBFactory.Create();
        serviceB.DoNantoka();
        ...
    }
}

DIの本を読むと、これはアンチパターンらしいです。なんでや!

理由は「Leaky Abstraction」とのことで、解決策として以下のようなコードを上げています。

public class LazyServiceB : IServiceB
{
    private readonly Lazy<ISerivceB> _lazy;
    public LazyServiceB(IServiceBFactory factory)
    {
        _lazy = new Lazy(() => factory.Create());
    }

    public void DoNantoka()
    {
        _lazy.Value.DoNantoka();
    }
}

こいつを挿せばDoB以外ではコスト気にしなくていいでしょうと。なるほどそれ自体はごもっとも。

ただし性能が大事なアプリでコストを隠蔽するのは個人的にはあまり好きではありません。重さの理由がIO待ちだったらfactoryはCreateではなくCreateAsyncを公開するべきだろうし、そういうものを含めた全体で見た場合には必要な情報を露出しているだけで「Leaky Abstraction」とは思えないのですね。ただし後者のLazyな例がハマるケースもあるだろうとは思っています。

実行時パラメータを渡してオブジェクトを生成しなければならない場合はFactory一択ですし、ケースバイケースで考えて使う。

キャッシュとCQRS

Redisのようなキャッシュ層をいれる場合はCQRS(コマンドクエリ責務分離)も採用でいい気がしている昨今です。クエリ専用のDBやテーブルをつくらなくても、コード内でServiceを分けるだけでもだいぶスッキリするかと思います。つまりこう。

public class DeckController
{
   private readonly IDeckQueryService _queryService;
   private readonly IDeckCommandService _commandService;
}

そもそも更新のときには、更新の前の読み取りでキャッシュをつかってはいけないわけですし、根本的に通るコードや関連クラスを分けてしまったほうがスッキリする。クエリはDB or キャッシュのデータアクセスと、クライアントに返すDTOへの変換が主なのでビジネスロジックが関与する余地が少ない。フィルタ要件によっては面倒も多いけど、単純作業が多いだけで複雑ではない。デルレイヤーを通す必要もないはずです。

コマンド側は複雑なことが多いです。強化であれば、餌になったキャラが消えた結果、セットしていたデッキからは外す必要があり、強化された結果進化しちゃったりすることがあり、ユーザーのパラメータアップによってミッションが達成されることがある。こういうドメインロジックの複雑さはコマンド側に寄ります。決してクエリではない。

コマンド側もキャッシュから読み取ってはいけないがキャッシュを飛ばす必要はあるので、キャッシュと完全にノータッチとはいきませんがそれでも分けたほうが幸せなんじゃないかというのが僕の肌感覚です。ちなみに実戦投入はしていません。

サーバサイドで何をキャッシュするか

ガラケーのブラウザ時代と違ってスマホならばアプリを一度起動すれば自分のデータは自端末にキャッシュされるわけだから、自分のデータの表示のためのRedisキャッシュはあまり意味をなさないのではないかと思います。ギルドやフレンドなどで他人のデータを見る時がRedisキャッシュの一番の用途となる。そう考えると、たとえば他人のデッキ一覧を見る要件は無いが他人が最後に使用した(いわゆる現在の)デッキを見ることはできる要件があるならば、デッキ詳細のオブジェクトグラフをシリアライズしてユーザー単位で格納、とかのほうがキャッシュ効率は良さそうです。ただし、アプリ起動時の一発目にどかっと自キャラ情報をとるところでDBを守らなくて良いのかという気持ちもなくはないので、テーブルをそのまま突っ込むような汎用キャッシュとすぐ手が切れるかと言われればまだ迷いはあります。モヤモヤ