この記事は【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#プロジェクトを生成 -
AssemblyDefinitionFile
をDefine 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;
}
自作コンパイラをビルドパイプラインに載せるには、この部分がハックできれば良さそうです。
どうやってハックできるのか確認していきましょう。
まず、MicrosoftCSharpCompiler
はCSharpLanguage.CreateCompiler
メソッドから参照されています。
public override ScriptCompilerBase CreateCompiler(ScriptAssembly scriptAssembly, EditorScriptCompilationOptions options, string tempOutputDirectory)
{
return new MicrosoftCSharpCompiler(scriptAssembly, options, tempOutputDirectory);
}
そして、CSharpLanguage
はScriptCompilers
のコンストラクタから参照されています。
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.CSharpSupportedLanguage
とScriptCompilers.SupportedLanguages
の上書きがキーになっているようでした(調べ方については割愛)。
さっそく、それらを上書きしてみましょう。
なお、以下のコードはinternalアクセスを多用しているため、Unity.InternalAPIEditorBridgeDev.001
等UnityEditor
にinternalアクセスが許可されているアセンブリ名を持つ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向けに公開しているパッケージをデモとして使います。
こちらからデモプロジェクト一式をダウンロードできます。
- 動作にはdotnet 3.0以上が必要です
- コマンドプロンプト(Windows)やターミナル(Mac)で、
dotnet --version
を実行したときに、3.0.x
以上が表示されていればインストール不要です - https://dotnet.microsoft.com/download からインストールしてください
- コマンドプロンプト(Windows)やターミナル(Mac)で、
- UnityプロジェクトをUnityエディタで開きます
-
Coffee.OpenSesame.Test.cs
がアクセシビリティに関するコンパイルエラー(CS0122)を吐いてますね。安心してください、privateアクセスしているだけです。あなたのUnityは正常ですよ。 - プロジェクトビューで
Tests/Coffee.OpenSesame.Test.asmdef
を選択し、コンテキストメニュー(右クリック)からOpenSesame Compiler > Setting
をクリックし、開いたウィンドウで以下のように入力します
- Open Sesame Compiler: チェックを入れる
- Publish Folder: Assets/Editor (初期値)
-
Save
を押すと、内容が保存されて、コンパイルが実行されます。そのまましばらく待つと...コンパイルエラーが消えました!
- ツールバーの
Window > General > Test Runner
を選択し、テストランナーウィンドウを開き、Run All
を押すと...privateアクセステストが通りました!
- この後は
Tests/Coffee.OpenSesame.Test.cs
にinternal/privateアクセスを追加しても、コンパイルエラーが吐かれませんよ! - 次に、プロジェクトビューで
Tests/Coffee.OpenSesame.Test.asmdef
を選択し、コンテキストメニュー(右クリック)からOpenSesame Compiler > Publish
を選択しましょう。 - dllファイルが生成されました。このdllは、もはやコンパイラの手を借りることなくinternal/privateアクセスが可能な存在です。
- このように、ポータブルな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
を併用することで可能
- internalクラス・インターフェースの継承は
- privateクラスに対する拡張メソッド
- 拡張メソッドの仕様上仕方ない気がするけど
- たぶん、まだあると思うので見つけたらコメントください!
- internal/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