
ブレイドエクスロードを例にした大規模開発におけるC#コード解析とUniRxでの非同期処理のベストプラクティス
Cysharpの河合(@neuecc)です。アプリボットさんには、gRPC関連の支援なども含め、ちょこちょことお邪魔しております。と、いうわけで、ゲスト寄稿回!今回は先日TGS2018で発表された新作、ブレイドエクスロードにおける UniRx の使用例について、解析手法など含めてご紹介します。
UniRxは、私の開発しているオープンソースのライブラリで、Unityに特化したReactive Extensionsの実装となります。イベント周りの処理や、非同期周りの処理が書きやすくなる、ということを利点にしていて、現在は特に日本のモバイルゲームのライセンス表記ではよく見かけるようになりました。
ブレイドエクスロードでは、広範に全面的に根っこから導入されていると聞いて、プロジェクトを見させていただきました。なるほどなるほど、パッと見るだけでも実際確かにかなり使われています!ここから漠然と何から手を付けるべきか。そこがとても悩ましいのですが、まず可視化から始めましょう。例えば、どのメソッドがよく使われているのかを。それにより、私の想定ではあまり使われないないものが多く使われていれば、特殊な利用法がなされているでしょうし、逆にもっと使われるべきものが使われていなければ、使ったほうがいいんじゃないかな?というアドバイスができたりします。
C#によるC#の解析
C#コンパイラはRoslynというコードネームで、C#自身で記述されていて、また、そのAPIをC#から呼び出すことも可能です。それによりC#プログラムをC#で解析することができます。通常、そこからLintを作ったりコードジェネレーターを作ったりしますが、今回はメソッド利用数を取ってみましょう。
今回はBuildalyzerを使います。Buildalyzerは上述のRoslynをラップしたもので、解析に必要なWorkspaceの構築をシンプルにしてくれます。
using Buildalyzer; using Buildalyzer.Workspaces; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace UniRxUsageAnalyzer { class Program { static async Task Main(string[] args) { // Buildalyzerのルート var manager = new AnalyzerManager(); // slnを指定してまるっと持ってくるでもいいんですが、とりあえずcsproj単品で並べてみた。 // 解析対象となる対象のプロジェクトと、参照としてのUniRxのプロジェクトを登録(Getすると同時に登録も行われるという仕様……) manager.GetProject(@"C:\***\UniRx.csproj"); manager.GetProject(@"C:\***\UniRx.Async.csproj"); manager.GetProject(@"C:\***\Assembly-CSharp.csproj"); // 登録されたプロジェクト群からAdhocWorkspaceを作成 var workspace = manager.GetWorkspace(); var targetProject = workspace.CurrentSolution.Projects.First(x => x.Name == "Assembly-CSharp"); // コンパイル結果みたいなのを取得 var compilation = await targetProject.GetCompilationAsync(); // 比較対象の型シンボルをCompilationから持ってくる var observableSymbol = compilation.GetTypeByMetadataName("UniRx.Observable"); // 収集 var methods = new List<IMethodSymbol>(); foreach (var tree in compilation.SyntaxTrees) { // Syntax -> Symbol変換するためのModelの取得 var semanticModel = compilation.GetSemanticModel(tree); // シンタックスツリーからメソッド呼び出しだけを抜き出し var invocationExpressions = tree.GetRoot() .DescendantNodes() .OfType<InvocationExpressionSyntax>(); foreach (var expr in invocationExpressions) { // その中からUniRx.Observableに属するメソッドのみを抽出 var methodSymbol = semanticModel.GetSymbolInfo(expr).Symbol as IMethodSymbol; if (methodSymbol?.ContainingType == observableSymbol) { methods.Add(methodSymbol); } } } // 解析 Console.WriteLine("## TotalCount:" + methods.Count); var grouping = methods .Select(x => x.OriginalDefinition) // Foo -> Fooへ変換 .Select(x => x.Name) // 今回はメソッド名だけ取る(オーバーロードの違いは無視) .GroupBy(x => x) .OrderByDescending(x => x.Count()) .Select(x => new { MethodName = x.Key, Count = x.Count() }) .ToArray(); // マークダウンの表で出力 Console.WriteLine("| メソッド名| 呼び出し数 |"); Console.WriteLine("| --- | --- |"); foreach (var item in grouping) { Console.WriteLine($"| {item.MethodName} | {item.Count} |"); } } } }
本題からそれるので、ここでは深くコードの説明はしませんが、色々できそうだという雰囲気を感じていただければ幸いです。興味を持ったら、是非Roslyn沼に沈みましょう。
さて、出力結果なのですが、その前に、まず私が見る前の時点での解析結果の上位はこうなっていました。
メソッド名 | 呼び出し数 |
---|---|
Do | 547 |
SelectMany | 471 |
AsUnitObservable | 454 |
Select | 345 |
ReturnUnit | 312 |
Defer | 301 |
Merge | 281 |
Where | 195 |
AsSingleUnitObservable | 135 |
Concat | 116 |
SelectMany
, Do
, AsUnitObservable
, Select
, Where
, ReturnUnit
などが多いのは想定通りです。ちょっと多いな?と思ったのは Merge
で、逆にあって然るべきなのに一つもなかったのが WhenAll
でした。
そこで、Merge
ではなくWhenAll
を使っていったらどうでしょう、というアドバイスの後、現在の解析結果は――
TotalCount:5275
メソッド名 | 呼び出し数 |
---|---|
SelectMany | 743 |
Do | 725 |
AsUnitObservable | 576 |
Select | 475 |
Defer | 459 |
ReturnUnit | 431 |
Merge | 270 |
Where | 237 |
Concat | 184 |
AsSingleUnitObservable | 146 |
Return | 144 |
Create | 128 |
WhenAll | 127 |
First | 95 |
Empty | 73 |
FromEvent | 72 |
FromCoroutine | 61 |
Timer | 47 |
Throw | 45 |
Never | 38 |
EveryUpdate | 31 |
ToObservable | 20 |
DoOnCompleted | 20 |
Skip | 16 |
Cast | 12 |
TimerFrame | 10 |
Delay | 8 |
AsObservable | 7 |
StartWith | 7 |
DelayFrame | 6 |
SubscribeOn | 6 |
StartAsCoroutine | 5 |
Switch | 5 |
Catch | 5 |
Publish | 5 |
DoOnSubscribe | 5 |
TakeUntilDestroy | 3 |
FirstOrDefault | 3 |
Repeat | 3 |
RepeatUntilDestroy | 3 |
Interval | 2 |
ObserveOnMainThread | 2 |
ThrottleFirst | 2 |
TakeUntil | 2 |
Amb | 2 |
DistinctUntilChanged | 1 |
Zip | 1 |
SkipWhile | 1 |
LastOrDefault | 1 |
Scan | 1 |
ZipLatest | 1 |
DoOnCancel | 1 |
Start | 1 |
DelaySubscription | 1 |
合計で5275(※数字は開発中のものです)、これはかなり多いと思います!相当ヘヴィに使っていますね。さて、Mergeの個数はむしろ減少し、逆にWhenAllの数が増えています。つまり、置き換え可能であったということで、良い形になったと思われます。
Mergeとは
http://reactivex.io/documentation/operators/merge.html
Mergeは複数のObservableを合成し、一つのObservableに変換するオペレーターです。それはいいのですが、私の感覚では、頻繁には出てこないオペレーターという印象があります。というのも、UniRxの使い道には「イベント周りの処理」と「非同期周りの処理」があり、Mergeは「イベント周りの処理」のためのものですが、UniRxでは多くの場合「非同期周りの処理」のために使われているケースのほうが多いからです。「ブレイドエクスロード」ではどちらの処理も使われてはいますが、Mergeに絡む箇所では非同期周りの並列実行のために用いられているケースが多めでした。
非同期周りの処理は Unityのターゲットフレームワークが .NET 4.5 以降の場合は、C# 5.0の機能を活かした async/await に置き換えることが可能です。UniRxでは Unity 2018.3 以降によるC# 7.0の機能を活用したUniTask(UniRx.Async)によるasync/awaitのUnityへの統合を提供しています。
なぜMergeが非同期周りの処理に向いていないかというと、ストリームの長さが可変になるので、場合によってハンドリングしにくくなることが挙げられます。
ストリームの長さ
Rxで長さ、というと唐突に出てきた概念のようですが、Rxは時間を横軸に取った、コレクション操作のように見立てることができます。
なので、そうしたストリームに対して発行される回数を長さと呼んでいます。非同期処理となるストリームの長さは一回しかイベントが来ないため、「1」です。ReactivePropertyやTimer、ボタンクリックなどイベント系とかは長さ「∞」です。
そして、長さが1のものは特別扱いが必要です。これは同期メソッドと非同期メソッドの関係性を考えてみれば分かりやすいかもしれません。
同期の場合、戻り値が複数のものはコレクションとしてforeachで扱い、単一の場合は直接呼びます。async/awaitが使える場合は同じような関係性で、複数のものはRxで、単一のものはawaitで扱うことができますが、async/awaitがない場合はどちらも統一的にRxで扱うことになります。
IObservableで全てを表せること。それが強みでもあり、同時に弱みでもあります。イベント(長さ∞)なのか非同期(長さ1)なのか型からは分からない。同じオペレーターで同じ処理を適用でき、どちらも区別せずに合成が可能とはいえ、アプリケーション上でどう扱うかは全く異なります。一回の処理のつもりだったら複数回処理が呼ばれるようになってしまっては困るし、逆に、シーケンスとして考えると長さが0になる、つまり一度も呼ばれなくなるようなことも起こりえます。例えばWhereでフィルタすることはRxにおいて頻出パターンですが、非同期を期待するシーケンスにWhereをかけて長さが0になった場合は後続が実行されなくなります。つまり、IObservableで全てを統一的に扱うことはバグの元であり、むしろ統一的に扱いたくないのです(必要な時に変換できる程度で十分)。
現状、型として区別がつかないのですが、何らかの形で見分けをつけたほうが良いので、お薦めするやり方としては、非同期を表現するIObservable(長さが必ず「1」のもの)に関しては、名前をXxxAsyncというように、後ろにAsyncをつけます。これは.NET 4.5以降のasync/awaitによるTask(つまり長さが「1」を保証している非同期表現)の命名規約と同一のため、将来的にasync/awaitへと移行する際にも互換性のある命名規約となります。
話を戻して、ブレイドエクスロードでは、ストリームの長さに対する統一感がなかったため、並列実行の待機としてMergeを用いていたり、場合によっては分岐によって戻り値の長さが膨らむものがありました。
if (foo) { return Observable.Merge(AsyncA(), AsyncB()); } else { return AsyncA(); }
これは条件分岐によって長さが1になったり2になったりしてます(Mergeによって増える)。この場合、非同期を期待してonNextに1回のみ実行する処理を書いていると、複数回発行されてバグとして処理されるでしょう。ただし、ブレイドエクスロードではそのような場合への対処として、onNextではなくonCompletedに処理を書くという回避方法が全体的なルールとして浸透していたため、長さが可変なことによる問題は起こっていないようでした。
これは、ようするにTask(戻り値無し)としてIObservableを扱うということに等しいので、運用例的には十分なものです。とはいえ、基本的にはonNext側に処理の本体を書き、onCompletedへは例外的な場合に使うほうが、全体的な統一感は取りやすいので、原則論としてはonNextだけで完結するような処理に変更していったほうが良いでしょう。
具体的な対策としては
- 長さを意識して、非同期として戻り値が1の場合はAsyncサフィックスをつける
- 非同期の複数処理をまとめる場合はWhenAllを使う
ことになります。
WhenAll
WhenAllは並列実行の待機を行うオペレーターです。
var results = Observable.WhenAll(AsyncA(), AsyncB(), AsyncC()); results.Subscribe(xs => { // この場合の↑のxsは[a, b, c](AsyncA/B/Cの戻り値のIOが剥げたもの) });
Merge().AsSingleUnitObservable() でも同じ効果ですが、WhenAllはより最適化された実装になっています。(ちなみにWhenAllは本家Rxには存在しなくて、これはasync/awaitのTask.WhenAllから発想を得たUniRx独自の最適化用オペレーターです)
前述の通りRxにはonNextとonCompletedの2つの実行可能なポイントが存在するため、 WhenAll().Subscribe(_ => { /* OnNextでなにかする /}) は Merge().Subscribe(_=> {}, () => {/ OnCompletedでなにかする */}) としても実行結果は一緒ですが、処理を書くのはOnCompletedよりもOnNext側に寄せていったほうが、統一感が出ます。
まとめ
Rxは学習コストの高さと、それ自身の複雑さにより、簡潔に書けるようになるはずが余計に複雑化するという、リアクティブスパゲティ問題があります。ブレイドエクスロードのプロジェクトは、大型プロジェクトとして根本からRxを導入しつつ、早期の統一的なルールの策定と、ちょっとした拡張を用意するなどして、全体的には上手く構築されているという印象があります。
今回のMerge -> WhenAllも、変更可能な話ではありましたが、統一されたルールで運用されていれば大きな問題ではありません(そもそもWhenAllはUniRxの独自拡張であり、本家のRxには存在しない)。柔軟性の高さがRxの利点であり、同時に複数人が長期間関わる大きなプロジェクトでの欠点でもあります。
その点だけ意識して組み上げれば、大きな武器になるのではないでしょうか。私も、こうしたプラクティスの発信など、よりよく使える環境を作っていきたいと思っています。
また、 冒頭での解析プログラムのように、Unityでのスクリプティング以外のC#の活用もまた、廻り廻ってUnityでも役立ったりします。C#を主軸にボーダーレスにスキルを磨くことで、一つ一つの領域でのスキルも深まる。「C#の可能性を切り開いていく」ことにより、そうした循環を作っていくこともまたCysharpという会社の目的でもあり、今回のような支援と情報公開を積極的に進めていければと考えています。ご協力いただいたアプリボットさん、ありがとうございました!
この記事へのコメントはありません。