@natsu1211

ゲーム開発、プログラミング言語の話

RoslynでUnityパッケージ内のAPIの呼び出し状況を調べる

やりたいこと

  • とあるUnityパッケージ内で定義されている全公開メソッド(API)を抽出
  • IDEを使わずに、これらのAPIがプロジェクト内での呼び出し場所と回数を抽出、CSVとして出力。

これができると何が嬉しいかというと、 Unityパッケージを作る横断チームにとって、自分が作ったフレームワークが社内のゲームプロジェクトで実際どういう風に使われているのかを静的に、定量的に把握できる。

実装

重要な部分だけ紹介。 まずUnityパッケージの全ソースコードから公開メソッド(API)を抽出、

CompilationContext.cs

    internal class CompilationContext
    {
        public CompilationContext(IEnumerable<string> scriptPaths)
        {
            ScriptContexts = scriptPaths.Select(path => new ScriptContext(path)).ToArray();
        }

        internal ScriptContext[] ScriptContexts { get; }
        internal SyntaxTree[] SyntaxTrees { get; private set; }
        internal CSharpCompilation Compilation { get; private set; }

        internal async UniTask InitializeCompliationAsync()
        {
            SyntaxTrees = await UniTask.WhenAll(ScriptContexts.Select(c => c.CreateSyntaxTreeAsync()));
            var references = ScriptContexts.AsParallel().Where(script => script.Assembly != null)
                .SelectMany(script => script.Assembly.allReferences)
                .Distinct()
                .Select(reference => MetadataReference.CreateFromFile(reference));

            // compile occurs here,
            var compilation = await UniTask.RunOnThreadPool(() => CSharpCompilation.Create("CompliationResult.dll",
                SyntaxTrees, references,
                new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)));

            Compilation = compilation;

            // エラーがあっても、Symbolのサーチ自体はできます
            // var ds = compilation.GetDiagnostics();
            // foreach (var d in ds)
            // {
            //     if (d.Severity == DiagnosticSeverity.Warning)
            //         Debug.LogWarning(d.GetMessage());
            //     else if (d.Severity == DiagnosticSeverity.Error)
            //         Debug.LogError(d.GetMessage());
            // }
        }
    }

ScriptContext.cs

    internal class ScriptContext
    {
        internal ScriptContext(string fullPath)
        {
            FullPath = fullPath;
            ScriptPath = AssetPathHelper.GetPackageAssetPath(fullPath);
            Assembly = AssemblyHelper.GetAssemblyFromScriptPath(ScriptPath);
        }

        // Path in Unity
        internal string ScriptPath { get; }

        // Full path in file system
        internal string FullPath { get; }

        // AST of this script
        internal SyntaxTree SyntaxTree { get; private set; }

        // Assembly include this script
        internal Assembly Assembly { get; private set; }

        internal async UniTask<SyntaxTree> CreateSyntaxTreeAsync()
        {
            var text = await File.ReadAllTextAsync(FullPath).ConfigureAwait(false);
            SyntaxTree = CSharpSyntaxTree.ParseText(text);
            return SyntaxTree;
        }
    }

AssemblyHelper.cs

    internal static class AssemblyHelper
    {
        private const string AssemblyExtension = ".dll";

        private static readonly Dictionary<string, Assembly> s_assemblies;

        static AssemblyHelper()
        {
            s_assemblies = CompilationPipeline.GetAssemblies().ToDictionary(assembly => assembly.name);
        }

        internal static Assembly GetAssemblyFromScriptPath(string scriptPath)
        {
            var assemblyName = CompilationPipeline.GetAssemblyNameFromScriptPath(scriptPath);
            if (assemblyName == null)
                return null;

            if (assemblyName.EndsWith(AssemblyExtension, StringComparison.OrdinalIgnoreCase))
                assemblyName = assemblyName.Substring(0, assemblyName.Length - AssemblyExtension.Length);

            return s_assemblies.TryGetValue(assemblyName, out var assembly) ? assembly : null;
        }
    }

