UniRxでの空呼び出し検出、或いはRoslynによるCode Aware Libraries時代の到来について

UniRx - Reactive Extensions for Unity用に、メソッド呼んだだけで何も処理してないIObservable<T>があったらWarningを出すAnalyzerを作ってみました。

AnalyzerはVisual Studio 2015からの機能です。というわけでVisual Studio 2015 RCが必要です。あとは、NuGetからAnalyzerが入れられるようになっているので

でOK。Unityのプロジェクトであっても問題なく使えます(ただしVSTUのcsproj自動生成でAnalyzerタグは吹っ飛ぶので、生成をフックして復元する必要はあります、フック方法の詳細はみんな大好き Boo.Lang を SATSUGAI する方法を参照のこと)。もし、他にこういうAnalyzerがあったら便利なのになー、とかってアイディアあったら気楽に言ってください!作りますので!

現在のうちの会社(グラニ)のプロジェクトはRxが土台から、ありとあらゆる全てで使われているので、ちょっとした呼び出しのつもりでやってたら何もおこらなくて(Susbcribe漏れ)クソが!となるシチュエーションが少なくなかったので、こういうAnalyzerが必需品だったのでした。

ようするにC# 5.0のTaskでawaitしてないと警告が出るのと同じ話なのですが、そういうのが言語組み込みキーワードでなくても自由に、(VS2015で動かせるなら)簡単にプロジェクト単位で追加出来る、というのがミソです。こういったライブラリとアナライザーの組み合わせは、Code Aware Librariesという言葉でまとめられます。.NET Compiler Platform ("Roslyn"): Analyzers and the Rise of Code-Aware Libraries。従来はライブラリのみの提供でしたが、そこにAnalyzerも組み合わせて、Best Practiceを一体化して伝えていくような世界観が広がっています。

例えば、私はLightNodeというWebAPIフレームワークを作っていますが、これは引数の型に幾つかの制約があります。また、メソッドのオーバーロードを許していなかったりします。それらは実行時のウォームアップのタイミングでフェイルファストとして気づかせるようにしていますが、それよりも前のタイミング、コードを書いている最中にリアルタイムで警告できれば、より良いでしょう。なので、Analyzerを同梱すれば、より良い形、より良いライブラリの有り様になります。

DiagnosticAnalyzerの作り方 Part2

以前にVS2015のRoslynでCode Analyzerを自作する(ついでにUnityコードも解析する)VS2015+RoslynによるCodeRefactoringProviderの作り方と活用法という記事を書きましたが、基本的にはそれらと同じです、アタリマエですが。↑の記事はCTPの頃のもので、若干インターフェイスが変わっちゃっていますが、少し修正するだけでほぼほぼ同じかな。

今回作ったのはCode Analyzerで、Fixは含めていないのでcsファイル一個だけで済んでいます。

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class HandleObservableAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "HandleObservable";

    internal const string Title = "IObservable<T> does not handled.";
    internal const string MessageFormat = "This call does not handle IObservable<T>.";
    internal const string Description = "IObservable<T> should be handled(assign, subscribe, chain operator).";
    internal const string Category = "Usage";

    internal static DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

    public override void Initialize(AnalysisContext context)
    {
        context.RegisterSyntaxNodeAction(AnalyzeMethodDeclaration, SyntaxKind.MethodDeclaration);
    }

    private static void AnalyzeMethodDeclaration(SyntaxNodeAnalysisContext context)
    {
        var invocationExpressions = context.Node
            .DescendantNodes(descendIntoChildren: x => !(x is InvocationExpressionSyntax))
            .OfType<InvocationExpressionSyntax>();

        foreach (var expr in invocationExpressions)
        {
            var type = context.SemanticModel.GetTypeInfo(expr).Type;
            // UniRx.IObservable? System.IObservable?
            if (new[] { type }.Concat(type.AllInterfaces).Any(x => x.Name == "IObservable"))
            {
                // Okay => x = M(), var x = M(), return M(), from x in M()
                if (expr.Parent.IsKind(SyntaxKind.SimpleAssignmentExpression)) continue;
                if (expr.Parent.IsKind(SyntaxKind.EqualsValueClause) && expr.Parent.Parent.IsKind(SyntaxKind.VariableDeclarator)) continue;
                if (expr.Parent.IsKind(SyntaxKind.ReturnStatement)) continue;
                if (expr.Parent.IsKind(SyntaxKind.FromClause)) continue;

                // Okay => M().M()
                if (expr.DescendantNodes().OfType<InvocationExpressionSyntax>().Any()) continue;

                // Report Warning
                var diagnostic = Diagnostic.Create(Rule, expr.GetLocation());
                context.ReportDiagnostic(diagnostic);
            }
        }
    }
}

