ゲーム開発でドメイン駆動設計を実践してわかった光と闇

形から入ったドメイン駆動設計によるゲーム開発の光と闇 #2/2 >> 1はこちら

10分経っても返ってこないAPIのチューニング

中榮健二氏(以下、中榮):次にいきます。「10分経っても返ってこないガチャAPIのチューニングの話」です。

キャラクターや装備を配布するガチャのAPIがあって、POSTでやりますと。

とりあえず、ガチャAPIの修正を頼まれたので、回しちゃうぞ〜と。フロント側で見ていると、HTTP Status 500。PHPで何が起こったか見たら、「30秒過ぎちゃった」と書いてあると。「そうか、時間が足りなかったか〜」と思って、PHPの実行時間を伸ばします。

伸ばしたら、今度はnginxがプロキシタイムアウトと言われました。どれほど伸びるかわからなかったので、とりあえず10分ぐらいにしてみたんですよね。まだ落ちる。恐ろしい。

これはレイヤ分割したことと無関係ではなくて、形から入ったDDDをやったことと無関係ではなくて、たぶんEloquentをそのまま使っていたらここまで遅くならなかったかもしれないです。

Entityと言ってたんですけど、ドメインごとに1つ集約といって、アグリゲートルートとも言うんですけど、メインのEntityを決めるのですが、ガチャの場合はガチャというそのもののEntityがありました。このEntityが大きかったんですよね。

ガチャはマスタの設定でレゴみたいに組み合わせてめちゃめちゃ柔軟に設定できるようにしたい思いが仕様書に詰め込まれてまして、ガチャのマスタテーブルだけで20個ぐらいあったと。さらにガチャの内容物にそのまま装備とかキャラクターとかほかのEntityが入っていて、多いところで1,000個ぐらいありました。純粋に大きかった。

これだけ大きいものを作るためにクエリ数も多かったんですけど、DBレコード、EloquentモデルからEntityへの変換も時間がかかっていて、メモリも食ってました。

DBクエリのほうも先ほど触れましたけど、Repository以外、Factoryで発行されている脱法クエリがありました。

むしろレイヤ分割してないときのほうが、こういうN+1に気づきやすいんですよね。FactoryでN+1問題が発生しているケースが多くありました。

さぁ、よろしい、パフォーマンスチューニングをしよう。

最後の問題から解決しようとして、とりあえずちゃんとレイヤの責務を再整理してわかりやすくしようと思いました。Repositoryで全部クエリを発行できるようにもしました。だけど、目指しただけで、完全には整理できなかったところもあるのですが。

Repositoryに載せた上で、Repositoryでどうやればクエリ発行やFactoryの処理を最小にできるを最初に何人かで考えて、プラクティスを共有しました。Eloquentという便利なORMの機能を使わずに、EagerLoadを手動でやりました。悲しいことになっています。

マスタデータは、基本的にデプロイからデプロイまでの間は変更する必要がないので、キャッシュしました。以前のプロジェクトだとDBクエリレベルでキャッシュもやっていたんですけど、Factoryのコストも考えて、オブジェクトをそのままローカルのメモリにキャッシュする作戦をとっていました。

これ、デプロイのときにウォームアップしないと1回目が非常に遅くなるので、デプロイのたびにキャッシュのウォームアップ処理も入っています。

あとは、ここまで戦略を立てて、あとは「推測するな、計測せよ」という金言のとおり、ClockworkというLaravelのデータをいろいろ取ってくるサードパーティのライブラリを拡張して管理画面でクエリ数とキャッシュの取得数を可視化して、地道にチューニングしました。

もともと、それはLaravel用だったので、Eloquentのどこでクエリが発行されたかを追跡できてたんですけど、僕らのRepositoryの中でどこで発行されているかが欲しかったので、それを追跡できるように改造して使っていました。

ユーザーアイテムのAPIがあって、その中でどんだけクエリが走っていて、それぞれのクエリの発行元がどのソースコードのどの部分かを見れるようにして、地道にチューニングしてました。これは実行数18ですけど、ひどいAPIだと2万になってたり、実行時間が30秒だったり、いろいろありましたね。

チューニングした感想と教訓

こうやってチューニングしていった感想と教訓です。考えて、ベストプラクティスを定めたあとの新規実装は、慣れてきたらすごく楽でした。ドメインから詳細を隠蔽しているとはいえ、開発者はやはりRepositoryの内部を気にする必要があって、DBクエリレベルで「ここまで最適化できるな」と考えてRepositoryも書いていかないといけないなと思いました。

完全に修正できなかったところは今でも負債として残っていたりします。

最大の教訓は、おそらくガチャを重くしすぎたので、モデル化のタイミングで必要ない属性は落とすことも重要だなと思いました。

この時点で10分以上かかっていたのが数秒ぐらいには縮んでいまして、そのあと、負荷試験チームという専門のチームがいまして、「4万RPSぐらいに耐えろ」という無茶振りがあったのをがんばっていたらしいので、無事に実用的に数百ミリ秒程度で収まったそうです。

Webフレームワークとの付き合い方の変化の話

あとは、光が残ってました。軽量DDDを実践する上で、Webフレームワーク「Laravel」との付き合い方が変わりました。

もともとEloquentを使っていて、EloquentはActiveRecordでDBレコードに自我が芽生えたようなオブジェクトを使うんですけど、はじめにDBありきです。

CRUDぐらいしかないアプリだったらすごい手軽に開発できて、それこそRailsが初期にウリにしてた「15分で作れるぜ」みたいなものでお手軽です。機能が多いので、規模が大きくなってくると秩序を保ち続けるのがつらい状態でした。

Repositoryパターンを採用したら考え方が逆になります。はじめにオブジェクトのほうがある。Entityがある。EntityがたまたまRepositoryの実装でDBに保存されている。そういう逆の考え方になってきた。

ORMにテーブル設計を引きずられているところもああって、デフォルトが基本的にサロゲートキーといって連番でIDをつけていくタイプでした。DDDをやり始めてからは、DBにID生成を任せるのは都合が悪いということになって、マスタ系・所持系、それぞれ整数ID決め打ちだったり、複合主キーだったり、所持系はクライアント側で生成できるUUIDをバイナリで突っ込んだほうが絶対いい、という感じの結論に至ってました。

また、ActiveRecordみたいにDBとモデル1対1じゃなくて、Entityとテーブルは必ずしも1対1じゃなくていいのが身に染みてわかってきました。

Eloquentについてもう1つ。Controllerの役割がだいぶ減りました。最初はControllerの中で全部ソフトウェアのユースケースのフローを行ってたんですけど、最後はServiceの、ドメイン層の入出力をHTTPリクエスト・レスポンスにマッピングするだけになっていました。

ControllerがなくてもServiceまでで一連の処理は完結しているということでした。それがたまたまController、UI層によってHTTPの世界に表現される、プレゼンテーションされるだけだと考え方が変わりました。

ドメインファーストと書いていますけど、レイヤを分けたことでフレームワークに依存するのがUIとインフラ層だけになってまして、ドメイン層だけでテストが書けるようになりました。DB設計をいったん忘れてドメインオブジェクトを書いて、「これをユースケースで使ったら、うまくいくかな?」みたいなところから設計を始めて、実際に実装も始められるようになりました。

1つ悪いことがあります。たまに最後にDBマイグレーションファイルを書き忘れて、動かそうとしたら動かないみたいなポカはあるんですけど。

我々の設計がまずあって、ドメインの設計があって、それに必要なインフラやUI、もしくはドメイン層のユーティリティなどが、Laravelのコンポーネントを使って便利に実装できる感覚に変わりました。

フレームワークと喧嘩するわけではないんですよね。ただフルに活用するのが難しくなっただけで、必要な機能をInfrastructureとかUIに活用できる感覚です。

まとめです。DB、UIファーストからドメイン、オブジェクトファーストへ。フレームワークを活用はするけど、ドメイン層の設計までをフレームワークに渡さない。俺たちの設計にフレームワークを従わせる感じの考えに変わっています。