Unity.CompilationPipelineを通して、各ソースファイルが依存しているdllを探し出して、InitializeCompliationAsync()内で各ソースと一緒にコンパイルしています。 あとはMethodDeclarationWalkerを通して、コンパイルで得られたSyntaxTreeに対して全探索を行います。PublicメソッドであればSymbolを保存。

MethodDeclarationWalker.cs

    internal class MethodDeclarationWalker : CSharpSyntaxWalker
    {
        private readonly SemanticModel _semanticModel;
        private readonly List<ISymbol> _symbols = new();

        public MethodDeclarationWalker(SemanticModel semanticModel)
        {
            _semanticModel = semanticModel;
        }

        public override void VisitMethodDeclaration(MethodDeclarationSyntax node)
        {
            var symbol = _semanticModel.GetDeclaredSymbol(node);
            if (symbol != null && symbol.DeclaredAccessibility == Accessibility.Public)
                _symbols.Add(symbol);
        }

        internal List<ISymbol> GetDeclarationSymbols()
        {
            return _symbols;
        }
    }

定義されているPublicメソッドの全Symbolは得られたので、あとはプロジェクトコードのSymbolに対して、フルネームが一致するかどうかを比較すれば、呼び出しされているかどうかがわかります。

SymbolFinder.cs

        internal static async UniTask<HashSet<string>> FindMethodDeclarationSymbolNamesAsync(
            IEnumerable<string> scriptPaths)
        {
            var compilationContext = new CompilationContext(scriptPaths);
            await compilationContext.InitializeCompliationAsync();

            var syntaxTrees = compilationContext.ScriptContexts.Select(s => s.SyntaxTree);
            var compilation = compilationContext.Compilation;

            var symbolNameBag = new ConcurrentBag<string>();
            await UniTask.RunOnThreadPool(() => Parallel.ForEach(syntaxTrees, tree =>
            {
                var semanticModel = compilation.GetSemanticModel(tree);
                var walker = new MethodDeclarationWalker(semanticModel);
                walker.Visit(tree.GetRoot());
                foreach (var s in walker.GetDeclarationSymbols()) symbolNameBag.Add(s.ToDisplayString());
            }));
            return symbolNameBag.ToHashSet();
        }

MethodInvocationWalker.cs

    internal class MethodInvocationWalker : CSharpSyntaxWalker
    {
        private readonly SemanticModel _semanticModel;
        private readonly HashSet<string> _calleeSymbolNames;
        private readonly List<(ISymbol callee, ISymbol caller)> _symbolsFound = new();

        public MethodInvocationWalker(SemanticModel semanticModel, HashSet<string> calleeSymbolNames)
        {
            _semanticModel = semanticModel;
            _calleeSymbolNames = calleeSymbolNames;
        }

        public override void VisitInvocationExpression(InvocationExpressionSyntax node)
        {
            var calleeSymbol = _semanticModel.GetSymbolInfo(node).Symbol;
            if (calleeSymbol == null) return;
            if (_calleeSymbolNames.Contains(calleeSymbol.ToDisplayString()))
            {
                var callerSymbol = SymbolFinder.FindMethodCallerSymbol(_semanticModel, node);
                if (callerSymbol != null)
                {
                    _symbolsFound.Add((calleeSymbol, callerSymbol));
                    Debug.Log($"Callee: {calleeSymbol.ToDisplayString()}");
                    Debug.Log($"Caller: {callerSymbol.ToDisplayString()}");
                }

            }
        }

        internal List<(ISymbol callee, ISymbol caller)> GetMethodSymbols()
        {
            return _symbolsFound;
        }
    }

ここまでできれば、あとは好きなように処理して出力すればいいです。

感想

  • UnityのビルドシステムはMSBuildではないので、通常の.NETプロジェクトみたいにRoslynのWorkspace APIを簡単に使えない。
  • ソースコードの量に比例するため、コンパイルとSymbolの比較にかなり時間がかかる。並列化が必須。
  • コンパイル済みのdllファイルを調べば、ソースコードを再度コンパイルしなくてもやりたいことができそうな気がしますので、また時間ある時に試してみたいです。