IUnityLinkerProcessorでCodeStrippingからコードを守る
Applibot Advent Calender2023の19日目の記事になります。
前回は @16cho さんの GodotでMagicOnionやってみる という記事でした。
はじめに
本記事では IUnityLinkerProcessor
を用いて動的に link.xml
を生成する方法をお伝えします。
ManagedCodeStrippingの対象にして欲しくないコードを保護する方法について、なぜそのような実装が必要になったかという経緯や具体的な事例を踏まえてご紹介します。
Managed Code Stripping とは
まずはじめに、ManagedCodeStrippingについて触れておきます。この機能を用いると、ビルド時にアプリケーション側で利用していないコードを削除し、そのバイナリサイズを削除します。
参考 : ManagedCodeStrippingについて
使用していないアプリケーション側のC#コードはもちろんのこと、UnityEngineやcorelibなどのエンジン側で用意されているコードも削除してくれるため、適切に設定することでアプリケーションのバイナリサイズの削減や、ゲームの起動時間の短縮も期待できます。
基本的には、MonoBehaviorやScriptableObjectなどのルート型を継承するクラスから各クラスやそのメンバへの参照を辿り、到達できなかったクラスやメンバが削除対象となります。
この際、リフレクション経由でのみアクセスしているクラスやメンバーは、コードを使用している扱いにならないため、そのままではManagedCodeStrippingによるコード削除の対象になります。
これを回避するためのコード利用を開発者側で明記するための仕組みとして link.xml
や Preserve
属性があります。
リフレクション経由などのアクセスで削除されるコードに対してはこれらを適切に設定することで、本来必要なコードが削除されてしまう問題を回避できます。
ManagedCodeStrippingについての詳しい挙動に関してはこちらの記事で紹介されています。
Managed Code Strippingの挙動の検証と2020 LTSで利用できる新しいアノテーション属性の紹介
プロジェクトで発生した問題
私が所属しているプロジェクトでは途中からManagedStrippingLevelを High
にしました。
すると、Zenjectを用いて依存注入しているクラスが軒並み消されていました。これはZenjectがリフレクション経由でクラスのコンストラクタを呼び出しているからです。クラス一つ一つに Preserve属性
をつけるとなると多くソースを触る + 抜けが出る、といったデメリットが生じます。そこで、Zenjectのコンテナにバインドしているクラスは自動的にStrippingの対象から外すようにしたいと考えました。
IUnityLinkerProcessorを使うまでの経緯
link.xmlへの書き込みにはアセンブリ名とクラス名が必要です。
Zenjectのコンテナにバインドしている部分には共通点があり、
Container.Bind<クラス名>();
Container.Bind<インターフェース>().To<クラス名>();
というルールが存在していました。(厳密にはルールはこれだけではありません。)
単純に.csファイルから正規表現などを使って探索すると、本体の対象ではないクラスが引っかかる可能性があります。(Bind<>()
という構文が他のクラスでも使われる可能性が高いため。)
そこで、特定のクラスのメソッドであることを前提に探索する必要があります。コンパイル後のアセンブリではクラス名 + メソッド名を取得することができます。今回はこれを解析することでZenjectのクラスのBind<>()
のみを対象にとることを目指しました。
IUnityLinkerProcessor
のコールバックが呼び出されるUnityLinkerの時点ではコンパイルが終わっており、dllが生成されています。これは構文解析が終了した状態なので、ZenjectクラスのBind<>()
といった探索が可能です。
dll情報を取得し、プロジェクトコード中のBind
メソッドを呼び出している箇所をILのInstructionレベルで特定します。メソッド呼び出しのオペランドから、実際にバインドしているクラスをFQDN(Fully Qualifed Domain Name)が特定できるので、それを収集してlink.xmlを生成します。収集にはDLLバイナリを読み込んでそこに含まれる全情報を見ることができるMono.cecil
を使いました。
- link.xmlの適用を自動化したい
- 構文解析が終わった状態のソースの取得ができる
以上の理由から IUnityLinkerProcessor を使用することを決めました。
IUnityLinkerProcessorについて
前提が長くなりました。ここから本編です。
IUnityLinkerProcessor
は、UnityLinkerの実行時にコールバックを受け取ることができ、その内部でlink.xmlを生成、ファイルへのパスを返すことでlink.xmlを適用することが可能です。
-
int CallbackOrder
基本的にはビルド時に呼ばれるコールバックの優先度を決めるフィールドです。
特殊な場合を除き、ここは基本的に 0で問題ありません。 -
string GenerateAdditionaLinkXmlFile(BuildReport report, UnityLinkerBuildPipelineData data)
link.xmlを生成してそのパスを返すことでlink.xmlを適用することが可能です。
具体実装について
先ほど紹介した Zenjectで依存注入するクラスが消えてしまう件の対策
を軸に紹介します。
- 全体設計
- link.xmlファイルパスの作成
- Mono.cecilを使った、保護対象となるクラスの収集
- XmlDocument / XmlNodeを使った、link.xmlの生成
- パスの返却
まずは全体的な設計についてです。
全体設計 : UnityLinkerProcessorBaseについて
自分が所属しているプロジェクトでは、Zenjectの他にもリフレクション経由でコンストラクタ呼び出しを行なっている部分が複数ありました。
収集対象毎に共通処理を書くのは非効率なので、まずは、GenerateAdditionalLinkXmlFile
の実装や、保護対象を収集するメソッドを定義したクラスUnityLinkerProcessorBase
を作成しました。
public abstract class UnityLinkerProcessorBase : IUnityLinkerProcessor
{
// コールバックが呼ばれる優先度 特に指定しないので 0
public int callbackOrder => 0;
// link.xmlのユニーク名
protected abstract string LinkXmlName { get; }
// 保護対象の収集を行うメソッド
protected abstract void CollectEntries(LinkXmlEntryCollector collector, Assembly[] assemblies);
// link.xmlのパスを返すコールバックメソッド
public string GenerateAdditionalLinkXmlFile(BuildReport report, UnityLinkerBuildPipelineData data)
{
// パスの生成
string path = System.IO.Path.Combine(data.inputDirectory, $"{LinkXmlName}.link.xml");
// link.xmlの生成を行うインスタンス
LinkXmlGenerator.LinkXmlGenerator generator = new LinkXmlGenerator.LinkXmlGenerator();
// 保護対象の収集
CollectEntries(new LinkXmlEntryCollector(generator), AppDomain.CurrentDomain.GetAssemblies());
// link.xmlの生成
generator.Generate(path);
return path;
}
}
以下、クラスの簡単な説明です。
- UnityLinkerProcessorBase
IUnityLinkerProcessorを利用する際の共通処理をまとめたクラス - CollectEntries
保護対象の収集を行う抽象メソッド - LinkXmlGenerator
収集した情報を元にlink.xmlの生成を行う - LinkXmlEntryCollector
LinkXmlGeneratorに保護対象の情報を追加するメソッドを提供
このクラスをベースに保護対象の収集を行うクラスを実装します。
Zenjectで依存注入するクラスを取得するという目的のDILinkerProcessor
です。
public class DILinkerProcessor : UnityLinkerProcessorBase
{
protected override string LinkXmlName => "di";
protected override void CollectEntries(LinkXmlEntryCollector collector, Assembly[] assemblies)
{
// 保護対象の収集を行います。
// 詳しい中身は後で紹介します。
}
private static bool TryGetType(string fullName, AssemblyCollection assemblyCollection, out Type type)
{
// クラス名から`Type`に変換を行います。
// 詳しい中身は後で紹介します。
}
private static bool TryParseInjectedTypeName(Instraction instraction, AssemblyCollection assemblyCollection, out Type type)
{
// Instractionから`Type`に変換を行います。
// 詳しい中身は後で紹介します。
}
}
TryGetType
やTryParseInjectedTypeName
の引数の型であるAssemblyCollection
ではAssemblyDefinition
を安全に使うための仕組みを入れています。
AssemblyDefinition
はusingを使用した際は自動的にDispose
が呼び出されるのですが、今回の場合は複数クラスにまたがって使用されることに加え、リストによって管理したいため、明示的にDispose
を呼び出すクラスを作成し、使用しています。
以下にAssemblyCollection
を載せます。
class AssemblyCollection : IDisposable
{
public AssemblyDefinition[] AssemblyDefinitions { get; }
public AssemblyCollection(Assembly[] assemblies)
{
List<AssemblyDefinition> targetAssemblyDefinition = new List<AssemblyDefinition>();
foreach (Assembly assembly in assemblies)
{
// dynamic アセンブリの場合はReadAssemblyができないためスキップ
if (assembly.IsDynamic)
{
continue;
}
AssemblyDefinition assemblyDefinition = AssemblyDefinition.ReadAssembly(assembly.Location);
targetAssemblyDefinition.Add(assemblyDefinition);
}
AssemblyDefinitions = targetAssemblyDefinition.ToArray();
}
public void Dispose()
{
if (AssemblyDefinitions != null)
{
foreach (AssemblyDefinition assemblyDefinition in AssemblyDefinitions)
{
assemblyDefinition.Dispose();
}
}
}
}
link.xmlファイルの作成
link.xmlファイルを作成するためのパスを生成します。
ファイルの生成は LinkXmlGeneratorの内部のxmlDocument.Save(path)
で行っています。
パスを生成するにあたって注意点がいくつかあります。
- data.inputDirectory
生成するlink.xmlは、GenerateAdditionalLinkXmlFileメソッドメソッドの引数として渡されてくるdata
のinputDirectory
内部に配置する必要があります。 - ファイル名
複数のファイルに分けてlink.xmlを生成する場合、link.xmlの名前が被ると最後に生成したもののみ適用されてしまいます。
本来、Assets/
にlink.xmlを配置する際は命名をlink.xml
に固定する必要があります。しかし、IUnityLinkerProcessor
を使う場合はファイルパスを直接ビルドパイプラインに渡しているので、命名の統一は必要ありません。ユニークな名前を適用するようにしましょう。
私が取った手法としては、GenerateAdditionalLinkXmlFile
を実装するabstractクラスを作り、abstractなstring LinkXmlName
フィールドを定義することで、継承先のクラスでlink.xmlにつける名前を決定できるようにしました。
string path = System.IO.Path.Combine(data.inputDirectory, $"{LinkXmlName}.link.xml");
Mono.cecilを使った、保護対象となるクラスの収集
次に保護対象のクラスの情報を収集します。
- LinkXmlEntryCollectorについて
クラスの情報を収集するCollectEntries
メソッドがLinkXmlGeneratorに対して情報を渡すためのクラスです。
LinkXmlGeneratorのアクセスを制限し、クラス情報の収集のみを行えるようにしています。
public class LinkXmlEntryCollector
{
private readonly LinkXmlGenerator _generator;
public LinkXmlEntryCollector(LinkXmlGenerator generator)
{
_generator = generator;
}
public void AddType(Type type) => _generator.AddType(type);
public void AddMethod(MethodInfo method) => _generator.AddMethod(method);
public void AddAssembly(Assembly assembly) => _generator.AddAssembly(assembly);
}
収集のロジック
実際の収集は AppDomain.CurrentDomain.GetAssemblies()
を起点として行います。
この時点で構文解析が終了した状態のコードを取得出来ます。
先ほど示したDILinkerProcessor
の中身を実装していきます。
まずはCollectEntries
メソッドです。
protected override void CollectEntries(LinkXmlEntryCollector collector, Assembly[] assemblies)
{
// AssemblyDefinitionのリストに変換
using AssemblyCollection assemblyCollection = new AssemblyCollection(assemblies);
foreach (AssemblyDefinition assemblyDefinition in assemblyCollection.AssemblyDefinitions)
{
foreach (TypeDefinition typeDefinition in assemblyDefinition.MainModule.GetTypes())
{
foreach (MethodDefinition methodDefinition in typeDefinition.GetMethods())
{
if (!methodDefinition.HasBody)
{
continue;
}
foreach (Instruction instruction in methodDefinition.Body.Instructions)
{
if (TryParseInjectedTypeName(instruction, assemblyCollection, out Type type))
{
if (type == null)
{
continue;
}
collector.AddType(type);
}
}
}
}
}
}
for文でmethodDefinition
のBody
のInstruction
を取得し、それを元にType
型をLinkXmlEntryCollector
のAddType()
で対象に加えています。
以下に関数内で使用してるTryParseInjectedTypeName
及び、TryGetType
を示します。
このクラスはInstraction
から対象となるType
を探索しています。
Instraction
からメソッド呼び出しの命令を取得し、その中のオペランドに対して、正規表現を用いて文字列の特定の部分を抽出しています。
private static bool TryParseInjectedTypeName(Instruction instruction, AssemblyCollection assemblyCollection, out Type type)
{
type = null;
if (instruction.OpCode != OpCodes.Callvirt)
{
return false;
}
string operand = instruction.ToString();
foreach ((Regex pattern, int groupIndex) in _ZENJECT_PATTERNS)
{
Match match = pattern.Match(operand);
if (match.Success == false)
{
continue;
}
if (TryGetType(match.Groups[groupIndex].Value, assemblyCollection, out Type resultType))
{
type = resultType;
return true;
}
}
return false;
}
private static bool TryGetType(string fullName, AssemblyCollection assemblyCollection, out Type type )
{
type = null;
foreach (AssemblyDefinition assembly in assemblyCollection.AssemblyDefinitions)
{
foreach (TypeDefinition typeDefinition in assembly.MainModule.GetTypes())
{
if (typeDefinition.FullName == fullName)
{
// Assembly-Csharp外は型取得のためにアセンブリ修飾名での検索が必須のため文字列を生成
string assemblyQualifiedName = $"{typeDefinition}, {assembly.FullName}";
type = Type.GetType(assemblyQualifiedName);
return true;
}
}
}
return false;
}
オペランドに対して行った正規表現のパターンが_ZENJECT_PATTERNS
です。
Container.Bind<対象のクラス>()
Container.Bind<対象のクラスのインターフェース>().To<対象のクラス>()
となっているものを対象に検索しました。
readonly (Regex Pattern, int IndexGroup)[] _ZENJECT_PATTERNS = new[]
{
// callvirt Zenject.ConcreteIdBinderGeneric`1<!!0> Zenject.DiContainer::Bind<TargetType>()
(new Regex(@"Zenject.ConcreteIdBinderGeneric`1<!!0> Zenject.DiContainer::Bind<(.*?)>" + Regex.Escape("()"), RegexOptions.Compiled),
1),
// callvirt Zenject.FromBinderGeneric`1<!!0> Zenject.ConcreteBinderGeneric`1<FromType>::To<TargetType>()
(new Regex(@"Zenject.FromBinderGeneric`1<!!0> Zenject.ConcreteBinderGeneric`1<(.*?)>::To<(.*?)>" + Regex.Escape("()"), RegexOptions.Compiled),
2),
};
XmlDocument / XmlNodeを使った、link.xmlの生成
対象のクラスやメソッドの情報を取得することができたら、それを元にlink.xmlを生成します。
以下がlink.xmlの生成を担っているLinkXmlGenerator
です。
クラス全体を紹介します。
public class LinkXmlGenerator
{
private readonly List<Type> _types = new List<Type>();
private readonly List<MethodInfo> _methods = new List<MethodInfo>();
private readonly List<Assembly> _assemblies = new List<Assembly>();
// 保護対象を追加していくメソッド
// LinkXmlEntryCollectorからのみ呼ばれることを想定
public void AddType(Type type)
{
_types.Add(type);
}
public void AddMethod(MethodInfo method)
{
_methods.Add(method);
}
public void AddAssembly(Assembly assembly)
{
_assemblies.Add(assembly);
}
// UnityLinkerProcessorBaseから呼び出される生成関数
public void Generate(string path)
{
// 情報を合わせる
IReadOnlyList<AssemblyNode> assemblyNodes = MargeTargetInfo();
// xmlの生成
XmlDocument xmlDocument = new XmlDocument();
GenerateXml(xmlDocument, assemblyNodes);
// xmlの出力
xmlDocument.Save(path);
}
// 収集した情報を元にlink.xmlを生成
private void GenerateXml(XmlDocument xmlDocument, IReadOnlyList<AssemblyNode> assemblyNodes)
{
XmlNode linkerNode = xmlDocument.AppendChild(xmlDocument.CreateElement("linker"));
foreach (AssemblyNode assemblyNode in assemblyNodes)
{
XmlNode xmlAssemblyNode = linkerNode.AppendChild(xmlDocument.CreateElement("assembly"));
if (xmlAssemblyNode.Attributes == null)
{
continue;
}
XmlAttribute fullNameAttribute = xmlDocument.CreateAttribute("fullname");
fullNameAttribute.Value = assemblyNode.Value.FullName;
xmlAssemblyNode.Attributes.Append(fullNameAttribute);
if (assemblyNode.IsPreserve)
{
// preserve="all"を追加
XmlAttribute preserveAttribute = xmlDocument.CreateAttribute("preserve");
preserveAttribute.Value = "all";
xmlAssemblyNode.Attributes.Append(preserveAttribute);
}
else
{
foreach (TypeNode typeNode in assemblyNode.Types)
{
XmlNode xmlTypeNode = xmlAssemblyNode.AppendChild(xmlDocument.CreateElement("type"));
if (xmlTypeNode.Attributes == null)
{
return;
}
XmlAttribute typeFullNameAttribute = xmlDocument.CreateAttribute("fullname");
typeFullNameAttribute.Value = typeNode.Value.FullName;
xmlTypeNode.Attributes.Append(typeFullNameAttribute);
if (typeNode.IsPreserve)
{
// preserve="all"を追加
XmlAttribute preserveAttribute = xmlDocument.CreateAttribute("preserve");
preserveAttribute.Value = "all";
xmlTypeNode.Attributes.Append(preserveAttribute);
}
else
{
// method name="method"を追加
foreach (MethodInfo method in typeNode.Methods)
{
XmlNode xmlMethodNode = xmlTypeNode.AppendChild(xmlDocument.CreateElement("method"));
if (xmlMethodNode.Attributes == null)
{
return;
}
XmlAttribute methodNameAttribute = xmlDocument.CreateAttribute("name");
methodNameAttribute.Value = method.Name;
xmlMethodNode.Attributes.Append(methodNameAttribute);
}
}
}
}
}
}
// collectorで集めた情報をlink.xmlに出力しやすいように独自のNode型に変換
private IReadOnlyList<AssemblyNode> MargeTargetInfo()
{
List<AssemblyNode> result = new List<AssemblyNode>();
//Distinct処理
List<Assembly> assemblyList = _assemblies.Distinct().ToList();
List<Type> typeList = _types.Distinct().ToList();
List<MethodInfo> methodList = _methods.Distinct().ToList();
// 情報集め
// assembly
foreach (Assembly assembly in assemblyList)
{
if (result.Exists(x => x.Value.FullName == assembly.FullName) == false)
{
result.Add(new AssemblyNode(assembly, true));
}
}
// type
foreach (Type type in typeList)
{
AssemblyNode targetAssemblyNode = result.Find(x => x.Value.FullName == type.Assembly.FullName);
if (targetAssemblyNode == null)
{
// 新規AssemblyNodeを作成
AssemblyNode assemblyNode = new AssemblyNode(type.Assembly, false);
result.Add(assemblyNode);
TypeNode typeNode = new TypeNode(type, true);
assemblyNode.AddTypeNode(typeNode);
}
else
{
// 既存のAssemblyNodeに追加
targetAssemblyNode.AddTypeNode(new TypeNode(type, true));
}
}
// method
foreach (MethodInfo method in methodList)
{
AssemblyNode assemblyNode =
result.Find(x => x.Value.FullName == method.DeclaringType.Assembly.FullName);
if (assemblyNode == null)
{
// 新規AssemblyNodeを作成
assemblyNode = new AssemblyNode(method.DeclaringType.Assembly, false);
result.Add(assemblyNode);
}
// Typeが存在するか探索
if (assemblyNode.TryGetTypeNodeByTypeName(out TypeNode typeNode, method.DeclaringType.FullName))
{
// 存在したTypeにmethodを追加
typeNode.AddMethodInfo(method);
}
else
{
// Typeを作成して追加
typeNode = new TypeNode(method.DeclaringType, false);
assemblyNode.AddTypeNode(typeNode);
typeNode.AddMethodInfo(method);
}
}
return result;
}
// アセンブリの情報を含むノード
private class AssemblyNode
{
private readonly List<TypeNode> _types = new List<TypeNode>();
public IReadOnlyList<TypeNode> Types => _types;
public Assembly Value { get; }
/// <summary>
/// アセンブリ空間をpreserveするか否か
/// </summary>
public bool IsPreserve { get; }
public AssemblyNode(Assembly assembly, bool isPreserve)
{
Value = assembly;
IsPreserve = isPreserve;
}
public void AddTypeNode(TypeNode typeNode)
{
_types.Add(typeNode);
}
public bool TryGetTypeNodeByTypeName(out TypeNode typeNode, string typeName)
{
typeNode = _types.Find(x => x.Value.FullName == typeName);
return typeNode != null;
}
}
// Typeの情報を含むノード
private class TypeNode
{
private List<MethodInfo> _methods = new List<MethodInfo>();
public IReadOnlyList<MethodInfo> Methods => _methods;
public Type Value { get; }
/// <summary>
/// そのTypeをPreserveするか否か
/// </summary>
public bool IsPreserve { get; }
public TypeNode(Type type, bool isPreserve)
{
Value = type;
IsPreserve = isPreserve;
}
public void AddMethodInfo(MethodInfo method)
{
_methods.Add(method);
}
}
public interface IEntry
{
}
}
生成するにあたって注意しなければならない点として、link.xmlの構成を守る、と言うものがあります。
Unity公式のManagedCodeStrippingについてのドキュメントではlink.xmlの様々な書き方が掲載されていますが、今回はアセンブリ、クラス、メソッドの保護とpreserve属性についてのみ記載します。
-
linker
属性
link.xmlの書き方としてlinker
属性の下に保護対象を宣言していくというルールがあります。
XmlNode linkerNode = xmlDocument.AppendChild(xmlDocument.CreateElement("linker"));
-
preserve
属性
preserve
属性はそのアセンブリやクラスに含まれているすべての要素をStripping対象から外します。
例えば、特定のアセンブリの全てを保護したい場合は、
XmlNode xmlAssemblyNode = linkerNode.AppendChild(xmlDocument.CreateElement("assembly"));
AssemlbyDefinition assembly = 対象のアセンブリ // ここではTargetAsseblyというアセンブリとします。
// アセンブリを保護する場合はアセンブリ名だけではなく、`fullname` で指定する必要がある。
XmlAttribute fullNameAttribute = xmlDocument.CreateAttribute("fullname");
fullNameAttribute.Value = assembly.FullName;
xmlAssemblyNode.Attribute.Append(fullNameAttribute);
// preserve属性を追加
XmlAttribute preserveAttribute = xmlDocument.CreateAttribute("preserve");
preserveAttribute.Value = "all";
xmlAssemblyNode.Attribute.Append(preserveAttribute);
このコードでは以下のようなlink.xmlが生成されます。
<linker>
<assemlby fullname="TargetAssembly" preserve="all">
</linker>
もちろんクラスを指定することもできます。
その場合はCreateElement("assembly")
の部分を CreateElement("type")
に置き換えることで実現できます。
- クラス、メソッドの保護
クラスやメソッドを単体で保護する場合は、親となるアセンブリやクラスのノードの下に繋いていく必要があります。
ここでは例としてTargetMethod
を保護する部分を紹介します。
XmlNode xmlTypeNode = xmlAssemblyNode.AppendChild(xmlDocument.CreateElement("type"));
XmlAttribute typeFullNameAttribute = xmlDocument.CreateAttribute("fullname");
typeFullNameAttribute.Value = type.FullName; //対象となるメソッドが含まれているTypeDefinition
xmlTypeNode.Attributes.Append(typeFullNameAttribute);
// xmlTypeNodeの子要素としてElementを作成
XmlNode xmlMethodNode = xmlTypeNode.AppendChild(xmlDocument.CreateElement("method"));
XmlAttribute methodNameAttribute = xmlDocument.CreateAttribute("name");
methodNameAttribute.Value = TargetMethod.Name;
xmlMethodNode.Attributes.Append(methodNameAttribute);
パスの返却
return path
で生成したlink.xmlのパスを返却したら適用完了です。
まとめ
今回はlink.xmlの自動生成の解決策の一つとして、IUnityLinkerProcessorを使用したlink.xmlの生成を行いました。
この記事で紹介した手法が何かの参考になれば幸いです。
以上Applibot Advent Calender2023の19日目の記事でした!
明日は @ref3000 さんです!
Discussion