境界づけられたコンテキスト

次に、「境界づけられたコンテキスト」という DDDの本の中でも難解な概念がありまして。

モデルはなにかを抽象化するといいましたが、同じ何かに対して複数のモデルができることがあります。こういうケースでモデルが複数あっても混乱しないように「このモデルはここからここまで」「このモデルはここからここまで」みたいなスコープを明確化することが重要だと書いてあります。

そのスコープの決め方なんですけど、アプリケーションの用途が変わるところとか、チームに応じて境界を定めて、同じ1つの境界の中ではモデルが2~3つできないように、という感じのことが書いてあります。

例に挙げられていたのが輸送アプリケーションで、「荷物をこの日に輸送すると予約する」というコンテキストと「荷物を実際に配送する」というコンテキストで、境界づけられたコンテキストの例として載っていました。今回解説しているソシャゲのプロジェクトでは、唯一のモデルですべての機能にほとんど対応していました。

最初のほうでゲームサーバにいくつか機能があると言ったんですけど、ゲームAPIのほかにマスタデータ管理とCS管理画面があります。

マスタデータ管理、この管理画面の目的は、開発者というよりプランナーがゲームバランスの調整を柔軟に行うための機能です。そこでマスタデータの入力ミスもけっこう起こるので、入力内容のバリデーションが要件に入ってくることが多いです。

いろいろな手法が世間ではありまして、Excelで入力はさせるけど、DSLによって独自の言語を定義してバリデーションできるようにする。ほかにも、前のプロジェクトであったんですけど、Googleスプレッドシートで管理してて、1人の超越者がいまして、すべてのCSVを管理していたと。人の形をしたマスタ管理システムもあります。

今回のプロジェクトは専用のマスタ管理画面を設けております。基本的に入力フォームから一つひとつ更新するかたちで、CRUD形式。入力したデータはCSVやJSONに出力した上でコードと一緒にバージョンを管理していました。リリースによってデータが変わることもあるので。

この管理画面でもゲームと同じドメインオブジェクトを使ってたんですよね。管理画面のために「ゲームでは使わないのになぁ」みたいなメソッドがたくさん紛れ込んできたり、Serviceに管理画面専用のメソッドが生えてきたり、そもそもゲーム側のEntityとは違う粒度でもう少し細かいところでバランス調整をしたいみたいなときに余計なものがついてきたり、そういうケースで問題が目立ったかなと思います。

そのプロジェクト内の対策としては「同じドメインオブジェクトは使うけど、Serviceぐらい分けようよ」みたいな感じで落ち着いてました。

このマスタ管理画面なんですけど、ゲーム本体のコアではないので、もうちょと省力化したり分離してもよかったのかなと思っています。例えば、管理画面だけCRUDを15分で作れるEloquentをそのまま使うことや、マスタ管理画面を分離してそっちはそっちで好き勝手やるという考えもありかなと思ってました。

あと、なによりデータの一括入力が不向きで、最終的にCSV一括インポートみたいな別の機能が作られていたりしたので、表計算ソフト方式が強いわけだなと思いました。先ほど言ったGoogleスプレッドシートのほかに、例えばAirtableなどのマスタ管理に向いてそうなSaaSがあったりするので、そういうものを活用するのもぜんぜんありかなと思います。

CS管理画面について

2つ目はCS管理画面。これはお問い合わせ対応用の画面です。運用の人たちが使います。ユーザーの行動履歴、ユーザーが何時何分にこのショップの商品を買ったなどの履歴の商品を残しておいて、お問い合わせに「いやいや、うちのシステムバグってないですよ」「ああ、やっぱバグってました」などと対応するために活用するための機能です。

このCS管理画面なんですけど、履歴を作るためにゲーム側のServiceに履歴追加処理がどんどん埋められていったんですよね。例えば、キャラクターを強化する処理……限界突破といってレベル以外に強くなる概念があるんですけど、これで限界突破の処理をしたあとに、CS管理画面のためだけに履歴を残す処理みたいなものが増えてくる。