戦略的には、メソッド呼び出し、つまりInvocationExpressionを拾いだして、そこからローカル変数代入/フィールド代入/return/LINQクエリ構文/メソッド呼び出しで使われていなければダメ扱いにする、という流れ。コード自体は行数も少なくて難しくはないのですけれど、戦略を決定するまでは割と悩みました。SyntaxTree自体も大量のメソッドがあり、SemanticModelも絡めると、色々な手段が取れそうでいて取れなさそうで、相当悩ましい。最終的にはかなり単純な手法に落ち着きましたが、直線距離で到達できるようになるまでには、かなり慣れが必要そうです。あと、最初作った時はクエリ構文のチェックを見落としてたりとか(さすがにこれを最初から気づくのは無理)、必要なケースを全て洗い出すのはそこそこ大変かな、といった感はあります。

DescendantNodesのdescendIntoChildrenという引数が中々面白くて、これは子孫ノードの探索を打ち切る条件を指定できます。これの何がいいって、例えばメソッド Observable.Range().Where().Select() があった場合、最上位のInvocationExpressionはObservable.Range().Where().Select()なのですが、その子孫に Observable.Range().Where() や Observable.Range() がいます。ふつーのDescendantNodesだとそれら全部を列挙してしまうんですが、今回は欲しいのは最上位だけなので、descendIntoChildrenで条件フィルタを足しています。

以前には紹介していない、ユニットテストのやり方も紹介しましょう。といっても、テンプレートに最初からTestプロジェクトと、便利クラス群が同梱されています。Analyzerだけの場合は基底クラスをDiagnosticVerifierに変えて……

namespace UniRxAnalyzer.Test
{
    [TestClass]
    public class HandleObservableAnalyzerTest : DiagnosticVerifier
    {
        protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
        {
            return new UniRxAnalyzer.HandleObservableAnalyzer();
        }

        [TestMethod]
        public void UnHandle()
        {
            var source = @"
using System;
   
class Test
{
    IObservable<int> GetObservable() => null;

    void Hoge()
    {
        GetObservable();
    }
}";
            var expected = new DiagnosticResult
            {
                Id = UniRxAnalyzer.HandleObservableAnalyzer.DiagnosticId,
                Message = "This call does not handle IObservable<T>.",
                Severity = DiagnosticSeverity.Warning,
                Locations = new[]
                {
                    new DiagnosticResultLocation("Test0.cs", 10, 9)
                }
            };

            this.VerifyCSharpDiagnostic(source, expected);
        }
    }
}

ようするにVerifyCSharpDiagnosticにテスト用のC#コードと、期待するDiagnosticResultを渡すだけです、実に簡単。もしエラーじゃなくOKの場合はsourceだけをVerifyCSharpDiagnosticに渡せば、そういうことになります。

まとめ

Analyzer、かなりイイです。実際。とにかくとりあえず触ってみませう。現状リファレンスとかは特にないですが、まぁLINQ to XML辺りがわかっていればSyntaxVisualizerとIntelliSenseを頼りになんとか作り上げられるでしょう!メソッド名を見ながらカンを働かせましょう。大丈夫大丈夫。また、GitHubには既にお手本となるAnalyzerが出回っているので、それを参照にすればかなりいけます。代表的なところではNR6Pack, StyleCopAnalyzers, Code Crackerなどがあります。

では、よきRoslynライフを!

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive