Help us understand the problem. What is going on with this article?

【Unity, C#】続・privateな型やメンバにアクセスするには、多分これが一番早いと思います

この記事は【unityプロ技②】 Advent Calendar 2019の25日目の記事です。
この記事は【Unity, C#】internalな型やメンバにアクセスするには、多分これが一番早いと思いますの続編です。先にこちらをご覧ください。
この記事におけるソースコードは、全てPublic Domainです。

TL;DR

  • C#の属性IgnoresAccessChecksToAttributeは実は任意の外部アセンブリのinternal/privateメンバに対して自由にアクセスできるヤバいやつだったよ。C#宇宙の 法則が 乱れる!
  • 自作のRoslynコンパイラをビルドパイプラインに乗っけることで、ワークフローを意識することなく運用できるよ。
  • C#8の機能を使うことができるよ。
  • くれぐれも じこせきにんで おねがいします。

IgnoresAccessChecksToAttributeおさらい

前回、こんな風に述べてました。

  • InternalsVisibleToAttributeとは逆の方向に作用する。つまり、ライブラリ利用者側に設定することで、ライブラリに対するinternalアクセスを許可する
  • フルネームはSystem.Runtime.CompilerServices.IgnoresAccessChecksToAttribute
  • Base Class Libraryに載っていないが、ランタイム(CLR/CoreCLR)で作用する
  • csc.exeやMsBuildを使わずに自力でコンパイルする際に、CSharpCompilationOptions.TopLevelBinderFlagsに対して特定のフラグを立てると有効になる

ところが、その後の調査により、privateな型やメンバに対してもアクセスができることが判明しました。

ちなみに、元記事にもキッチリ書いてありました

  • In other words, how to get access to internal and private members without needing to use reflection or something like InternalsVisibleToAttribute.

うっかりが過ぎる。

privateな型やメンバにアクセスするモチベーション

internalな型やメンバにアクセスするモチベーションと同じです。
internal要素以上に、privateアクセス修飾子によって隠蔽されているメンバは多く、その中には有用なものもあります。

また、例えば拡張メソッドを実装する場面において、そのクラスのprivateメンバを扱うことができると考えるとどうでしょうか。
パフォーマンス/機能面で強力なハックを提供できるかもしれません。

実際やろうとすると手順が面倒なことに気づく

前回のパッケージを使ってprivateアクセスしてみましょう。

  • privateアクセスするコードを書く。IDE上でエラー表示になる(正常)
  • Unity側ではコンパイルエラーになる(正常)
  • Assets/Open C# ProjectでC#プロジェクトを生成
  • AssemblyDefinitionFileDefine Constraintsで無効化し、コンパイルエラーを解消
  • AssemblyDefinitionFileを右クリックし、InternalAccessibleCompiler/Compileでコンパイル
  • 生成したdllをインポート

わーい、とっても面倒くさい
いちいち手作業でコンパイルするのもナンセンスですね。
こんなことならリフレクション使ったほうが早いんじゃね。

ちなみに、C#プロジェクトを生成しても、対応するAssemblyDefinitionFileが無効化されるとC#ソリューションから外れ、インテリセンスがご臨の終です。どうもありがとうございました。
新しくIDEのインスタンス立ててC#プロジェクト読み込めばなんとかなりますが、なんか釈然としません...

なんとか良い感じに運用してみましょう。

【方法1】 アセットの変更に対するコールバックで対処する

素直に実装すればこうでしょうか。

  • 無効化されているAssemblyDefinitionFileから(なんとかして)C#プロジェクトを生成する
  • AssetPostprocessorで対象のcsファイルの変更を検知して、ファイルや外部参照、シンボルをコンパイラに引き渡す
  • AssetPostprocessor.OnGeneratedSlnSolutionメソッドでソリューションファイルの変更を検知し、C#プロジェクトが除外されないように対応する

運用するだけであればこの方法で十分そうですが、問題もあります。

  • 無効化されたAssemblyDefinitionFileが放置されているの
  • 外部参照解決がプラットフォーム的に正しいか確認が必要
    • Unityのバージョンアップで参照が増えたりするので対応が必要

さらっと流しましたがOnGeneratedSlnSolution文書化されていないメソッドです。
その他、以下のようにソリューションやプロジェクト生成時コールバックが用意されています。
これらはstaticメソッドであることに注意してください。

using UnityEditor;
using UnityEngine;

namespace CSharpProjectSolutions
{
    public class CustomAssetPostprocessor : AssetPostprocessor
    {
        // Unity標準のジェネレータ「以外」でC#プロジェクトを生成するかどうか返すコールバック(UnityVS等で利用).
        static bool OnPreGeneratingCSProjectFiles()
        {
            Debug.LogFormat("<color=cyan>OnPreGeneratingCSProjectFiles</color>");
            return false;
        }

        // C#プロジェクトファイルが生成された後に、修正を適用するコールバック.
        static string OnGeneratedCSProject(string path, string content)
        {
            Debug.LogFormat("<color=blue>OnGeneratedCSProject:</color> {0}\n\n{1}", path, content);
            return content;
        }

        // C#ソリューションファイルが生成された後に、修正を適用するコールバック.
        static string OnGeneratedSlnSolution(string path, string content)
        {
            Debug.LogFormat("<color=orange>OnGeneratedSlnSolution:</color> {0}\n\n{1}", path, content);
            return content;
        }

        // VisualStudioのバージョンアップによってcsprojがUnityと互換性が無くなったときの「セーフガード」.
        // 後処理でcsprojを修正、または作り直す. 願わくば、これが必要になりませんように.
        // ...とソースコードに書いてあった(意訳)
        static void OnGeneratedCSProjectFiles()
        {
            Debug.Log("<color=red>OnGeneratedCSProjectFiles:</color>");
        }
    }
}

【方法2】 自作コンパイラをビルドパイプラインに組み込む

そもそも、こんなに手間が掛かるのは、コンパイラへの入力としてC#プロジェクトファイルを使っているからです。
では、UnityにおいてC#プロジェクトファイルってビルドに必要なんでしょうか?答えはノーです

プロジェクトディレクトリ内からソリューションファイルやプロジェクトファイルを全て削除したとしても、コンパイルは元気に走ります
コンパイラへの入力は、実際には何が使われているんでしょうか?

以下、Unityにおけるコンパイルのに関する話になりますが、長くなりますので興味ない方は「めんどくさいのでパッケージを使う」まで読み飛ばし推奨


Unity組み込みコンパイラの仕組み

UnityのC#コンパイラはcscで、unity_csc.batまたはunity_csc.shとしてプラットフォームごとのスクリプトにラップされています。
実際のところ、この辺の処理はUnityのバージョンによって異なりますが詳しくは割愛。
Unity 2019.3では、unity_csc.*に関する記述はMicrosoftCSharpCompiler.StartCompilerにあります。

protected override Program StartCompiler()
{
    // プラットフォームに合ったコンパイラ(unity_csc)を探す
    var csc = Paths.Combine(EditorApplication.applicationContentsPath, "Tools", "RoslynScripts", "unity_csc");
    if (Application.platform == RuntimePlatform.WindowsEditor)
    {
        csc += ".bat";
    }
    else
    {
        csc += ".sh";
    }

    csc = Paths.UnifyDirectorySeparator(csc);

    // コンパイラが見つからなかったら例外
    if (!File.Exists(csc))
        ThrowCompilerNotFoundException(csc);

    // responseファイルを生成する
    if (assembly.GeneratedResponseFile == null)
    {
        assembly.GeneratedResponseFile = GenerateResponseFile(assembly, options, tempOutputDirectory);
    }

    // ProcessStartInfoを生成し、新しいコンパイルプロセスを開始
    var psi = new ProcessStartInfo() { Arguments = "/noconfig @" + assembly.GeneratedResponseFile, FileName = csc, CreateNoWindow = true };
    var program = new Program(psi);
    program.Start();

    return program;
}

自作コンパイラをビルドパイプラインに載せるには、この部分がハックできれば良さそうです。
どうやってハックできるのか確認していきましょう。

まず、MicrosoftCSharpCompilerCSharpLanguage.CreateCompilerメソッドから参照されています

public override ScriptCompilerBase CreateCompiler(ScriptAssembly scriptAssembly, EditorScriptCompilationOptions options, string tempOutputDirectory)
{
    return new MicrosoftCSharpCompiler(scriptAssembly, options, tempOutputDirectory);
}

そして、CSharpLanguageScriptCompilersのコンストラクタから参照されています

static ScriptCompilers()
{
    SupportedLanguages = new List<SupportedLanguage>();

    var types = new List<Type>();
    types.Add(typeof(CSharpLanguage));

    // typesにはCSharpLanguageしか入っていないので、以下と同じ
    // SupportedLanguages.Add(new CSharpLanguage());
    foreach (var t in types)
    {
        SupportedLanguages.Add((SupportedLanguage)Activator.CreateInstance(t));
    }

    // SupportedLanguagesにはCSharpLanguageしか入っていないので以下略
    CSharpSupportedLanguage = SupportedLanguages.Single(l => l.GetType() == typeof(CSharpLanguage));
}

staticコンストラクタに行き着きました。
CSharpSupportedLanguageがこのタイミングで生成されていることがわかりますね。
SupportedLanguageがリストとして受けられるようになっているのは、C#以外の言語(懐かしのUnityScript、Boo)に対応していた名残でしょう。

ここから先の参照は本題とズレるので省きますが、このCSharpSupportedLanguageをどうにかして書き換えることができれば良さそうです

自作コンパイルを使ってコンパイルする

IncrementalCompilerというパッケージを使ったことはありますか?
「ビルド時間が大幅に短縮できる」「C#7.2の機能が使える」という、Unity 2018.1〜2018.2向けの非常に強力なエディタ拡張パッケージで、実はUnity 2018.3以降では類似機能がビルトインされています

「自作コンパイラをビルドパイプラインに載せる」という意味では、IncrementalCompilerも同じことを行なっているはずです。
試しにパッケージを調べてみると、ScriptCompilers.CSharpSupportedLanguageScriptCompilers.SupportedLanguagesの上書きがキーになっているようでした(調べ方については割愛)。

さっそく、それらを上書きしてみましょう。


なお、以下のコードはinternalアクセスを多用しているため、Unity.InternalAPIEditorBridgeDev.001UnityEditorinternalアクセスが許可されているアセンブリ名を持つAssemblyDefinitionFileが必要です。

まずはエントリポイントからです。
InitializeOnLoad属性を使って、ロード時に自動的に実行されるようにしましょう。
ここではScriptCompilersのフィールドを書き換えるコードを用意します。

[InitializeOnLoad]
internal class CustomCSharpInstaller
{
    static CustomCSharpInstaller()
    {
        var customLanguage = new CustomCSharpLanguage();

        // SupportedLanguagesにカスタムC#を追加.
        ScriptCompilers.SupportedLanguages.RemoveAll(x => x.GetType() == typeof(CustomCSharpLanguage));
        ScriptCompilers.SupportedLanguages.Insert(0, customLanguage);

        // CSharpSupportedLanguageはreadonlyなのでリフレクションで上書き.
        typeof(ScriptCompilers)
            .GetField("CSharpSupportedLanguage", BindingFlags.Static | BindingFlags.NonPublic)
            .SetValue(null, customLanguage);

        // こちらも上書き.
        EditorBuildRules.GetPredefinedTargetAssemblies()
            .Where(x => x != null && x.Language != null)
            .First(x => x.Language.GetType() == typeof(CSharpLanguage))
            .Language = customLanguage;
    }
}

これにより、C#のコンパイル時に、CSharpLanguageの代わりにCustomCSharpLanguageが選択されるようになりました。


次に、CustomCSharpLanguageを実装していきますが、こちらはCSharpLanguage(ソース)を継承すれば最小限のコードで済みます。

internal class CustomCSharpLanguage : CSharpLanguage
{
    public override ScriptCompilerBase CreateCompiler(ScriptAssembly scriptAssembly, MonoIsland island, bool buildingForEditor, BuildTarget targetPlatform, bool runUpdater)
    {
        // カスタムコンパイラを使うかどうかのフラグ.
        // ScriptAssemblyやMonoIslandにはファイル一覧、参照一覧、シンボル一覧、出力ファイル名などの情報が格納されている.
        // それに応じて必要なアセンブリのみコンパイラを切り替えることが可能。
        bool useCustomCompiler = true;

        if(useCustomCompiler)
            // カスタムコンパイラを使う.
            return new CustomCSharpCompiler(island, runUpdater);
        else
            // 使わない場合はデフォルトのコンパイラを使う.
            return base.CreateCompiler(scriptAssembly, island, buildingForEditor, targetPlatform, runUpdater);
    }
}