1人がCS管理画面の作業をやっていて、1人がゲームの作業をしていたりしたら、コンフリクトもしたりします。でも、これこそイベントを用いてServiceにアプリケーションを分離できたかなと思っています。リアルタイム性を求められないので、イベントを飛ばしてキューにやってワーカーで処理することで十分だったと反省していました。

こんな感じでまだ手探りではあるんですけど、境界づけられたコンテキストについても多少は「ああ、こういうことか」というのがわかってきました。

ちょっと深いモデル

最後。光が少なかったので急遽つけ足したんですけど(笑)。

ソシャゲでよくある機能で、アイテムとか装備とかキャラクター、ユーザーの持ち物をユーザーに付与する機能がありまして、例えば、ユーザーのミッションの報酬やバトルのドロップやショップで商品を購入することで頻出の機能なんですよね。

コンテキストに応じてプレゼントボックスに送ったり、ユーザーの持ち物として直接付与したり。新規入手だったらキャラクターの挨拶を入れないといけないので、新規入手かどうかの判定を行ったりします。あとはユーザーはキャラクターを100体までしか持てないとかだったら、100体までの上限判定を行ったりします。

基本的に機能ごとに開発していたので、そういうコードが重複していました。例えばアイテムだと、アイテムの有効期限を決めて、何個あるかという情報や、装備品だと経験値と個数といった情報が重複していました。switchとかで分岐してそれぞれのところに処理が書いてあった。あまりに多すぎて、「これはさすがに共通化しないと、あとで大変なことになるんじゃね?」と気づいたので、これを共通化しました。

ただコードをまとめただけじゃなくて、先ほどのユーザーに付与されるデータをインターフェースでまとめました。完全に造語なんですけど、ユーザーが受け取れるものをReceivableと命名しました。Receivableを作っておけば、あとは専用のサービスで処理できます。

だから、そのServiceを使う側からすれば、ユーザーが受け取れるものを受け取らせるだけという認識で使えるようになりました。抽象レベルが改善しました。だいぶ重複するコードも削除されたのでハッピーハッピーです。

軽量DDDを実践した感想とまとめ

最後です。感想とまとめ。

ゲームのための設計とかインターフェース、前までだいぶフレームワークに依存していたんですけど、フレームワークの機能は置いておいて、自分自身で設計を考えられるようになったなというのが1つ目です。

クラスを必然的に多く作れることになるので、オブジェクト指向にだいぶ慣れてきます。オブジェクト指向は段階が2つぐらいあって、誰かが作ったクラスを使えるというのと、自分でクラスの生産者になれるという段階があって、後者にだいぶ慣れてくるかなというのが1ついいところだなと思いました。

あとは、パターンといっても、そのパターンは戦略を実現するために理由があって持って来られたものなので、パターンの役割を考えることが重要だと思います。このスライドを作るにあたってエヴァンス本を読み返したりしてたんですけど、悩んだことが書いてあるんですよね。5章・6章、Entity、VO、Factory、Repositoryあたりだけでも参考になるので、ちらっと読むとすごく参考になるなと思いました。

最後に、タイトルに寄せるために無理やりつけ加えたんですけど、ゲームはもともとコンピュータ上でできるものを作っているので、開発者から仕様の距離というか、開発がやりやすいようにできていることが多くて、モデリングしやすいんじゃないかなと思ったりしました。軽量DDDも捨てたものではないと思います。

最後です。Nextatではエンジニアを募集しています。先ほど言ったゲーム以外にも、業務システムやECサイトや決済系など、いろいろ請けてやっています。設計について議論してくれる方が少ないので、来ていただけるとうれしいです。

京都の会社なんですけど、最近、東京オフィスができました。この前「PHPカンファレンス沖縄」で沖縄に行って沖縄いいなと思ったので、「沖縄進出したいね」って話をしてました。

以上です。ご清聴ありがとうございました。

(会場拍手)

軽量DDDのディレクトリ構成

司会者:今日のお話でなにか質問されたい方っておられますか。どうぞ。

