Главная страница   /   7.2. Создание фреймворка внедрения (Метапрограммирование в .NET

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

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

Кевин Хазард

7.2. Создание фреймворка внедрения

Это может звучать привлекательной, мощной идеей – иметь возможность реструктурировать код, как только компилятор заканчивает работу. Но здесь есть одна проблема. У .NET Framework нет возможности поддерживать это начинание. Таким образом, вам самим необходимо придумать способ. Хотя это и не легко, у фреймворка Cecil есть необходимая функциональность. В этом разделе рассматривается Cecil и показывается, как вы можете использовать его, чтобы переписать сборку.

Что такое Cecil?

В этой книге вы видели убедительные примеры метапрограммирования, где используются различные классы .NET. Но рефлексия в .NET не обладает достаточной мощью, чтобы изменить существующий код. И вот почему. Во-первых, рефлексия считывает содержимое сборки и позволяет вызывать членов сборки. Но вы не можете изменить содержание метода или класса – вы можете использовать его только как есть. Emitter API подпускает вас на шаг ближе, давая вам возможность создавать код на лету, но вы не можете редактировать существующий код в сборках. Что вам нужно – это комбинация обоих подходов: читать содержимое сборки и модифицировать ее. К счастью, есть еще один способ осуществить это.

Cecil – это сборка, которая является частью проекта Mono (www.mono-project.com). Она позволяет читать и писать сборки и отлаживать файлы. Вы можете получить исходный код для Cecil на www.mono-project.com/Cecil. Cecil также доступна для NuGet, поэтому вы можете легко ссылаться на него в своих проектах. Cecil используется на ряде проектов, связанных с Mono, например, Gendarme (www.mono-project.com/Gendarme), инструменте статического анализа.

В следующем мы проникнем в глубь Cecil API, чтобы посмотреть, как Cecil меняет код.

Модификация кода при помощи Cecil

Чтобы рассмотреть, как работает Cecil, мы собираемся использовать проект Injectors, который был создан одним из авторов (Джейсоном). Вы можете найти исходный код на http://injectors.codeplex.com. Этот проект использует Cecil для прочтения содержимого сборки и изменения определенных частей на основе существования метаданных. После того как все изменения сделаны, сборка сохраняется на диск. На рисунке 7-1 показан процесс того, как Injectors меняет сборку.

Рисунок 7-1: Модификация сборки при помощи фреймворка Injectors. Все изменения сохраняются обратно в ту же сборку.

Поскольку Cecil не является сборкой, которая поставляется с .NET, вам придется потратить время на знакомство с новым API. Но как скоро выяснится, Cecil окажется не слишком трудной, как только вы сделаете несколько первоначальных шагов. Давайте начнем с рассмотрения того, как сборки загружаются и сохраняются в Cecil.

Примечание

Было бы несколько проще, если бы мы могли указать вам на API, который уже существует в .NET и обрабатывает парсинг сборки и переписывание кода, но мы не можем. К счастью, у сообщества разработчиков .NET есть ряд вариантов, которые можно выбрать, чтобы сделать это. Мы выбрали Cecil для этой книги, но мы настоятельно рекомендуем вам посмотреть на CCI (http://ccimetadata.codeplex.com), IKVM (www.ikvm.net) и Tao (https://github.com/philiplaureano/Tao).

Загрузка и сохранение сборок при помощи Cecil

Чтобы запустить любой код, который изменит содержимое сборки, сначала нужно загрузить целевую сборку, изменить ее и сохранить изменения на диск. Это довольно легко сделать в Cecil:

public static class InjectorRunner
{
	public static void Run(FileSystemInfo assemblyLocation)
	{
		var assembly = AssemblyDefinition.ReadAssembly(
			assemblyLocation.FullName);
		assembly.Inject();
		assembly.Write(assemblyLocation.FullName);
	}
}

Класс AssemblyDefinition предоставляет метод ReadAssembly(), который можно использовать для загрузки содержимого этой сборки. Метод Inject() является методом расширения, предоставляемым фреймворком Injectors: вы будете видеть, как он работает, в следующем разделе. Как только изменения сделаны, вы называете Write() для этой сборки.

Как вы видите, загрузка и сохранение сборок в Cecil очень просты. Задача состоит в изменении содержания. Вы увидите, как это сделать, в следующем разделе.

Посещение содержимого сборки

Cecil не обеспечивает механизм для посещения всего содержимого сборки, так что вы должны создать свой собственный. У фреймворка Injectors есть ряд методов расширения для посещения конкретного члена и для внедрения его с изменениями кода, если это необходимо. Вот как выглядит метод Inject() для AssemblyDefinition:

internal static class AssemblyDefinitionExtensions
{
	internal static void Inject(
		this AssemblyDefinition @this)
	{
		@this.RunInjectors();
		foreach(var module in @this.Modules)
		{
			module.Inject();
		}
	}
}

Метод расширения RunInjectors() исследует текущий элемент, чтобы увидеть, отмечен ли он любыми атрибутами внедрения, которые внесут изменения в сборку. Вы увидите определение InjectorAttribute в разделе "Выполнение инжекторов". Ключевым моментом является то, что как только AssemblyDefinition было исследовано, во все модули в сборке происходит внедрение. В каждом модуле внедрение проходит во все типы и так далее, вплоть до параметров в методе. Все остальные методы расширения для других членов следуют этому паттерну.

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

Определение InjectorAttribute

В языках, как C# и VB, вы можете определить атрибуты, которые наследуются от класса Attribute. Но вы не можете сделать ваши пользовательские атрибуты дженериками. Вы не можете написать такой код:

public sealed class MyCustomAttribute<T> : Attribute

Это чисто языковое ограничение. CLR поддерживает дженерик атрибуты, но C# и VB этого не разрешают – так же как вы можете перегрузить методы возвращаемым типом только в IL, но не в C# и VB. Это было бы выгодно, потому что вы могли бы сделать пользовательский дженерик атрибут, который мог бы быть использован для любого члена на основе Cecil, например, TypeDefinition или MethodDefinition. Но если вы готовы написать немного IL, вы можете определить дженерик атрибут.

Это не так сложно, как вы думаете. Хитрость заключается в том, чтобы написать свой атрибут на вашем любимом .NET языке, а затем превратить его в IL. Начните с написания атрибута:

public class InjectorAttribute : Attribute
{
	public InjectorAttribute : base() { }
	public void Inject(object target)
	{
		if(target == null)
		{
			throw new ArgumentNullException("target");
		}
		this.OnInject(target);
	}
	protected abstract void Inject(object target);
}

Затем скомпилируйте код, откройте сборку в ILDasm и сбросьте содержимое в текстовый файл. В текстовом файле вы затем можете сделать атрибут дженериком. Изменения нужно сделать в трех местах. Первым является определение класса:

.class public abstract auto ansi beforefieldinit
	Injectors.Core.Attributes.Generic.InjectorAttribute`1<class T>
		extends [mscorlib]System.Attribute

Теперь, когда класс является дженериком, вы можете использовать T в Inject() и OnInject():

.method public hidebysig instance void
	Inject(!T target) cil managed
.method family hidebysig newslot abstract virtual
	instance void OnInject(!T target) cil managed { }

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

Выполнение инжекторов

В разделе "Посещение содержимого сборки", вы видели, как RunInjectors() вызывается в методе расширения Inject(). Теперь, когда пользовательский атрибут определен, вы можете увидеть, как эти атрибуты обрабатываются:

internal static void RunInjectors<T>(this T @this)
	where T : class, ICustomAttributeProvider
{
	var injectors = @this.GetInjectors();
	foreach(var injector in injectors)
	{
		injector.Inject(@this);
	}
}

В Cecil каждый класс определения (как AssemblyDefinition и MethodReturnTypeExtensions) реализует интерфейс ICustomAttributeProvider, который определяет свойство CustomAttributes. Это коллекция атрибутов для данного члена, и это то, что вы должны использовать, чтобы найти атрибуты на основе InjectorAttribute. Но InjectorAttribute не имеет дженерик ограничений по Т для ICustomAttributeProvider, вот почему ограничение сделано здесь.

GetInjectors() возвращает список объектов, основанных на InjectorAttribute. Следующий листинг показывает, что делает этот метод.

Листинг 7-2: Получение списка инжекторов от элемента
internal static ReadOnlyCollection<InjectorAttribute<T>> GetInjectors<T>(this T @this)
	where T : class, ICustomAttributeProvider
{
	var injectors = new List<InjectorAttribute<T>>();
	foreach(var attribute in @this.CustomAttributes)
	{
		var baseAttributeType =
			attribute.AttributeType.Resolve().BaseType.Resolve();
		while(baseAttributeType != null &&
			baseAttributeType.BaseType != null)
		{
			if(baseAttributeType.FullName == ICustomAttributeProviderExtensions.baseFullName 
				&& 
				baseAttributeType.Scope.Name == ICustomAttributeProviderExtensions.baseScopeName)
				{
					var injectorAttribute = attribute.Create<InjectorAttribute<T>>();
					injectors.Add(injectorAttribute);
					break;
				}
			baseAttributeType = baseAttributeType.BaseType.Resolve();
		}
	}
	return injectors.AsReadOnly();
}

В Cecil нет метода IsAssignableFrom, как вы получали в Reflection API. Вы хотели бы находить конкретные атрибуты в свойстве CustomAttribute, которые наследуются от InjectorAttribute<T>. Но вам нужно писать цикл while и смотреть на BaseType, чтобы видеть, совпадает ли он с именем для InjectorAttribute<T>. Если он совпадает, вы создаете экземпляр атрибута и добавляете его в коллекцию. Метод расширения Create() представлен в следующем листинге.

Листинг 7-3: Создание атрибута в Cecil
internal static T Create<T>(this CustomAttribute @this)
	where T : class
{
	var type = @this.AttributeType.Resolve();
	var attributeTypeName = type.FullName + ", " +
		type.Module.Assembly.Name.Name;
	var attributeType = Type.GetType(attributeTypeName);
	object[] arguments = null;
	if(@this.HasConstructorArguments)
	{
		arguments = new object[@this.ConstructorArguments.Count];
		for(var i = 0; i < @this.ConstructorArguments.Count; i++)
		{
			arguments[i] = @this.ConstructorArguments[i].Value;
		}
	}
	T value = Activator.CreateInstance(attributeType, arguments) as T;
	if(@this.HasProperties)
	{
		foreach(var attributeProperty in @this.Properties)
		{
			attributeType.GetProperty(attributeProperty.Name)
				.SetValue(value,
			attributeProperty.Argument.Value, null);
		}
	}
	return value;
}

Строки 9-16: Получение аргументов конструктора

Строки 17-18: Создание атрибута

Строки 19-27: Установка свойств атрибута

Это не самая легкая вещь в мире – создать объект атрибута – вы не можете использовать Activator.CreateInstance(). Первое, что нужно сделать, это найти аргументы конструктора, если таковые существуют. Эти значения должны быть переданы CreateInstance(), чтобы убедиться, что вы создаете объекты в правильном состоянии. Кроме того, необходимо установить любые значения свойства, если атрибут был определен с использованием именованных свойств. Каждое из этих значений должно быть установлено через вызов GetProperty(). Как только атрибут объекта будет в правильном состоянии с правильными данными, он возвращается, где метод Inject() вызывается в RunInjectors().

На данный момент вы видели основную архитектуру инжекторов. Хотя некоторые из Cecil API могут быть незнакомы для вас, если вы использовали Reflection API, вы увидите немало аналогий между классами с точки зрения их логического использования (например, MethodInfo и MethodDefinition). Чтобы закрыть дискуссию, давайте рассмотрим простой инжектор, чтобы увидеть, как можно добавить проверку на null в аргументированные методы.

Создание NotNullAttribute

Проверка аргументов метода на основе ссылки, чтобы увидеть, являются ли они null, - это общая идиома в .NET. Хороший код должен выбросить ArgumentNullException, если аргумент был предоставлен как null, а не ждать, чтобы .NET выбросил NullReferenceException для вас, если вы пытаетесь использовать его. ArgumentNullException может обеспечить дополнительную информацию (например, имя параметра), тогда как NullReferenceException вообще не связано с параметром.

Теперь, создание проверки на null включает в себя простой, стандартный код:

public void AMethod(string value)
{
	if(value == null)
	{
		throw new ArgumentNullException("value");
	}
}

Как вы можете видеть, это довольно легко сделать. Дело в том, что это простой, но утомительный код. Вы должны писать блок if, который вызывает исключение с правильным именем параметра, каждый раз. Было бы гораздо легче, если бы вы могли позволить чему-то еще, например, инжектору, писать это для вас. Следующий листинг демонстрирует NotNullAttribute и то, как он внедряет IL для обработки этой проверки.

Листинг 7-4: Добавление проверки на null
[AttributeUsage(AttributeTargets.Parameter,
	AllowMultiple = false, Inherited = true)]
[Serializable]
public sealed class NotNullAttribute :
	InjectorAttribute<ParameterDefinition>
{
	protected override void OnInject(ParameterDefinition target)
	{
		if(!target.ParameterType.IsValueType)
		{
			var method = (target.Method as MethodDefinition);
			var argumentNullExceptionCtor =
				method.DeclaringType.Module.Assembly.MainModule.Import(
			typeof(ArgumentNullException).GetConstructor(
				new Type[] { typeof(string) }));
			var processor = method.Body.GetILProcessor();
			var first = processor.Body.Instructions[0];
			processor.InsertBefore(first,
				processor.Create(OpCodes.Ldarg, target));
			processor.InsertBefore(first,
				processor.Create(OpCodes.Brtrue_S, first));
			processor.InsertBefore(first,
				processor.Create(OpCodes.Ldstr, target.Name));
			processor.InsertBefore(first,
				processor.Create(OpCodes.Newobj,
				argumentNullExceptionCtor));
			processor.InsertBefore(first,
				processor.Create(OpCodes.Throw));
		}
	}
}

Строка 9: Проверка типа параметра

Строки 11-15: Получение ссылки к конструктору ArgumentNullException

Строки 16-17: Получение списка инструкций

Строки 18-28: Добавление кодов операций для проверки на null

Первое, что вам нужно сделать, это убедиться, что параметр атрибута является ссылочным типом, а не значимым, через свойство IsValueType, потому что нет никакого способа узнать через атрибут, что он может быть тем или другим. Это валидный код:

public void AMethod([NotNull] string value1, [NotNull] Guid value2)

Нет причин выпускать код для value2, потому что оно всегда будет не-null.

Когда вы знаете, что параметр является ссылочным типом, вы можете начать выпускать проверки на null. Вам нужно получить ссылку на конструктор ArgumentNullException, который принимает один аргумент, потому что вы будете использовать это в IL, который вы будете выпускать, и для этого нужен вызов Import(). Далее вы получаете ссылку ILProcessor, которая похожа на класс ILGenerator в Reflection.Emit, и сюда можно добавить коды операций для метода. Но она более гибкая, поскольку вы можете выбрать инструкцию и добавить новые инструкции до или после нее, что невозможно с ILGenerator. В данном случае вы должны убедиться, что проверка на null происходит перед выполнением любого другого кода в методе, поэтому вы находите первую инструкцию и вставляете перед ней все коды операций.

Пять кодов операций, которые вы используете, работают следующим образом:

  • Ldarg: вставить целевой параметр в стек.
  • Brtrue_S: если он не null, перейти к остальной части кода в методе.
  • Ldstr: вставить имя параметра в стек.
  • Newobj: создать новое ArgumentNullException, которое будет использовать параметр в стеке для его построения.
  • Throw: выбросить исключение.

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

Атрибуты ничего не делают сами по себе. Вам нужно что-то, чтобы дать толчок процессу внедрения для сборки. В следующем разделе вы увидите, как вы можете сделать так, чтобы Injectors выполнялся в ваших проектах через пользовательскую задачу MSBuild.

Создание задачи MSBuild

MSBuild является платформой на основе XML, которая используется для организации многочисленных задач, происходящих в процессе сборки. Есть ряд предопределенных задач, которые можно использовать в файле MSBuild, таких как <Exec> (для запуска исполняемого). Вы также можете создавать свои собственные пользовательские задачи. Интересно, что проектные файлы Visual Studio для C# и VB также используют формат MSBuild, что делает его идеальным для создания пользовательской задачи внедрения, которую вы могли бы использовать, чтобы добавить модификации сборки после построения в проект VS. Рисунок 7-2 иллюстрирует, где задача вступает в игру в файле MSBuild.

Рисунок 7-2: В какой-то момент в файле MSBuild запускается компилятор. Как только задача завершается, запускается InjectorTask, чтобы изменить новую сборку через Injectors.

Сначала создайте пользовательскую задачу. Как показано в следующем листинге, это довольно просто.

Листинг 7-5: Создание пользовательской задачи MSBuild для внедрения
using Injectors.Core;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using System.Diagnostics;
using System.IO;
namespace Injectors.Task
{
	public sealed class InjectorTask : AppDomainIsolatedTask
	{
		public override bool Execute()
		{
			Log.LogMessage("Injecting assembly {0}...",
				this.AssemblyLocation);
			var stopwatch = Stopwatch.StartNew();
			InjectorRunner.Run(new FileInfo(this.AssemblyLocation));
			stopwatch.Stop();
			Log.LogMessage(
				"Assembly injection for {0} complete - total time: {1}.",
				this.AssemblyLocation, stopwatch.Elapsed.ToString());
			return true;
		}
		[Required]
		public string AssemblyLocation { get; set; }
	}
}

Чтобы создать пользовательскую задачу, вам нужно, чтобы класс наследовался от ITask. Вспомогательный класс AppDomainIsolatedTask сделает это для вас; единственный метод, который вам нужно обработать, это Execute() – именно здесь вы определяете логику пользовательской задачи. Обратите внимание, что вы также можете создавать свойства, такие как AssemblyLocation, которые пользователи задачи могут установить для указания частей информации, которые будут необходимы вашей задаче (вы вскоре увидите, как использовать эту задачу в файле MSBuild).

Примечание

Вам нужно ссылаться на сборки Microsoft.Build.Framework и Microsoft.Build.Utilities.v4.0 для создания пользовательских задач.

Как вы можете видеть в InjectorTask, все, что делает Execute() – это вызывает Run() для InjectorRunner, вставляя выражения отладки до и после вызова. Добавьте эту задачу в файл MSBuild, и он ее правильно выполнит:

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build"
	xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
	<UsingTask TaskName="Injectors.Task.InjectorTask"
		AssemblyFile="Injectors.Task.dll"/>
	<!-- Other build elements go here... -->
</Project>

Элемент <UsingTask> позволяет указать сборку, которая содержит пользовательскую MSBuild задачу. После добавления этого элемента вы можете ссылаться на задачу из любого места файла MSBuild:

<InjectorTask AssemblyLocation="Injectors.SampleTarget.exe" />

Установите свойство AssemblyLocation в элементе <InjectorTask>, который будет выполнен до вызова Execute() для пользовательской задачи.

Установить это при помощи простого MSBuild файла довольно легко. Поскольку проектные файлы C# и VB являются файлами MSBuild, вы можете добавить эту пользовательскую задачу внедрения в ваши собственные проекты. Это можно сделать, либо отредактировав файл проекта вручную вне VS, или выгрузить проект в VS и сделать это там. Для редактирования файла в VS щелкните правой кнопкой мыши по проекту и выберите в контекстном меню Unload Project. Затем снова щелкните правой кнопкой мыши по проекту и выберите Edit {project name}.{project file}. Это покажет содержимое файла в VS. (Помните, что нужно щелкнуть правой кнопкой мыши и выбрать Reload Project, чтобы проект снова был загружен в решение). Какой бы подход вы не использовали, чтобы отредактировать файл проекта, вы найдете этот раздел XML:

<Target Name="AfterBuild">
</Target>

Вставьте элемент <InjectorTask> в элемент <Target>. Если вы не можете найти его в файле проекта, добавьте элемент в элемент <Project>. Кроме того, этот элемент может быть обернут в XML комментарий, поэтому сначала удалите комментарий. Как только вы это сделаете, модификации кода произойдут после построения проекта.

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