Главная страница   /   5.4. Создание динамических сборок (Метапрограммирование в .NET

Метапрограммирование в .NET

Метапрограммирование в .NET

Кевин Хазард

5.4. Создание динамических сборок

Теперь, когда вы немного погрузились в мир кодов операций, вы готовы к изучению Reflection.Emit. К концу этого раздела вы будете знать, как создать динамическую сборку. Мы вернемся к примеру ToString() из раздела 2.4.2 и реализуем его при помощи Reflection.Emit API, чтобы вы могли увидеть, как все это работает.

Построение динамической версии ToString()

В этом разделе мы пройдем по общим задачам, которые вы будете решать при создании динамического кода с Reflection.Emit. Это сводится к трем этапам:

  • Создать сборку
  • Создать несколько типов
  • Реализовать один или несколько методов для этого типа

Теперь давайте разобьем каждый этап на необходимые шаги.

Создание сборки

Первый строительный блок, который вам нужен, - это динамическая сборка. Технически, это означает, что вы должны создать две вещи: сборку и модуль. О модулях не часто говорят в .NET, но они важны при создании кода с использованием Reflection.Emit. Каждая сборка содержит один или несколько модулей, и вы используете модули, чтобы построить типы. Есть возможность создать многомодульные сборки, но для простоты мы будем придерживаться подхода "один модуль на сборку".

Во всяком случае, давайте рассмотрим кое-какой код. Следующий листинг демонстрирует то, что вам нужно сделать, чтобы начать работу с Reflection.Emit путем создания сборки и модуля. Этот код содержится в классе с именем ReflectionEmitMethodGenerator: в дальнейшем вы увидите, как вызвать его из метода ToString(). Этот класс большой, так что мы пойдем по нему небольшими шагами, начиная с конструктора, который создает динамическую сборку.

Листинг 5-5: Создание динамической сборки и модуля
public sealed class ReflectionEmitMethodGenerator
{
	private AssemblyBuilder Assembly { get; set; }
	private ModuleBuilder Module { get; set; }
	private AssemblyName Name { get; set; }
	public ReflectionEmitMethodGenerator()
		: base()
	{
		this.Name = new AssemblyName()
		{
			Name = Guid.NewGuid().ToString("N")
		};
	this.Assembly = AppDomain.CurrentDomain.DefineDynamicAssembly(
		this.Name, AssemblyBuilderAccess.Run);
	this.Module = this.Assembly.DefineDynamicModule(
		this.Name.Name);

Строки 9-12: Определение имени сборки

Строки 13-14: Создание динамической сборки

Строки 15-16: Создание динамического модуля

Сначала вы определяете имя для сборки при помощи класса AssemblyName. Далее, вы создаете новый объект AssemblyBuilder с этим именем через DefineDynamicAssembly() и объект AppDomain. Наконец, вы создаете свой динамический объект ModuleBuilder, вызвав DefineDynamicModule(). Как отмечалось в разделе 2.2.1, есть много перегруженных вариантов в мире рефлексии, и это также справедливо для пространства имен Reflection.Emit. Не стесняйтесь изучать варианты методов, которые доступны для вас.

Надо также отметить, что существует целый ряд значений для AssemblyBuilderAccess, как RunAndSave, Save и так далее. Использование Run означает, что сборка будет существовать только в памяти и не будет сохранена на диск. Это соответствует вашим потребностям на данный момент; в разделе 5.4.3 вы увидите, когда возможность сохранения сборки на диск позволяет выполнять операции верификации.

Создание типа

В следующем фрагменте кода вы можете увидеть, как создается динамический тип при помощи вызова DefineType() для нашего ModuleBuilder: это дает вам TypeBuilder. Этот метод Generate<T> существует в классе ReflectionEmitMethodGenerator, поэтому он имеет доступ ко всем полям, определенным в листинге 5-5.

public Func<T, string> Generate<T>()
{
	var target = typeof(T);
	var type = this.Module.DefineType(
		target.Namespace + "." + target.Name);

Если вы еще не уловили паттерн, все классы Reflection.Emit заканчиваться словом Builder. Это подтверждает мнение, что вы строите код на лету.

Добавление кодов операций

Наконец, вы подходите к коду, который создает и реализует динамический метод ToString(). Первое, что вам нужно сделать, это создать метод, показанный в следующем фрагменте кода. Этот код, который является продолжением метода из предыдущего фрагмента кода, вызывает DefineMethod() для вашего TypeBuilder, определяя тип и область видимости его аргумента в качестве аргументов:

var method = type.DefineMethod(methodName,
	MethodAttributes.Static | MethodAttributes.Public,
	typeof(string), new Type[] { target });
method.GetILGenerator().Generate(target);

Ваш новый метод является public, static методом, который принимает один аргумент (печатается как Т: объект, который хочет иметь динамическую реализацию ToString()) и возвращает строку.

Не обманывайте себя этой последней строкой кода, хотя тут многое происходит. GetILGenerator() возвращает ILGenerator, и это его вы будете использовать, чтобы определить ваш метод с кодами операций. Как вы можете догадаться, нужно изрядное количество кода для реализации методов на лету, поэтому мы создали метод расширения Generate(), чтобы сделать это для вас. В следующем листинге показана реализация Generate().

Листинг 5-6: Использование ILGenerate для выпуска кода
internal static void Generate(this ILGenerator @this, Type target)
{
	var properties = target.GetProperties(
		BindingFlags.Public | BindingFlags.Instance);
	if(properties.Length > 0)
	{
		var stringBuilderType = typeof(StringBuilder);
		var toStringLocal = @this.DeclareLocal(
			typeof(StringBuilder));
		@this.Emit(OpCodes.Newobj,
			stringBuilderType.GetConstructor(Type.EmptyTypes));
		@this.Emit(OpCodes.Stloc_0);
		@this.Emit(OpCodes.Ldloc_0);
		var appendMethod = stringBuilderType.GetMethod(
			"Append", new Type[] { typeof(string) });
		var toStringMethod = typeof(StringBuilder).GetMethod(
			"ToString", Type.EmptyTypes);
		for(var i = 0; i < properties.Length; i++)
		{
			ToStringILGenerator.CreatePropertyForToString(
				@this, properties[i], appendMethod,
				i < properties.Length - 1);
		}
		@this.Emit(OpCodes.Pop);
		@this.Emit(OpCodes.Ldloc_0);
		@this.Emit(OpCodes.Callvirt, toStringMethod);
	}
	else
	{
		@this.Emit(OpCodes.Ldstr, string.Empty);
	}
	@this.Emit(OpCodes.Ret);
}

Строки 3-4: Получить открытый список свойств

Строки 8-9: Объявить локальный StringBuilder

Строки 10-13: Создать новый StringBuilder

Строки 14-15: Метод Get Append()

Строки 18-23: Вывод значений свойств

Строки 24-26: Вернуть объединенные значения свойств

Как и прежде, вам нужно получить список открытых свойств для целевого объекта. Если такие есть, вы создаете локальную переменную StringBuilder через DeclareLocal(). Затем вы создаете новый StringBuilder через newobj и сохраняете при помощи stloc.0. Каждый раз, когда вы хотите добавить код операции в ваш метод, вы вызываете Emit(). Он имеет много перегруженных вариантов, которые позволяют определить конкретные значения, например, какой конструктор вы хотите вызвать для StringBuilder.

Далее, вы получаете ссылку на метод Append() для StringBuilder. Этот объект MethodInfo используется каждый раз, когда вы хотите указать вызов Append() в IL потоке. Это обрабатывается в CreatePropertyForToString(): мы вернемся к этому методу. После того как вы создали весь код для вывода свойств и их значений, вы очищаете стек через код операции pop. Наконец, вы загружаете локальный StringBuilder, вызываете для него ToString() и возвращаете с кодом операции ret.

Следующий листинг показывает, как в динамический метод добавляется информация о свойстве.

Листинг 5-7: Добавление информации о свойстве в динамический метод
private static void CreatePropertyForToString(ILGenerator generator,
	PropertyInfo property, MethodInfo appendMethod,
	bool needsSeparator)
{
	if(property.CanRead)
	{
		generator.Emit(OpCodes.Ldstr, property.Name + ": ");
		generator.Emit(OpCodes.Callvirt, appendMethod);
		generator.Emit(OpCodes.Ldarg_0);
		var propertyGet = property.GetGetMethod();
		generator.Emit(propertyGet.IsVirtual ?
			OpCodes.Callvirt : OpCodes.Call,
			propertyGet);
		var appendTyped = typeof(StringBuilder).GetMethod("Append",
			new Type[] { propertyGet.ReturnType });
		if(appendTyped.GetParameters()[0].ParameterType !=
			propertyGet.ReturnType)
		{
			if(propertyGet.ReturnType.IsValueType)
			{
				generator.Emit(OpCodes.Box, propertyGet.ReturnType);
			}
		}
		generator.Emit(OpCodes.Callvirt, appendTyped);
		if(needsSeparator)
		{
			generator.Emit(OpCodes.Ldstr, Constants.Separator);
			generator.Emit(OpCodes.Callvirt, appendMethod);
		}
	}
}

Строка 7: Получить имя свойства

Строка 8: Присоединить имя свойства

Строка 9: Загрузить ссылку на объект

Строки 11-13: Корректно вызвать геттер

Строки 16-24: Добавить значение свойства

Строки 25-29: Добавить разделитель значений

Для каждого читаемого свойства, вы получаете его название и вставляете в стек с помощью ldstr. Затем вы добавляете его в StringBuilder, вызывая Append(): это то, для чего нужен код операции callvirt. Теперь вам нужно получить значение свойства. Напомним, что если вы когда-либо вызываете метод для объекта, вы сначала должны вставить объект в стек: вот что делает ldarg.0. Затем вы вызываете геттер через код операции callvirt или call в зависимости от того, является ли метод virtual или нет. Теперь вы вставляете значение свойства в StringBuilder. Выясните, какая перегруженная версия Append() подходит типу свойства (выпуская код операции box, если свойство является типом значения) и используйте callvirt для конкретного вызова Append(). Если вам нужно добавить разделитель между одним значением свойства и названием следующего свойства, вы выпускаете два кода операций: ldstr и callvirt.

Вызов нового метода

Теперь, когда реализация метода осуществлена, вам придется "приготовить" TypeBuilder, чтобы сделать его полноценным типом, а затем получить метод, который вы создали с новым типом. В следующем фрагменте кода (который заканчивает определение класса из листинга 5-3 и фрагменты кода из подразделов "Создание типа" и "Добавление кодов операций") показывает, как это сделать:

var createdType = type.CreateType();
var createdMethod = createdType.GetMethod(methodName);
return (Func<T, string>)Delegate.CreateDelegate(
	typeof(Func<T, string>), createdMethod);
	}
}

Вы вызываете CreateType(), а затем находите метод с помощью знакомого вызова GetMethod(). Последняя строка кода может показаться немного странной, для чего нужен этот вызов Delegate.CreateDelegate()? Это позволяет вам использовать созданный метод как Func<T, string>, что несколько легче, чем попытка использовать MethodInfo. Кроме того, это дает вам простой способ кэширования метода для будущих вызовов. Следующий листинг закрывает обсуждение и показывает метод расширения, который создает динамический код и кэширует полученный Func<T, string>.

Листинг 5-8: Метод расширения для создания динамического метода
public static class ToStringViaReflectionEmitExtensions
{
	private static Lazy<ReflectionEmitMethodGenerator> generator =
		new Lazy<ReflectionEmitMethodGenerator>();
	private static Dictionary<Type, Delegate> methods =
		new Dictionary<Type, Delegate>();
	internal static string ToStringReflectionEmit<T>(this T @this)
	{
		var targetType = @this.GetType();
		if(!ToStringViaReflectionEmitExtensions.methods.ContainsKey(
			targetType))
		{
			ToStringViaReflectionEmitExtensions.methods.Add(
				targetType,
				ToStringViaReflectionEmitExtensions.generator
					.Value.Generate<T>());
		}
		return (ToStringViaReflectionEmitExtensions.methods[
		targetType] as Func<T, string>)(@this);
	}
}

На данный момент, все на месте. Для динамической реализации метода ToString(), все что вам нужно сделать, заключается в следующем:

public override string ToString()
{
	return this.ToStringReflectionEmit();
}

Вот и все! Вы создали динамический код на лету, который адаптивен к любой структуре объекта. Хотя для этого нужно писать и немалое количество кода, как только он будет у вас под рукой, все, что вам нужно будет сделать – это один вызов метода для создания богатого и краткого описания любого объекта при помощи кода, который будет быстро выполняться.

Из-за такой близости от CLR это все имеет свои подводные камни. Можно легко создать неправильный код, который будет вести себя странно или вызывать исключения. Давайте посмотрим, как вы можете отладить код, чтобы справиться с первой ловушкой.

Поддержка отладки

Одним из замечательных свойств Reflection.Emit является то, что он может создавать отладочную информацию на лету, что позволяет войти в отладчик и посмотреть, как работает ваш динамический код. Примеры кода для этой книги имеют модифицированную версию ReflectionEmitMethodGenerator, которая называется ReflectionEmitWithDebuggingMethodGenerator; единственное различие между двумя этими классами заключается в том, что отладка включается во втором. Давайте сосредоточимся на этих различиях, чтобы вы могли видеть то, что требуется для того, чтобы создать необходимые данные отладки.

Первое, что вам нужно сделать, это добавить атрибут к динамической сборке, чтобы CLR знал, что сборка была создана с отладочными символами. Следующий листинг содержит этот код: он создает DebuggableAttribute с необходимыми значениями и добавляет его к новой сборке.

Листинг 5-9: Добавление атрибута по отладке к динамической сборке
private void AddDebuggingAttribute(AssemblyBuilder assembly)
{
	var debugAttribute = typeof(DebuggableAttribute);
	var debugConstructor = debugAttribute.GetConstructor(
		new Type[] { typeof(DebuggableAttribute.DebuggingModes) });
	var debugBuilder = new CustomAttributeBuilder(
		debugConstructor, new object[] {
			DebuggableAttribute.DebuggingModes.DisableOptimizations |
			DebuggableAttribute.DebuggingModes.Default });
	assembly.SetCustomAttribute(debugBuilder);
}

Примечание

Вы можете больше узнать о технических причинах того, почему вам нужен этот атрибут, на http://mng.bz/18Q7 и http://mng.bz/aSg7.

Теперь вам нужно создать модуль, который нужен для создания отладочной информации. Это так же просто, как вызов другой версии DefineDynamicModule():

this.Module = this.Assembly.DefineDynamicModule(
	this.Name.Name + ".dll", true);

Ключевой частью является второй параметр, который представляет собой булево значение. Передавая true, вы говорите ModuleBuilder добавлять символьную информацию.

Далее, скажите ModuleBuilder из файлов, которые вы будете использовать, связать символьную информацию об отладке. Эти файлы являются файлами кода, например, файлы .cs. К сожалению, нет ничего в Reflection.Emit, что создаст для вас .il файл, который соответствует созданным кодам операций, поэтому вам придется справиться с этим вручную. Это не так сложно, как кажется: скоро вы увидите, как сделать это на лету. А пока, вот как вы создадите динамический документ:

var fileName = target.Name + "ToString.il";
var document = this.Module.DefineDocument(fileName,
	SymDocumentType.Text, SymLanguageType.ILAssembly,
	SymLanguageVendor.Microsoft);

Это не создает файл, вы даете вашему модулю знать, что вы собираетесь использовать этот файл для отладки. Три перечисленных значения имеют различные варианты в зависимости от кода, который вы создали, или вы вызываете перегруженный вариант DefineDocument(), который принимает имя файла, если вы не обращаете внимания на эти параметры.

Теперь начинается самое интересное. Вам нужно выровнять конкретные области в кодовом файле с кодами операций, которые вы создаете в вашем методе. В этом примере мы создадим этот файл и передадим его вспомогательному методу Generate():

using(var file = File.CreateText(fileName))
{
	method.GetILGenerator().Generate(target, document, file);
}

Есть три вещи, которые вы можете сделать с методами и отладкой. Во-первых, вы можете добавить описательную информацию к параметрам, например, имя. Вы делаете это, вызывая DefineParameter() для вашего MethodBuilder:

method.DefineParameter(1, ParameterAttributes.In, "target");

Вторая вещь – это добавление описательной информации для локальных переменных. Сделайте это, вызвав SetLocalSymInfo() для LocalBuilder, который вы получите после вызова DeclareLocal():

toStringLocal.SetLocalSymInfo("builder");

Третья вещь довольно интересна. Вы можете отметить точки в файле кода, чтобы они соответствовали подходящему набору кодов операций. Вы делаете это, вызывая MarkSequencePoint() для ILGenerator. Опять же, .il файла при создании кода на лету с помощью Reflection.Emit нет, но довольно легко создать псевдо-.il файл, основываясь на кодах операций, которые вы выпускаете. Следующий листинг показывает метод расширения, который обрабатывает необходимые точки. Это метод расширения для тех кодов операций, которые делают что-то со строками (подобно ldstr); в коде из примера есть другие методы расширения для обработки кодов операций, которые вызывают методы и используют типы.

Листинг 5-10: Выделение нужных частей в файле с динамическим кодом
internal static void Emit(this ILGenerator @this,
	OpCode opcode, string value,
	ISymbolDocumentWriter document,
	StreamWriter file, int lineNumber)
{
	var line = opcode.Name + " \"" + value + "\"";
	file.WriteLine(line);
	@this.MarkSequencePoint(document, lineNumber,
		1, lineNumber, line.Length + 1);
	@this.Emit(opcode, value);
}

Вы создаете строку IL и добавляете ее в текстовый файл. Затем, вы сообщаете ILGenerator, что отладчик должен выделить эту новую строку кода код для любых следующих кодов операций, добавленных вызовом Emit(), при помощи MarkSequencePoint().

После того как вы выпустили весь контент отладки, у вас есть возможность войти в сгенерированный код, чтобы увидеть, как он работает. Рисунок 5-5 является скриншотом Visual Studio, где загружен новый .il файл.

Рисунок 5-5: Отладка динамического кода в .NET. При создании IL файла на лету с информацией об отладке вы можете просмотреть новый созданный код.

Вы видите, что код выделен правильно. Вы также можете увидеть, что имя аргумента и локальная переменная также отображаются. Такая возможность отладки IL – это чрезвычайно мощно. Чем больше вы будете использовать Reflection.Emit, тем больше вы будете благодарны, что вы можете сделать что-то вроде этого.

Отладка – это не единственный инструмент, который можно использовать, чтобы убедиться, что код правильный. В следующем разделе вы увидите, как верификация также пригодна при работе с динамическим кодом.

Верификация результатов

Одно дело иметь логическую ошибку в коде, такую как инкремент значения вместо декремента. Это может привести к забавным ошибкам, но в целом их не слишком трудно отследить. А что же произойдет, если вы создадите набор кодов операций, которые приведут к реализации метода, не имеющего никакого смысла? Допустим, у вас есть следующие два кода операций в статическом методе, который не принимает аргументов и возвращает строку:

ldstr "Some value"
ret

Это имеет смысл. Строка помещается в стек, а затем она извлекается оттуда, как только получает возвращаемое значение. Но что произойдет, если вы забыли код операции ldstr? Теперь все, что у вас есть, только это:

ret

Что делает ваш код в этом случае? Классы Reflection.Emit с удовольствием создадут метод, как этот, но при попытке выполнить этот метод вы получите InvalidProgramException. Иногда вы можете получить действительно странные результаты, в зависимости от того, насколько неправильна реализация метода. Независимо от того, каковы результаты, вы действительно захотите узнать, что ваш код неверен.

В .NET есть инструмент под названием peverify.exe, который может верифицировать метаданные и реализацию кода в сборке. Это утилита командной строки, которая может принимать многое число аргументов, хотя вы обычно будете использовать два: /md, который говорит peverify искать ошибки метаданных, и /il, который просит peverify найти проблемы c реализацией. peverify не можете найти логические ошибки в коде, но он может обнаружить ту противную ошибку IL, которую вы видели ранее в этом разделе. Рисунок 5-6 показывает, как выглядят результаты peverify. Вы видите, что он сообщает об ошибке IL: вот конкретный текст ошибки - Return value missing on the stack (Возвращаемое значение отсутствует в стеке).

Рисунок 5-6: Использование peverify для сборки с ошибками. Любая ошибка в метаданных или кодах операций будет представлена проанализированной.

Проблема с peverify заключается в том, что это инструмент командной строки. Нельзя ссылаться на сборку peverify.dll в коде и сказать ей, чтобы она проверила сборку, созданную вами. Но мы создали сборку, которая выполняет peverify.exe для вас за кулисами. Она называется AssemblyVerifier, и все, что вам нужно сделать, это сослаться на эту сборку и добавить следующие строки кода:

assembly.Save(name.Name + ".dll");
AssemblyVerification.Verify(assembly);

Вы должны сохранить динамическую сборку на диск, чтобы это работало, также вы должны убедиться, что вы определяете динамическую сборку со значением AssemblyBuilderAccess.RunAndSave. Если peverify находит проблемы в вашем коде, AssemblyVerification разбирает консольные выходные данные и преобразует информацию в VerificationException, где вы можете увидеть все ошибки в свойстве Errors.

Примечание

Вы можете получить AssemblyVerifier через NuGet. Чтобы найти исходный код, посетите http://assemblyverifier.codeplex.com.

Прежде чем мы перейдем к другой опции динамической генерации кода, давайте рассмотрим способы, которые помогут упростить для вас использование Emit API.

Упрощение работы при помощи ILDasm

Если вы не пишете .il файлы день за днем или вы постоянно не работаете с Reflection.Emit API, использование кодов операций не будет вашей сильной стороной. Хотя мы любим метапрограммирование и генерацию кода, создание кодов операций может быть сложным, даже если у вас это хорошо получается. Но в действительности у вас это и не обязано хорошо получаться: вы должны знать, как обойти этот момент.

Вспомните раздел 2.4.2, где у нас была жестко закодированная версия, созданная для ToString() в C#. Чтобы эмитировать этот код в Reflection.Emit, вы можете использовать инструменты, которые уже находятся в вашем распоряжении. Во-первых, скомпилируйте код, как вы это обычно делаете. Затем загрузите сборку с ILDasm и перейдите в код, который вы хотели бы воспроизвести. Например, это то, как выглядит часть IL для C# версии ToString():

IL_0000: newobj instance void
	[mscorlib]System.Text.StringBuilder::.ctor()
IL_0005: ldstr "Age: "
IL_000a: call instance class
	[mscorlib]System.Text.StringBuilder
	[mscorlib]System.Text.StringBuilder::Append(string)
IL_000f: ldarg.0
IL_0010: call instance int32 Customers.Customer::get_Age()
IL_0015: callvirt instance class
	[mscorlib]System.Text.StringBuilder
	[mscorlib]System.Text.StringBuilder::Append(int32)
IL_001a: ldstr " || "
IL_001f: callvirt instance class
	[mscorlib]System.Text.StringBuilder
	[mscorlib]System.Text.StringBuilder::Append(string)

В листингах 5-6 и 5-7 вы должны были увидеть сходство. Это потому что мы основывали наш код на IL, который мы видели в ILDasm. Гораздо легче реализовать то, что вы хотите сделать, на таком языке, как C# или VB. Если «украсть» то, что компиляторы уже создали для вас, вы можете свести к минимуму количество времени, которое требуется, чтобы написать проверяемый динамический код, который работает так, как вы ожидаете.

Примечание

Иногда компилятор будет следовать целям отладки, когда он хочет выровнять точки останова в коде. Необходимости воспроизводить это в коде Reflection.Emit нет: можно спокойно игнорировать это, когда вы следуете такой технике «обмана».

Теперь вы знаете, как создавать код на лету с классами в Reflection.Emit. Но это не единственный доступный вариант. Следующий раздел описывает еще один динамический вариант, который имеет определенные преимущества перед Reflection.Emit.

Создание динамических прокси

Если вы хотите глубже изучить то, что может обеспечить Emit API, пожалуйста, ознакомьтесь с проектами DynamicProxies (http://dynamicproxies.codeplex.com) и EmitDebugger (http://emitdebugger.codeplex.com). EmitDebugger оборачивает классы Emit для автоматического создания IL файла с контрольными точками для динамического кода. DynamicProxies создает прокси-классы на лету, так что вы можете перехватывать вызовы виртуальных методов для классов (посетите http://en.wikipedia.org/wiki/Proxy_pattern для получения дополнительной информации о прокси паттерне).

Как вы можете себе представить, это не простая вещь, потому что вам нужно следить за рядом вещей, например, аргументами по ссылке, дженериками и реализацией интерфейса для класса.

Обратите внимание, что DynamicProxies использует EmitDebugger, чтобы дать вам возможность отладки прокси-классов: вполне хорошая функциональная возможность.