質問者1:お話、ありがとうございました。質問は2つあって、1つは軽量DDDで開発をやった際に、ディレクトリ構成が気になって、どんなディレクトリ構成にしたんでしょうか? Fat Modelを解消されたと思うんですけど、逆にここにかなり寄ったみたいな事例があったら教えていただきたいです。

あとは、先ほどあまり聞こえなかったんですけど、4万 Per Requestですか?

中榮:4万 Request Per Second?

質問者1:はい。DB側の設定もたくさんあると思っていて、どういうふうにDB側はやったのかなと気になりまして。

中榮:ありがとうございます。1つ目の話から。ディレクトリ構成ですか……書いてくるべきでしたね。今日紹介したプロジェクトでとっていたものと、本当は僕はこうやったほうがいいなと思っているものが2つあります。

1つ目からなんですけど、1つ目はアプリケーションのソースのディレクトリがあって、Domains、Services、Repositoriesみたいにクラスの機能で切っていくタイプです。Domainsの下に各機能ごとに、例えばアイテムや装備、Equipmentなどのクラスの機能でまず分割するタイプでこのゲームは開発していました。

僕個人としては、むしろソースのルートにItemsがあって、その中をDomainsやServicesで分けたほうが本来ドメイン駆動に沿っているのではないかなと思っています。

双方たぶん良い点や悪い点あると思うんですよね。前者のほうがおそらく既存のフレームワークから入ってきているとわかりやすい。ですが、後者のほうが完全にパッケージ化とかもしやすい。例えば、このアイテムパッケージを完全に外部にしても、本当にうまくいけばできるかもしれない。決済系だけ別のマイクロサービスにするときには、たぶん後者のほうがいいんじゃないかなと思っています。

インフラの分割について

中榮:2つ目は何でしたっけ?

質問者1:4万リクエストでDB側の……。

中榮:たぶん後ろの方のほうが詳しいんですけど(笑)。

タカハシ:僕が答えたほうがいいですかね。すみません、テクロスのタカハシです。僕はこの案件でインフラの担当をしていたので、僕のほうが答えられるかなと思います。

端的に言うと、データベースを分割しまくりました。動かしていたのがAWS環境用だったんですけど、AWSのAuroraというRDBMSのサービスがあるんですけど、それをたくさん並べて気合いで受ける方法がありました。

ただ、スケールするまでに、先ほどのチューニングの問題でそもそもパフォーマンスチューニングすらできないみたいな感じだったので、だいぶ難航しましたね。New Relicというパフォーマンスの解析ツールを入れたんですけど、入れると動かないみたいな。

(会場笑)

スタックトレースが長すぎて動かない状況からスタートしたという。そこからなんとかとりあえず分析ツールが動くようにするし、クエリを減らしました。定石ですけど、発行されるクエリはデータベースをたくさん並べて分割して受ける。

中榮:最初マスタ系と所持系のDBがあったのを、所持系が非常に細切れに分割された感じです。だから、基本的にそのリポジトリでもJOINなどはほとんど使ってなかったです。

タカハシ:そうですね。そこはもう分割しやすいかたちで。

中榮:非常に単純なクエリを大量に発行するみたいな、Auroraが得意なタイプのクエリでほとんど攻めてましたね。

質問者1:ありがとうございます。

質問者2:今回はゲームのアプリをDDDで作った話だったんですけど、これから先に似たような機能の似たような感じのゲームを作ることになったとして、DDDでやっぱり作りますか? それともMVCに戻ったりしそうですか?

中榮:軽量DDDで書いていて、保守はとてもしやすかったんですよね。どこを直したらこれが直るみたいなものがすごくわかりやすくて。なので、複雑なところはやっぱり軽量DDDでの形式で書くのがいいかなと思っていて、例えば表示だけMVCに、単純にORMを使って開発もありかなと考えています。

今回、完全に全部固く書いたので、コアを定めてほかのところはゆるく書いたりするのも選択肢ですし、境界づけられたコンテキストみたいに、例えば決済系だけを分離することや、認証だけ分離することはぜんぜんやっていきたいなと思います。

質問者2:ありがとうございました。