このように、CSharpLanguage動的にコンパイラクラスのインスタンスを返せます
Unity2019.2までは、この部分でmsc/cscの切り替えを行なっていました
なお、Unity2019.3からはcsc(MicrosoftCSharpCompiler)のみです。


最後に、コンパイラクラスを作成しましょう。
このクラスはresponse file(コンパイルオプションを記述したファイル)を生成し、それを入力としてコンパイラプロセスを起動することが責務です。
MicrosoftCSharpCompiler(ソース)を継承すればこちらも簡単です。

internal class CustomCSharpCompiler : MicrosoftCSharpCompiler
{
    public CustomCSharpCompiler(MonoIsland island, bool runUpdater) : base(island, runUpdater)
    {
    }

    protected override Program StartCompiler()
    {
        // 継承元のコンパイルプロセスは即終了させる.
        var p = base.StartCompiler();
        p.Kill();

        // 最後に生成されたresponse fileを取得する.
        // 複数のファイルが生成される場合があるので、outオプションで判定する.
        var outopt = string.Format("/out:\"{0}\"", m_Island._output);
        var responsefile = Directory.GetFiles("Temp", "UnityTempFile*")
                .OrderByDescending(f => File.GetLastWriteTime(f))
                .First(path => File.ReadAllLines(path).Any(line => line.Contains(outopt)));

        //  自作のコンパイラでresponse fileを処理する.
        var psi = new ProcessStartInfo()
        {
            Arguments = ...,
            FileName = ...,
            CreateNoWindow = true
        };

        // プロセスを開始する.
        var program = new Program(psi);
        program.Start();

        return program;
    }
}

継承元(MicrosoftCSharpCompiler)でcscを使ったコンパイルプロセスを作っているので、継承元のものは終了させましょう。


response fileは、継承元のコンパイラクラスで作成され、Tempフォルダに保存されるので、最新のものをピックアップしましょう。
response fileの生成は継承元のコンパイラクラスに任せましょう、簡単に外部参照やシンボル等の整合性が取れます。
ちなみに、responsefileの中身はこんな感じです。
C#プロジェクトの内容をコンパイルオプションに置き換えたようなイメージですね。

/target:library
/nowarn:0169
/out:"Temp/*********.dll"
/debug:portable
/optimize-
/nostdlib+
/preferreduilang:en-US
/langversion:latest
/reference:"/********/Unity.app/Contents/Managed/UnityEngine/UnityEngine.dll"
/reference:"/********/Unity.app/Contents/Managed/UnityEngine/UnityEngine.AIModule.dll"
/reference:"/********/Unity.app/Contents/Managed/UnityEngine/UnityEngine.ARModule.dll"
/reference:"/********/Unity.app/Contents/Managed/UnityEngine/UnityEngine.AccessibilityModule.dll"
/reference:"/********/Unity.app/Contents/Managed/UnityEngine/UnityEngine.AnimationModule.dll"
/reference:"/********/Unity.app/Contents/Managed/UnityEngine/UnityEngine.AssetBundleModule.dll"
...以下、シンボル定義とcsファイルの一覧

余談ですが、なんでわざわざresponse fileを作る必要があるんでしょうか?
正解はProcess.Startで引数が長すぎて死ぬからです。
あと、文字列がダブルクォーテーションで囲まれてるのも注意が必要です。
私はどちらの罠も踏み抜きました。


長くなりましたが、これがUnity上で自作C#コンパイラを動かすための雛形となるコードです。
このコードを好きなように改造し、dllにコンパイルしてインポートすることで、csファイルのコンパイルが始まる前に自作C#コンパイラをビルドパイプラインに載せられます。

前回IgnoresAccessChecksToの下準備よりもめんどくさいですね。

めんどくさいのでパッケージを使う&デモ

