この記事は【unityプロ技②】 Advent Calendar 2019の25日目の記事です。
この記事は【Unity, C#】internalな型やメンバにアクセスするには、多分これが一番早いと思いますの続編です。先にこちらをご覧ください。
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メソッドであることに注意してください。
usingUnityEditor;usingUnityEngine;namespaceCSharpProjectSolutions{publicclassCustomAssetPostprocessor:AssetPostprocessor{// Unity標準のジェネレータ「以外」でC#プロジェクトを生成するかどうか返すコールバック(UnityVS等で利用).staticboolOnPreGeneratingCSProjectFiles(){Debug.LogFormat("<color=cyan>OnPreGeneratingCSProjectFiles</color>");returnfalse;}// C#プロジェクトファイルが生成された後に、修正を適用するコールバック.staticstringOnGeneratedCSProject(stringpath,stringcontent){Debug.LogFormat("<color=blue>OnGeneratedCSProject:</color> {0}\n\n{1}",path,content);returncontent;}// C#ソリューションファイルが生成された後に、修正を適用するコールバック.staticstringOnGeneratedSlnSolution(stringpath,stringcontent){Debug.LogFormat("<color=orange>OnGeneratedSlnSolution:</color> {0}\n\n{1}",path,content);returncontent;}// VisualStudioのバージョンアップによってcsprojがUnityと互換性が無くなったときの「セーフガード」.// 後処理でcsprojを修正、または作り直す. 願わくば、これが必要になりませんように.// ...とソースコードに書いてあった(意訳)staticvoidOnGeneratedCSProjectFiles(){Debug.Log("<color=red>OnGeneratedCSProjectFiles:</color>");}}}
【方法2】 自作コンパイラをビルドパイプラインに組み込む
そもそも、こんなに手間が掛かるのは、コンパイラへの入力としてC#プロジェクトファイルを使っているからです。
では、UnityにおいてC#プロジェクトファイルってビルドに必要なんでしょうか?答えはノーです。
プロジェクトディレクトリ内からソリューションファイルやプロジェクトファイルを全て削除したとしても、コンパイルは元気に走ります。
コンパイラへの入力は、実際には何が使われているんでしょうか?
以下、Unityにおけるコンパイルのに関する話になりますが、長くなりますので興味ない方は「めんどくさいのでパッケージを使う」まで読み飛ばし推奨。
Unity組み込みコンパイラの仕組み
UnityのC#コンパイラはcsc
で、unity_csc.bat
またはunity_csc.sh
としてプラットフォームごとのスクリプトにラップされています。unity_csc.*
に関する記述はMicrosoftCSharpCompiler.StartCompilerにあります。
protectedoverrideProgramStartCompiler(){// プラットフォームに合ったコンパイラ(unity_csc)を探すvarcsc=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を生成し、新しいコンパイルプロセスを開始varpsi=newProcessStartInfo(){Arguments="/noconfig @"+assembly.GeneratedResponseFile,FileName=csc,CreateNoWindow=true};varprogram=newProgram(psi);program.Start();returnprogram;}
自作コンパイラをビルドパイプラインに載せるには、この部分がハックできれば良さそうです。
どうやってハックできるのか確認していきましょう。
まず、MicrosoftCSharpCompiler
はCSharpLanguage.CreateCompiler
メソッドから参照されています。
publicoverrideScriptCompilerBaseCreateCompiler(ScriptAssemblyscriptAssembly,EditorScriptCompilationOptionsoptions,stringtempOutputDirectory){returnnewMicrosoftCSharpCompiler(scriptAssembly,options,tempOutputDirectory);}
そして、CSharpLanguage
はScriptCompilers
のコンストラクタから参照されています。
staticScriptCompilers(){SupportedLanguages=newList<SupportedLanguage>();vartypes=newList<Type>();types.Add(typeof(CSharpLanguage));// typesにはCSharpLanguageしか入っていないので、以下と同じ// SupportedLanguages.Add(new CSharpLanguage());foreach(vartintypes){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]internalclassCustomCSharpInstaller{staticCustomCSharpInstaller(){varcustomLanguage=newCustomCSharpLanguage();// 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
(ソース)を継承すれば最小限のコードで済みます。
internalclassCustomCSharpLanguage:CSharpLanguage{publicoverrideScriptCompilerBaseCreateCompiler(ScriptAssemblyscriptAssembly,MonoIslandisland,boolbuildingForEditor,BuildTargettargetPlatform,boolrunUpdater){// カスタムコンパイラを使うかどうかのフラグ.// ScriptAssemblyやMonoIslandにはファイル一覧、参照一覧、シンボル一覧、出力ファイル名などの情報が格納されている.// それに応じて必要なアセンブリのみコンパイラを切り替えることが可能。booluseCustomCompiler=true;if(useCustomCompiler)// カスタムコンパイラを使う.returnnewCustomCSharpCompiler(island,runUpdater);else// 使わない場合はデフォルトのコンパイラを使う.returnbase.CreateCompiler(scriptAssembly,island,buildingForEditor,targetPlatform,runUpdater);}}
このように、CSharpLanguage
は動的にコンパイラクラスのインスタンスを返せます。
Unity2019.2までは、この部分でmsc/cscの切り替えを行なっていました。
なお、Unity2019.3からはcsc(MicrosoftCSharpCompiler
)のみです。
最後に、コンパイラクラスを作成しましょう。
このクラスはresponse file(コンパイルオプションを記述したファイル)を生成し、それを入力としてコンパイラプロセスを起動することが責務です。MicrosoftCSharpCompiler
(ソース)を継承すればこちらも簡単です。
internalclassCustomCSharpCompiler:MicrosoftCSharpCompiler{publicCustomCSharpCompiler(MonoIslandisland,boolrunUpdater):base(island,runUpdater){}protectedoverrideProgramStartCompiler(){// 継承元のコンパイルプロセスは即終了させる.varp=base.StartCompiler();p.Kill();// 最後に生成されたresponse fileを取得する.// 複数のファイルが生成される場合があるので、outオプションで判定する.varoutopt=string.Format("/out:\"{0}\"",m_Island._output);varresponsefile=Directory.GetFiles("Temp","UnityTempFile*").OrderByDescending(f=>File.GetLastWriteTime(f)).First(path=>File.ReadAllLines(path).Any(line=>line.Contains(outopt)));// 自作のコンパイラでresponse fileを処理する.varpsi=newProcessStartInfo(){Arguments=...,FileName=...,CreateNoWindow=true};// プロセスを開始する.varprogram=newProgram(psi);program.Start();returnprogram;}}
継承元(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