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

この記事は[【unityプロ技②】 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/unityforpro2)の25日目の記事です。
この記事は[【Unity, C#】internalな型やメンバにアクセスするには、多分これが一番早いと思います](https://qiita.com/mob-sakai/items/f3bbc0c45abc31ea7ac0)の続編です。先にこちらをご覧ください。
この記事におけるソースコードは、全て`Public Domain`です。


## TL;DR

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



## `IgnoresAccessChecksToAttribute`おさらい


[前回](https://qiita.com/mob-sakai/items/f3bbc0c45abc31ea7ac0)、こんな風に述べてました。

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

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

ちなみに、**[元記事](https://www.strathweb.com/2018/10/no-internalvisibleto-no-problem-bypassing-c-visibility-rules-with-roslyn/)にもキッチリ書いてありました**。

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

うっかりが過ぎる。

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

[internalな型やメンバにアクセスするモチベーション](https://qiita.com/mob-sakai/items/f3bbc0c45abc31ea7ac0#internalな型やメンバにアクセスするモチベーション)と同じです。
internal要素以上に、privateアクセス修飾子によって隠蔽されているメンバは多く、その中には有用なものもあります。

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


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

[前回のパッケージ](https://github.com/mob-sakai/InternalAccessibleCompilerForUnity)を使ってprivateアクセスしてみましょう。

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

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

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

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

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

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

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

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

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

さらっと流しましたが`OnGeneratedSlnSolution`は[文書化されていないメソッド](https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/AssetPostprocessor.cs#L140)です。
その他、以下のようにソリューションやプロジェクト生成時コールバックが用意されています。
これらは**staticメソッドである**ことに注意してください。

```cs
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](https://github.com/Unity-Technologies/UnityCsReference/blob/2019.3/Editor/Mono/Scripting/Compilers/MicrosoftCSharpCompiler.cs#L120)にあります。

```cs
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;
}
```

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

まず、`MicrosoftCSharpCompiler`は`CSharpLanguage.CreateCompiler`メソッドから[参照されています](https://github.com/Unity-Technologies/UnityCsReference/blob/2019.3/Editor/Mono/Scripting/Compilers/CSharpLanguage.cs#L39)。

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

そして、`CSharpLanguage`は`ScriptCompilers`のコンストラクタから[参照されています](https://github.com/Unity-Technologies/UnityCsReference/blob/2019.3/Editor/Mono/Scripting/ScriptCompilers.cs#L79)。

```cs
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以降では[類似機能がビルトインされています](https://forum.unity.com/threads/unity-incremental-c-compiler-deprecated.523993/)。

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

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

---

なお、以下のコードは**internalアクセスを多用している**ため、`Unity.InternalAPIEditorBridgeDev.001`等`UnityEditor`に[internalアクセスが許可されているアセンブリ名](https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/AssemblyInfo/AssemblyInfo.cs)を持つ`AssemblyDefinitionFile`が必要です。

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

```cs
[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`([ソース](https://github.com/Unity-Technologies/UnityCsReference/blob/2019.3/Editor/Mono/Scripting/Compilers/CSharpLanguage.cs))を継承すれば最小限のコードで済みます。

```cs
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の切り替えを行なっていました](https://github.com/Unity-Technologies/UnityCsReference/blob/2019.2/Editor/Mono/Scripting/Compilers/CSharpLanguage.cs#L49)。
なお、Unity2019.3からはcsc(`MicrosoftCSharpCompiler`)のみです。

---

最後に、コンパイラクラスを作成しましょう。
このクラスは**response file(コンパイルオプションを記述したファイル)を生成**し、それを入力として**コンパイラプロセスを起動**することが責務です。
`MicrosoftCSharpCompiler`([ソース](https://github.com/Unity-Technologies/UnityCsReference/blob/2019.3/Editor/Mono/Scripting/ScriptCompilers.cs))を継承すればこちらも簡単です。

```cs
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で引数が長すぎて死ぬから**です。
あと、**文字列がダブルクォーテーションで囲まれてる**のも注意が必要です。
~~私はどちらの罠も踏み抜きました。~~

<br>

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

[前回](https://qiita.com/mob-sakai/items/f3bbc0c45abc31ea7ac0#ignoresaccesscheckstoattributeを使ってinternalアクセスしてみる)の`IgnoresAccessChecksTo`の下準備よりもめんどくさいですね。

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

今回は、先述のコンパイラを同梱済みの[Unity向けに公開しているパッケージ](https://github.com/mob-sakai/OpenSesameCompilerForUnity)をデモとして使います。
[こちら](https://github.com/mob-sakai/OpenSesameCompilerForUnity/archive/demo.zip)からデモプロジェクト一式をダウンロードできます。

1. 動作にはdotnet 3.0以上が必要です
    - コマンドプロンプト(Windows)やターミナル(Mac)で、`dotnet --version`を実行したときに、`3.0.x`以上が表示されていればインストール不要です
    - https://dotnet.microsoft.com/download からインストールしてください
1. UnityプロジェクトをUnityエディタで開きます
1. `Coffee.OpenSesame.Test.cs`がアクセシビリティに関するコンパイルエラー(CS0122)を吐いてますね。**安心してください、privateアクセスしているだけです。あなたのUnityは正常ですよ。**  
1. ![](https://user-images.githubusercontent.com/12690315/71448725-fd78b700-2782-11ea-8323-02ac35b23c9e.png)
1. プロジェクトビューで`Tests/Coffee.OpenSesame.Test.asmdef`を選択し、コンテキストメニュー(右クリック)から`OpenSesame Compiler > Setting`をクリックし、開いたウィンドウで以下のように入力します  
![](https://user-images.githubusercontent.com/12690315/71448492-cc4ab780-277f-11ea-9c65-5456a12e5ce1.png)
    - Open Sesame Compiler: **チェックを入れる**
    - Publish Folder:  **Assets/Editor** (初期値)
1. `Save`を押すと、内容が保存されて、コンパイルが実行されます。そのまましばらく待つと...コンパイルエラーが消えました！:tada:  
![](https://user-images.githubusercontent.com/12690315/71448859-695c1f00-2785-11ea-8362-b71b23f05c08.png)
1. ツールバーの`Window > General > Test Runner`を選択し、テストランナーウィンドウを開き、 `Run All`を押すと...privateアクセステストが通りました！  
![](https://user-images.githubusercontent.com/12690315/71448890-e6879400-2785-11ea-8e55-033f55a76b68.png)
1. この後は`Tests/Coffee.OpenSesame.Test.cs`にinternal/privateアクセスを追加しても、コンパイルエラーが吐かれませんよ！
1. 次に、プロジェクトビューで`Tests/Coffee.OpenSesame.Test.asmdef`を選択し、コンテキストメニュー(右クリック)から`OpenSesame Compiler > Publish`を選択しましょう。
1. dllファイルが生成されました。このdllは、**もはやコンパイラの手を借りることなくinternal/privateアクセスが可能な存在です**。  
![](https://user-images.githubusercontent.com/12690315/71448940-11261c80-2787-11ea-8ce8-db7647f719a2.png)
1. このように、ポータブルな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