SWETグループの長谷川(@nowsprinting)です。
Unity 2020.2以降、Unityエディタ上でRoslynアナライザによる静的解析 (static analysis) を実行可能になりました。 また、それ以前のバージョンで作られたUnityプロジェクトであっても、JetBrains RiderなどのC#向けIDE(統合開発環境)上でRoslynアナライザの実行がサポートされています。
静的解析を充実させることで、コンパイラだけではチェックしきれないようなバグや性能劣化の原因を早期に検出できます。
例えば弊社では、実行時に動的にインスタンス化されるクラスのコンストラクタがIL2CPPビルド時にストリップされないように [Preserve]
アトリビュートの指定漏れを検出するアナライザを導入し、ショーストッパーとなりえる問題を早期発見できるようにしています。
通常、こうした問題の検出はシニアエンジニアによるコードレビューに頼られます。静的解析によってレビュー負荷を軽減できれば、より高度な問題に目を向けられるという副次的効果も見込めます。
本記事では、社内フレームワークやプロジェクト固有のルールに対応するカスタムアナライザを作る手順やTipsを紹介します。
Roslynアナライザとは
Roslyn(ろずりん)とは、C# 6.0から導入された.NETコンパイラプラットフォームの通称です。C#およびVisual Basic向けコンパイラのほか、コード生成API、コード解析APIが公開されています。
Roslynのコード解析APIでは、構文解析 (syntactic analysis) および意味解析 (semantic analysis) を行なうことができ、これを利用して様々なアナライザを自作できます。 これを、本稿ではRoslynアナライザもしくは単にアナライザと呼びます。
なお、一般的なUnityプロジェクトで利用できるオープンソースのアナライザもあり、例えば以下のものが知られています。
UnityプロジェクトでRoslynアナライザを使用する
Roslynアナライザの作り方に先立って、Unityプロジェクトでアナライザを使用する方法について述べます。
Unity 2020.2以降、Unityプロジェクト内のアナライザ(および依存する)DLLファイルを適切に設定することで、Unityエディタ上で静的解析が実行されるようになりました。しかし、実用するにはまだ使い勝手が良いとは言えません *1 *2 *3 *4 *5 *6 *7。
一方で、多くの方がコーディングに使用されているC#向けIDE(具体的にはJetBrains Rider、Visual Studio、Visual Studio Code)では、プロジェクトの.csprojファイルに使用するアナライザを定義してあれば静的解析が実行されます。 アナライザによる診断がもっとも欲しいタイミングは、まさにIDEでコードを書いているときです。アナライザを導入するのであれば、IDEでの診断をメインに据えることをおすすめします。
まずUnityプロジェクトでのDLL設定を、続いて、その設定を.csprojに反映する方法を紹介します。
UnityプロジェクトでのDLL設定
Unityプロジェクト(2019.4で確認しています)に配置したDLLファイルはPlugin Inspectorウィンドウで設定を変更でき、その設定はDLLの.metaファイルに書き込まれます。
ProjectウィンドウでAssetsフォルダ下にあるアナライザのDLLを選択してPlugin Inspectorウィンドウを表示し、以下のように変更します。
- Select platform for plugin下のチェックをすべてoff
- Asset Labelsに
RoslynAnalyzer
を追加 *8
DLLがAssetsフォルダ下にない場合、Asset Labelsは設定できませんので注意してください。
アナライザ設定を.csprojファイルへ反映する
IDEが使用する.csprojファイルは、IDEに対応したプラグインパッケージ *9 によって自動生成されます。 以下のプラグインでは、前述のようにUnity 2020.2向けに設定されたアナライザDLLの設定を.csprojに反映してくれますので、Unityプロジェクト側で前述の設定を済ませるだけでIDEでも静的解析が実行されるようになります。
- JetBrains Rider Editor v2.0.6以降
- Visual Studio Code Editor *10 v1.2.0以降
上記以外のIDEをお使いの場合、また、AdditionalFilesを使用するアナライザを使用する場合は、.csproj生成時にアナライザの定義を挿入する必要があります。 この設定をサポートしてくれるエディタ拡張がCysharpさんにより公開されていますので、こちらを利用してみてください。
重要度設定とサプレス設定
アナライザには、診断項目ごとにデフォルトの重要度 (severity) が設定されています。プロジェクトによって重要度を上げたい/下げたい場合、これを上書き設定できます。
UnityエディタおよびVisual Studioの場合、ルールセットファイルを使用します。設定方法は公式マニュアルの Roslyn analyzers and ruleset files を参照してください。 Riderの場合は、Preferences... を開き、Editor | Inspection Settings | Roslyn Analyzersの中で設定できます。
また、特定のコードにおいてのみ、診断の対象外にしたいケースもあります。
- 対象となるクラスやメソッド定義に対して [SuppressMessage]アトリビュートをつけることで、そのクラス/メソッド内では指定した診断をサプレスできます
- 対象となるコード行の前後に
#pragma warning disable <DiagnosticID>
と#pragma warning restore <DiagnosticID>
ディレクティブを書くことで、指定した診断をサプレスできます
なお、Riderにはインスペクションを行単位でサプレスできるコメント書式がありますが、Rider上であってもRoslynアナライザの診断に対しては無効です。
#pragma warning disable/restore
ディレクティブを使用してください。
Roslynアナライザの作成(基礎編)
続いて、基本的なRoslynアナライザ作成の手順を紹介します。
プロジェクトの準備
Roslynアナライザは、.NETプロジェクトとして作成します。 .NET SDK コマンドラインツールのほか、.NETプロジェクトを扱うことのできるIDEが使用できます。
ただし、アナライザ用のプロジェクトテンプレートは、Windows版のVisual Studioでのみ提供されています。 ここではVisual Studio 2019でプロジェクトを作成する手順を紹介します *11。
ひな形の作成
- Visual Studio Installerを起動し、インストール済みVisual Studio 2019 *12 の「変更」をクリック。「個別のコンポーネント」タブにある「.NET Compiler Platform SDK」を選択してインストールします
- Visual Studio 2019を起動し、「新しいプロジェクトの作成」をクリック。プロジェクトテンプレートとして「Analyzer with Code Fix (.NET Standard)」のC#のほうを選択します(同じ名称でVisual Basic向けもあるので注意)
これでアナライザのひな形ができました。
ひな形のソリューションは、例えば名称を HogeFugaAnalyzer
としたとき、下記5つのプロジェクト (.csproj) で構成されています。
- HogeFugaAnalyzer: アナライザ本体。この名称はソリューションと同じものです
- HogeFugaAnalyzer.CodeFixes: アナライザで検出した問題のオートフィックス機能を提供するプロジェクトです。本記事では解説しません
- HogeFugaAnalyzer.Package: アナライザ本体とCode FixをNuGetパッケージ (.nupkg) ファイルにパッケージングするプロジェクトです。Unityプロジェクト専用のアナライザではDLLファイルを直接扱うため使用しません
- HogeFugaAnalyzer.Test: アナライザのユニットテストを記述するプロジェクトです
- HogeFugaAnalyzer.Vsix: Visual Studio拡張機能としてアナライザをデバッグ実行するためのプロジェクトです。本記事では解説しません *13
Windows以外の環境でビルドする設定
以降の開発をWindows以外の環境で行なう場合、ひな形のままではビルドできないため、アナライザ本体の PackageID
属性を書き換えます。
HogeFugaAnalyzer.csprojを任意のエディタで開き、
<PackageId>*$(MSBuildProjectFullPath)*</PackageId>
の部分を、例えば
<PackageId>HogeFugaAnalyzer</PackageId>
に変更します。 ただし、この名称はNuGetパッケージ (.nupkg) の名前としてパッケージングプロジェクト (HogeFugaAnalyzer.Package.csproj) 内で定義されています。同時にHogeFugaAnalyzer.Packageプロジェクトは削除しましょう。
もしNuGetパッケージとして配布を予定しているのであれば、パッケージングにまつわる設定をアナライザ本体の.csprojに記述するか、もしくは本体のPackageIDを例えば下記のように変更することで回避できます。
<PackageId>HogeFugaAnalyzer.Diagnostic</PackageId>
テストプロジェクトの設定
生成されたひな形ではテストプロジェクトが.NET Core App 2.0向けに設定されているため、開発環境に合わせて変更します。
例えば.NET SDK 5.0で開発する場合、 HogeFugaAnalyzer.Test.csprojをエディタで開き、
<TargetFramework>netcoreapp2.0</TargetFramework>
の部分を
<TargetFramework>netcoreapp5.0</TargetFramework>
に変更します。
以上で、IDE上でビルドが成功する状態になります。
アナライザの動作解説
アナライザのひな形プロジェクトでは、コード上にあらわれる型の名称を検査し、小文字が含まれていれば警告するサンプルが実装されています。 これをもとに、簡単に診断の流れを解説します。
DiagnosticAnalyzer
アナライザは、DiagnosticAnalyzer を継承かつ [DiagnosticAnalyzer]アトリビュートがつけられたクラスとして定義されます。
DiagnosticAnalyzer
を継承したクラスには、SupportedDiagnostics
プロパティおよび Initialize
メソッドの実装が必要です。
SupportedDiagnostics
このアナライザが提供する診断内容 (DiagnosticDescriptor) を配列で返します。
DiagnosticDescriptor
は、IDEのインスペクション設定で一覧表示され、重要度 (severity) を設定させるために使われます。
また、実際に問題を検出した際に表示されるメッセージもここに設定します。
ひな形では private static readonly DiagnosticDescriptor Rule
を定義し、これを ImmutableArray
でラップして返す実装になっています。
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }
Initialize
Roslynコンパイラによってアナライザがロードされると呼ばれるメソッドです。
コンパイラは、ソースコードに対して、字句解析・構文解析・意味解析といったフェーズを経て中間言語 (Intermediate Language) を出力します。
アナライザは、その診断内容に適したフェーズでコンパイラからコールバックを受けて動作します。
Initialize
メソッドでは、コールバックを受け取りたい契機を AnalysisContext に登録します。
ひな形では、シンボルの意味解析完了ごとに動作させたいメソッド AnalyzeSymbol()
を、 RegisterSymbolAction()
で登録しています。
public override void Initialize(AnalysisContext context) { (snip) context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType); }
アクションには、シンボルのほかにもコンパイルの開始・終了、メソッド呼び出しなど多数のアクションが定義されており、自由に組合わせてアナライザを作ることができます。
ただし、各アクションの呼び出し順は保証されていません *14。たとえばsymbol actionの時点でsyntax tree actionの処理が完了していることに依存するようなアナライザは動作しない恐れがあります。
アクションに関する詳細は、Roslynのドキュメント Analyzer Actions Semantics を参照してください。
診断の実行
コールバックを受けるよう登録した private static void AnalyzeSymbol()
が実際の診断を行なうメソッドです。
診断に必要な情報は引数で受け取れます。ここではシンボルの名前に小文字を含む場合、診断結果である Diagnostic を生成して返しています。
private static void AnalyzeSymbol(SymbolAnalysisContext context) { var namedTypeSymbol = (INamedTypeSymbol)context.Symbol; if (namedTypeSymbol.Name.ToCharArray().Any(char.IsLower)) { var diagnostic = Diagnostic.Create( Rule, namedTypeSymbol.Locations[0], namedTypeSymbol.Name); context.ReportDiagnostic(diagnostic); } }
Diagnostic.Create()
の引数は、DiagnosticDescriptor
、Location、メッセージ挿入語句となっています。
この結果を受けたIDE上では Location
で指示された位置にポップアップ等で DiagnosticDescriptor
に定義されたメッセージが表示されます。
以上のように、アナライザプロジェクトのひな形は、そのままでアナライザとして動作するものが生成されています。 アナライザの開発がはじめてであれば、この時点で一度Unityプロジェクトに組み込んでその振る舞いを確認することをおすすめします。
アナライザのテスト
ユニットテスト
アナライザ作成においても、できるだけ早い段階かつ小さい単位でユニットテストを行なうことは役に立ちます。
しかし、Roslynコード解析APIで提供されているテストAPIは、解析対象コードと期待する Diagnostic
を引数に取って合否検証まで行なうという粒度の大きいものしかありません。
このAPIでは、複雑な診断を行なうアナライザのテストは困難です。
そこで、アナライザの実行と結果検証を分けて使えるフレームワークDena.CodeAnalysis.Testingを作成し、使用しています。 Dena.CodeAnalysis.Testingは、GitHubおよびNuGet Galleryで公開しています。
Dena.CodeAnalysis.Testingを使用するには、テストプロジェクトの.csprojに下記の定義を追加します。
<PackageReference Include="Dena.CodeAnalysis.Testing" Version="1.0.0" />
Dena.CodeAnalysis.Testingを用いたテストの書きかた
テストは次のように記述できます。
var analyzer = new YourAnalyzer(); var diagnostics = await DiagnosticAnalyzerRunner.Run( analyzer, @" public static class Foo { public static void Bar() { System.Console.WriteLine(""Hello, World!""); } }"); var actual = diagnostics .Where(x => x.Id != "CS1591") // Ignore "Missing XML comment for publicly visible type or member" .ToArray(); Assert.AreEqual(1, actual.Length); Assert.AreEqual("YourAnalyzer0001", actual.First().Id);
この例では DiagnosticAnalyzerRunner.Run
にアナライザのインスタンスと検証対象コードを渡し、診断結果を受け取った後に Assert
で期待通りの結果であるかを検証しています。
アナライザの実行と Assert
が分離されているため、最終的な診断結果だけでなく、アナライザ内の中間情報を検証するテストを書くこともできます。
また、アナライザをテストダブル(モック、スタブ、スパイ等)に置き換えてのテストも可能です。
ユニットテストを考えるとき、例えば INamedTypeSymbol
などをテストデータとして生成できればよいのですが、Roslyn APIでは困難です。そのため上記のように診断対象コードをテストの入力データとしています。
最後の Assert
はテスト対象や目的に応じて様々な書きかたがありますが、ひとつ注意すべき点があります。
上例では診断結果から CS1591
を取り除いた上で、件数および DIagnostic.Id
を検証しています。
もしこれを直接
Assert.IsTrue(diagnostics.Any(x => x.Id == "YourAnalyzer0001"));
のように書いてしまうと、想定外の診断結果バグを見逃してしまったり(上例では2つ目の Diagnostic
が無いことを確認できません)、返るはずの Diagnostic
が返らなかったとき原因判明に時間がかかったりします(テストデータのミスによるコンパイルエラーは多々発生します)。
少々面倒に感じますが、関係ないものを明示的に取り除いてから Assert
する手順を踏むことをおすすめします。
LocationAssert.HaveTheSpan
Dena.CodeAnalysis.Testingでは、補助的なツールとして LocationAssert.HaveTheSpan
も提供しています。
次のように、 DiagnosticResult
に含まれる Location
が正しく設定されていることの検証を簡単に記述できます。
LocationAssert.HaveTheSpan( new LinePosition(7, 34), // 期待する始点 new LinePosition(7, 42), // 期待する終点 diagnostic.Location );
なお、Location
の行およびカラムはコードでは0オリジンで指定しますが、診断メッセージには1オリジンで(つまり+1して)表示されます。
1足すか引くかは混乱しがちなポイントですが、 LocationAssert.HaveTheSpan
では次のようにアサートメッセージに両方の数値が並べて表示され、判別しやすくなっています。
また、メッセージはそのままテストコードのexpectedとしてコピー&ペーストできる書式となっています。
テストデータについてのTips
ユニットテストの効率を高めるため、テストで使用するデータ(アナライザにおいては診断対象コード)の質には気を配るべきです。 特に、実際の検証対象コードと乖離したデータでテストを書いてそれがパスしてしまうと、アナライザを実際のプロジェクトへと組み込んでから問題に気づくこととなり手戻りが大きくなります。
そのため、よほどシンプルなものを除き、テストデータは string
ではなく個別のテキストファイルに定義することをおすすめします。
拡張子を.csにすることで、コンパイルエラーや警告をあらかじめ修正でき、テスト実行時に自作アナライザとは無関係の診断結果に煩わされることもなくなります。
複数のテストデータで同名のクラスを使用している場合、ファイルを小分けすることで使いまわしができます。逆に同名のクラスをあえて使い分けたい場合、テストケースごとに namespace
を使い分けることで実現できます。
その他、以下の点にも注意してみてください。
- メソッド呼び出しを検出するとき、それが拡張メソッドでないか。拡張メソッドは明示的に型が異なるため、テストデータも拡張メソッドとして記述しなければ検出できません
- アトリビュートのクラス名に
Attribute
を付け忘れていないか。例えば、アトリビュートのクラス名がHogeAttribute
でもHoge
でも、[Hoge]
と記述できてしまいます。しかし、アナライザから見ると別物です
Unityプロジェクトに組み込んでのテストTips
アナライザをDLLにビルドし、それをUnityプロジェクトに組み込んでテストする際のTipsを紹介します。
極力ユニットテストで品質を上げておく
Unityプロジェクトに組み込んでからのテストではデバッグしづらく、また修正・再実行に時間もかかります。 大前提として、極力、ユニットテストでリアリティの高いテストデータ(診断対象コード)を使って品質を上げておくべきです。
dotnet buildコマンドを使用する
IDEでアナライザを実行する場合、IDEによってアナライザ自体がキャッシュされるため、何度も安定したテストを実行することは困難です。 そのため、初期の段階ではdotnetコマンドでインクリメンタルコンパイルを無効にした実行を試すことをおすすめします。
次のコマンドで実行できます。
$ dotnet build --no-incremental YOUR_PROJECT_NAME.csproj
ファイルロガーを使用する
アナライザをUnityプロジェクトに組み込むと、コンソール出力を観測できないため、いわゆるprintデバッグは不可能になります。 そのため、デバッグ困難な事象に突き当たってしまったら、早めにファイルロガーの仕組みを入れることをおすすめします。
なお、OSSなど外部のロガーソリューションを利用する場合、依存するDLLもすべてアナライザ本体同様 <Analyzer>
ノードに書く必要がある点に注意してください。
Roslynアナライザの作成(応用編)
実用的なアナライザとして、冒頭で触れた「実行時に動的にインスタンス化されるクラスのコンストラクタがIL2CPPビルド時にストリップされないように [Preserve]
アトリビュートの指定漏れを検出するアナライザ」の作成方法を紹介します。
このアナライザの実現には、二段階の処理が必要です。
まず、メソッドの呼び出し箇所でコールバックを受け、それが特定のメソッド呼び出しであるかを判断し型引数を取得します。
続いて、その型のコンストラクタに [Preserve]
アトリビュートが定義されてるかを診断します
*15。
では、順に見ていきましょう。まず Initialize
でメソッド呼び出し箇所のコールバックを登録します。
public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); context.RegisterOperationAction( AnalyzeAttributes, OperationKind.Invocation ); }
コールバックに渡される OperationAnalysisContext
からは、呼び出しているメソッドの情報が取得できます。
ここでは、クラスを型引数として取り、実行時に動的にインスタンス化するクラスを登録しているメソッド呼び出しを検出します。
private void AnalyzeAttributes(OperationAnalysisContext context) { var invocation = (IInvocationOperation)context.Operation; var methodSymbol = invocation.TargetMethod; if (methodSymbol.ContainingType.Name != "ServiceCollection") return; if (methodSymbol.Name != "AddSingleton") return; if (methodSymbol.TypeArguments.Count()==0) return;
特定のメソッド呼び出し(ここでは ServiceCollection.AddSingleton<>()
)であったならば、続いて型引数のコンストラクタについているアトリビュートを診断します。
var injectedClass = methodSymbol.TypeArguments[0] as INamedTypeSymbol; var ctorShouldBePreserved = new List<IMethodSymbol>(); foreach (var ctor in injectedClass.Constructors) { foreach (var attribute in ctor.GetAttributes()) { if (attribute.AttributeClass?.Name == "PreserveAttribute") { ctorShouldBePreserved.Add(ctor); } } }
以上で、ServiceCollection.AddSingleton<>()
に型引数として渡しているクラスのコンストラクタに [Preserve]
アトリビュートがついているか否かを確認できました。
最後に、診断結果をコンパイラに渡します。
if (ctorShouldBePreserved.Count == 0) { var diagnostic = Diagnostic.Create( CtorDoesNotHaveInject, // [Preserve]付きコンストラクタが存在しない意のDiagnosticDescriptor location, // 割愛していますが、呼び出し部分の型引数部分を指すLocation injectedClass.Name); context.ReportDiagnostic(diagnostic); } }
コードでは割愛していますが、この診断結果の Location
(primary location) には、本来の問題箇所である型引数のコンストラクタ定義箇所ではなく ServiceCollection.AddSingleton<>()
呼び出し位置(の型引数部分)を指定しています。
IDE上でアナライザが実行されるときはエディタタブで開かれているファイルを起点とした診断しか行われないため *16 *17、このアナライザは呼び出し側のファイルでしか機能せず、またそのファイル以外を指す診断結果は無効となるためです。
まとめ
なかなか情報も少なく、ハードルが高い印象のRoslynアナライザです。しかし、導入してしまえばエンジニアへの負担なく問題を早期発見できる、とても効果の高いものです。
プロジェクトのコーディング規約やレビューでの観点のうち明文化されているものがあれば、それをアナライザにはできないか、検討してみてはいかがでしょうか。
最後に。以上のような活動に共感していただけた方、興味を持たれた方、一緒に働いてみようと思ってくれた方。 下記職種で採用しておりますので、ぜひご応募ください。お待ちしております。
https://career.dena.jp/job.phtml?job_code=1618career.dena.jp
*1:診断結果は、GUIではコンソールウィンドウ、CLI (Batch mode) ではビルドログに混ざって出力されます
*2:Unity 2020.2では、診断は当該ファイルのコンパイルもしくはReimportの契機でのみ実行されます(Unity 2020.3.4より、通常のコンパイルステップで動作するように修正されました)
*3:Unity 2020.2では、ルールセットファイルによる重要度の設定変更は、Reimportの契機でのみ反映されます(Unity 2021.1で修正されました)
*4:Unity 2020.2では、CLI実行ではルールセットファイルによる重要度設定は無効です(Unity 2021.1で修正されました)
*5:Packages/下のDLLには、アナライザとして識別させるためのラベル設定ができません
*6:Packages/下にアナライザとして設定したDLL(と.metaファイル)を配置しても、アナライザとして動作しません
*7:その他、Unity 2020.2時点のRoslynアナライザサポート状況はこちらにまとめてあります https://www.nowsprinting.com/entry/2021/04/18/200619
*8:ラベルは、ウィンドウ右下のしおり状アイコンをクリックすることで入力できます。アイコンが表示されていない場合は"Asset Labels"の文字をクリックすると表示されます
*9:Package Managerウィンドウでインポートおよびアップデートできます
*10:Visual Studio Code (VSCode) 用プラグインです。Visual Studio用ではないのでご注意ください
*11:その他の環境でアナライザプロジェクトを作成する場合は、 Roslyn SDK内にあるテンプレート https://github.com/dotnet/roslyn-sdk/tree/main/src/VisualStudio.Roslyn.SDK/Roslyn.SDK/ProjectTemplates/CSharp/Diagnostic もしくはテンプレートリポジトリ https://github.com/nowsprinting/RoslynAnalyzerTemplate を利用するか、こちらの記事を参考に設定してください https://zenn.dev/naminodarie/articles/32973a36fcbe99
*12:Community EditionでもRoslynアナライザは作成できますが、for macではできません
*13:Visual Studio 2019 v16.10ではデバッグ実行の手段が変わるため不要になるようです。参考 https://qiita.com/ryuix/items/36dabbf3c7e4e395e49e
*14:compilation start/endのような対になっているものの呼び出し順は保証されます。また、compilation end actionは必ず最後に1回実行されます
*15:あからじめSymbolActionでコンストラクタのアトリビュートを収集しておき、後でメソッド呼び出し箇所のコールバックで処理する、という二段構えの方法も考えられますが、2つの理由で採用していません。1つ目は、アクションの呼び出し順が未定義であること。2つ目は、IDE上で実行されるときはエディタタブで開かれているファイルを起点とした診断しか行われないため、型引数に使われている型が別ファイルにあるとSymbolActionが呼ばれないためです
*16:インスペクション機能はプロジェクト全体を診断してくれますが、この場合も実行単位はファイルごとのため同様の制限があります
*17:先に紹介したdotnet buildコマンドではこの制限を受けないため、診断されているはずなのにIDEに表示されないときはdotnet buildコマンドを試してみると原因にたどり着きやすいこともあります