今回は、先述のコンパイラを同梱済みのUnity向けに公開しているパッケージをデモとして使います。
こちらからデモプロジェクト一式をダウンロードできます。

  1. 動作にはdotnet 3.0以上が必要です
    • コマンドプロンプト(Windows)やターミナル(Mac)で、dotnet --versionを実行したときに、3.0.x以上が表示されていればインストール不要です
    • https://dotnet.microsoft.com/download からインストールしてください
  2. UnityプロジェクトをUnityエディタで開きます
  3. Coffee.OpenSesame.Test.csがアクセシビリティに関するコンパイルエラー(CS0122)を吐いてますね。安心してください、privateアクセスしているだけです。あなたのUnityは正常ですよ。
  4. プロジェクトビューでTests/Coffee.OpenSesame.Test.asmdefを選択し、コンテキストメニュー(右クリック)からOpenSesame Compiler > Settingをクリックし、開いたウィンドウで以下のように入力します
    • Open Sesame Compiler: チェックを入れる
    • Publish Folder: Assets/Editor (初期値)
  5. Saveを押すと、内容が保存されて、コンパイルが実行されます。そのまましばらく待つと...コンパイルエラーが消えました!:tada:
  6. ツールバーのWindow > General > Test Runnerを選択し、テストランナーウィンドウを開き、 Run Allを押すと...privateアクセステストが通りました!
  7. この後はTests/Coffee.OpenSesame.Test.csにinternal/privateアクセスを追加しても、コンパイルエラーが吐かれませんよ!
  8. 次に、プロジェクトビューでTests/Coffee.OpenSesame.Test.asmdefを選択し、コンテキストメニュー(右クリック)からOpenSesame Compiler > Publishを選択しましょう。
  9. dllファイルが生成されました。このdllは、もはやコンパイラの手を借りることなくinternal/privateアクセスが可能な存在です
  10. このように、ポータブルなdllファイルを生成することで、dllをインターフェースとしたinternal/privateアクセスが実現できます。もちろん、別プロジェクトでも利用できますし、パッケージとして配布することも可能です。

このパッケージ(com.coffee.open-sesame-compiler)のアピールポイント

  • ワークフローを変化させずに、internalアクセスもprivateアクセスもできちゃう
    • C#宇宙の 法則が 乱れる!
    • インストールするだけで使え、覚えるべきことが少ない
  • 必要最低限のアセンブリだけを自作コンパイルで処理できる
    • ↑の設定画面でOpen Sesame Compilerにチェックを入れたアセンブリのみ処理できる
    • それ以外はデフォルトのコンパイラで処理するので、影響範囲が小さい
  • AssemblyDefinitionFileを無効化しなくていい
    • internal/privateな要素を使っていても、コンパイルエラーにならない
    • 間違った使い方によるエラーは報告してくれるので安心
  • Publish機能を使えば、ポータブルなdllとしてエクスポートできる
    • 配布する際にコンパイル部分の依存が不要になる
  • C#8が使える
  • .Net 3.5でも.Net 4.xでも動く

気になるところ

機能検証に時間を取りすぎた結果、テストにあまり時間が取れませんでした...
「とりあえず、こういうことができる」事が分かったという段階ですね。今後に期待してください。

  • IgnoresAccessChecksToAttributeによるinternal/privateアクセスでできないことは?
    • internal/privateクラス・インターフェースの継承
      • internalクラス・インターフェースの継承はInternalsVisibleToを併用することで可能
    • privateクラスに対する拡張メソッド
      • 拡張メソッドの仕様上仕方ない気がするけど
    • たぶん、まだあると思うので見つけたらコメントください!
  • ランタイムでも動くの?
    • 未確認です...
  • IL2CPP対応してる?
    • 未確認です...
  • ブレークポイントは?
    • 未確認です...
  • サポートしてるバージョンは?
    • Unity 2018.3〜2019.2までは確認しました(Mac)
    • Unity 2019.3と2020.1で大幅に変更があったようなので、今後対応します
  • なんかエラー出るんだけど?
    • エディタを一度閉じた後、Library/ScriptAssembliesを削除して再起動してください
  • IDEだとprivate要素にエラー出たまんまなんだけど?
    • それはいわゆる、コラテラルダメージというものに過ぎない。目的の為の、致し方ない犠牲だ。

終わりに

internal/privateアクセスが手軽にできるようになりました。
正直、リフレクション憎しのエネルギーで結構ヤバいものを生み出してしまった感があります。
くれぐれも じこせきにんで おねがいします。

そして、この記事を書いている間に「あれ?UnityEditor.Modules.ICompilationExtension使ったらもっと簡単にイケるんじゃね?」と気づきました。
そういうことに気を取られるから遅筆なんだぞ!
後でその検証もやります。
【追記】ダメでした。

この場を借りて、様々な情報をご提供頂きました@pCYSl5EDgoさんにお礼を申し上げますm(_ _)m

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした