Главная страница   /   10.2. Понимание основ Roslyn (Метапрограммирование в .NET

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

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

Кевин Хазард

10.2. Понимание основ Roslyn

Начнем наше путешествие по работе Roslyn с создания исполняемых фрагментов кода во время выполнения. Это будет сделано двумя способами: сначала вы используете скриптовый движок, а затем вы увидите, как это делается путем компиляции и выполнения кода.

Запуск кода при помощи скриптового движка

Один из самых распространенных примеров в этой книге касался создания динамического кода для реализации ToString(). Результатом является набор пар «имя-значение» свойства, разделенные ||, и это вы видите на рисунке 10-4. Давайте сделаем то же самое при помощи Roslyn. На данный момент мы не будем касаться кэширования или других улучшений производительности. Это CTP, и пытаться взглянуть на полученные цифры производительности кажется в лучшем случае подозрительным.

Давайте начнем с определения метода расширения, который будет использован.

public static class ToStringViaRoslynExtensions
{
	public sealed class Host<T>
	{
		public Host(T target)
		{
			this.Target = target;
		}
		public T Target { get; private set; }
	}
	public static string Generate<T>(this T @this)
	{

Вы увидите определение Generate(), но обратите внимание, что есть также класс Host, который имеет свойство только для чтения того же типа, что и @this. Это необходимо для скриптинга среды, поэтому он может ссылаться на объект, который вы получили в @this.

Давайте посмотрим на код, который сгенерирован для создания выразительного описания объекта из ToString():

var code = "new StringBuilder()" +
	string.Join(".Append(\" || \")",
		from property in @this.GetType().GetProperties(
			BindingFlags.Instance | BindingFlags.Public)
		where property.CanRead
		select string.Format(
		".Append(\"{0}: \").Append(Target.{0})",
			property.Name)) + ".ToString()";

Этот код рефлексии похож на то, что вы видели в других примерах, которые обрабатывают ToString() на лету. Разница в том, что вы создаете C# код как выходные данные, а не что-то вроде IL в динамическом методе. Что попадает в переменную code, выглядит примерно так:

new StringBuilder().Append("Age: ").Append(Target.Age).Append(" || ")
	.Append("Name: ").Append(Target.Name).ToString()

Последний кусок вводит в игру скриптинговый движок Roslyn, чтобы выполнить код:

var hostReference =
		new AssemblyFileReference(typeof(
			ToStringViaRoslynExtensions).Assembly.Location);
	var engine = new ScriptEngine(
		references: new[] { hostReference },
		importedNamespaces: new[] { "System", "System.Text" });
	var host = new Host<T>(@this);
	var session = Session.Create(host);
	return engine.Execute<string>(code, session);
	}
}

Строки 1-3: Получить ссылку на хост

Строки 4-6: Создать скриптинговый движок

Строки 7-8: Создать сессию

Строка 9: Выполнить код

Прежде всего, необходимо получить AssemblyFileReference на сборку, которая содержит тип Host, так чтобы скриптовый движок знал, что обозначает Target в коде, который вы ему даете. Затем создайте объект ScriptEngine, передавая ему ссылки на сборки, о которых он должен знать, а также любые пространства имен. Вы используете StringBuilder, вот почему в движок передается System.Text. Также необходим объект Session, так как нужно передать хостовый объект, который код может использовать, а именно экземпляр Host, а Session – это то, что связывает динамический код с вашим кодом, который выполняется в данный момент. Последний шаг – это выполнение кода, и это делает Execute(). На рисунке 10-3 показан процесс и взаимодействие между этими частями, чтобы вы могли видеть на более высоком уровне, как они работают вместе, чтобы запустить ваш динамический код.

Рисунок 10-3: Взаимодействие со ScriptEngine. Ваш код передает ссылку на сборку, которая содержит класс Host, наряду с целевым объектом, в ScriptEngine. Выполняемый скрипт будет использовать обе части, чтобы правильно работать.

Чтобы проверить это, создайте простой класс с несколькими свойствами и переопределенным ToString() для вызова метода расширения:

public sealed class Person
{
	public Person(string name, uint age)
	{
		this.Name = name;
		this.Age = age;
	}
	public override string ToString()
	{
		return this.Generate();
	}
	public uint Age { get; private set; }
	public string Name { get; private set; }
}

Если вы запустите ToString() в консольном приложении:

static void Main(string[] args)
{
	Console.Out.WriteLine(
		new Person("Joe Smith", 30).ToString());
}

вы увидите результат, как на рисунке 10-4.

Рисунок 10-4: Вызов ToString(), реализованный через Roslyn. При компиляции C# во время выполнения, вы можете реализовать все, что угодно, на уровне абстракции, где вы всегда пишете.

Конечный результат может казаться простым, но только подумайте, что вы сделали. Вы создали код на лету, но это не был IL или дерево выражений. Это был C# код! Движок Roslyn скомпилировал этот маленький кусочек кода и выполнил его на лету. Не потребовалось никаких знаний кодов операций или выражений – вы можете написать код, который буквально пишет больше кода и запускает его.

Можно утверждать, что это можно имитировать тем, что в настоящее время имеется в C# компиляторе. Создать фрагмент кода в файле, запустить компилятор, загрузить полученную сборку и выполнить метод посредством рефлексии. С Roslyn, однако, это все происходит в памяти, и вам не придется возиться с вещами, как загрузка сборки и создание файла, если вы не хотите этого. Ради справедливости стоит отметить, что Roslyn можете компилировать кодовые файлы и создавать сборки, и ему нужно это делать, если он собирается заменить csc.exe. Но дело в том, Roslyn представляет анализ кода и манипуляции на гораздо более высоком уровне, чем это было доступно ранее.

Теперь, когда вы окунулись в Roslyn, давайте погрузимся в его API и посмотрим, как создать упрощенный динамический mock во время выполнения.

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

В предыдущем разделе было показано, как использовать скриптовый движок для запуска C#. Сейчас вы увидите, как вы можете компилировать mock C# в сборку. Прежде чем углубиться в Roslyn API, давайте определим, что такое mock.

Что такое mock?

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

Давайте рассмотрим простой пример, чтобы понять, как используются mock-объекты. Скажем, у вас есть класс Person, который использует сервис, чтобы посмотреть адрес для этого человека (person). Рисунок 10-5 показывает зависимость Person от сервиса.

Рисунок 10-5: Использование зависимости напрямую. Для выполнения объекта с комплексной настройкой требуется много времени, что может сделать модульное тестирование сложным и трудоемким.

Теперь, когда разработчику нужно проверить объект Person, ему также нужно убедиться, что сервис настроен, работает и вернет ожидаемые данные. Это отнимет много времени и является хрупким. Более хороший подход заключается в том, чтобы разорвать прямую зависимость от AddressService, как показано на рисунке 10-6.

Рисунок 10-6: Использование интерфейса в коде. Теперь объект Person не волнует, как реализуется IAddressService, и вы можете использовать mock-объекты для модульных тестов на основе Person.

Теперь Person имеет зависимость от интерфейса IAddressService. Код не волнует, как работает класс, реализующий IAddressService; его заботит контракт, который указывает интерфейс. Во время тестирования используется объект MockAddressService, а в производственной версии используется AddressService.

Совет

Если хотите узнать больше о модульном тестировании, пожалуйста, ознакомьтесь с The Art of Unit Testing (http://manning.com/osherove/) и с книгой Внедрение зависимостей в .NET (http://smarly.net/dependency-injection-in-net).

Вы можете обернуть mock-объекты, если захотите; вы можете написать код, реализующий интерфейс и уведомляющий вас, когда был вызван метод. Вы также можете использовать методики метапрограммирования, чтобы синтезировать класс во время выполнения, который сделает это для вас. Давайте посмотрим, как можно использовать Roslyn, чтобы создать mock во время выполнения.

Генерация mock кода

В этом разделе вы увидите, как можно создать mock, используя C# код, во время выполнения. Вы скомпилируете код и создадите экземпляр mock-объекта, передавая его обратно вызывающему элементу. Вызывающий элемент будет использовать класс с методами, соответствующими сигнатуре методов в интерфейсе, который вы захотите обрабатывать в тесте. Это позволит вам обеспечить mock реализацию метода интерфейса, что полезно в сценариях тестирования.

Mock фреймворки в .NET

Mock-структура, которую вы собираетесь создать, довольно упрощенная по сравнению с некоторыми из фреймворков, которые существуют в настоящее время в мире .NET.

Мы рекомендуем NSubstitute (http://nsubstitute.github.com), Moq (http://code.google.com/p/moq/) и RhinoMocks (http://hibernatingrhinos.com/open-source/rhino-mocks).

Будем надеяться, что как только Roslyn будет официально выпущен, в этом фреймворке будут обновлены движки, чтобы использовать Roslyn API для создания mock-объектов.

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

public sealed class MockCodeGenerator
{
	private const string Template = @"
		[System.Serializable]
		internal sealed class {0}
			: {1}
		{{
			private {2} callback;
			public {0}({2} callback)
			{{
				this.callback = callback;
			}}
			{3}
		}}";

Каждому mock-объекту нужны новое имя типа ({0}), интерфейс, его реализующий, ({1}), ссылка на объект обратного вызова ({2}), а также список методов интерфейса с реализацией на основе методов, которые существуют в объекте обратного вызова ({3}). Давайте заполним эти пробелы в коде:

public MockCodeGenerator(string mockName,
	Type interfaceType, Type callbackType)
	: base()
{
	this.MockName = mockName;
	this.InterfaceType = interfaceType;
	this.InterfaceTypeName = InterfaceType.FullName;
	this.CallbackType = callbackType;
	this.Generate();
}
private void Generate()
{
	this.Code = string.Format(MockCodeGenerator.Template,
		this.MockName, this.InterfaceTypeName,
		this.CallbackType.FullName,
		this.GetMethods());
}

Кроме генерации методов, все остальное является довольно шаблонным. Обратите внимание, что мы используем свойство FullName для типа интерфейса и для типа обратного вызова. Это немного упрощает генерацию кода, потому что вам не нужно включать выражения using. Следующий листинг показывает, как можно создавать методы, которые реализуют интерфейс.

Листинг 10-1: Создание методов для интерфейса
private string GetMethods()
{
	var methods = new StringBuilder();
	var callbackMethods = this.CallbackType.GetMethods(
		BindingFlags.Public | BindingFlags.Instance);
	foreach (var interfaceMethod in
		this.InterfaceType.GetMethods())
	{
		methods.Append("public " +
			MockCodeGenerator.GetMethod(interfaceMethod) + "{");
		var callbackMethod = this.FindMethod(
			callbackMethods, interfaceMethod);
		if (callbackMethod != null)
		{
			if (callbackMethod.ReturnType != typeof(void))
			{
				methods.Append("return ");
			}
			methods.Append("this.callback." +
				MockCodeGenerator.GetMethod(
					callbackMethod, false) + ";");
		}
		else
		{
			if (interfaceMethod.ReturnType != typeof(void))
			{
				methods.Append("return " + (
					interfaceMethod.ReturnType.IsClass ?
						"null;" : string.Format("default({0});",
						interfaceMethod.ReturnType.FullName)));
			}
		}
		methods.Append("}");
	}
	return methods.ToString();
}

Строки 6-10: Создание реализации для каждого метода

Строки 11-22: Вызов метода для объекта обратного вызова, если есть соответствие

Строки 23-32: Вернуть значение по умолчанию для метода интерфейса, если возвращаемое значение не является void

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

Вы вызываете FindMethod(), чтобы найти соответствие для объекта обратного вызова, как показано в следующем листинге.

Листинг 10-2: Нахождение соответствия для объекта обратного вызова
private MethodInfo FindMethod(
	MethodInfo[] callbackMethods, MethodInfo interfaceMethod)
{
	MethodInfo result = null;
	foreach (var callbackMethod in callbackMethods)
	{
		if (callbackMethod.ReturnType ==
			interfaceMethod.ReturnType)
		{
			var callbackParameters =
				callbackMethod.GetParameters();
			var interfaceParameters =
				interfaceMethod.GetParameters();
			if (callbackParameters.Length ==
				interfaceParameters.Length)
			{
				var foundDifference = false;
				for (var i = 0;
					i < interfaceParameters.Length; i++)
				{
					if (callbackParameters[0].ParameterType !=
						interfaceParameters[0].ParameterType)
					{
						foundDifference = true;
						break;
					}
				}
				if (!foundDifference)
				{
					result = callbackMethod;
					break;
				}
			}
		}
	}
	return result;
}

Строки 7-8: Убедиться, что возвращаемые типы одинаковы

Строки 10-27: Убедиться, что все типы параметров соотвестствуют

Строки 28-32: Вернуть текущий метод, если все типы соответствуют

Метод GetMethod() возвращает строковую версию MethodInfo, которая может быть использована в генерации кода C#, как показано в следующем листинге.

Листинг 10-3: Генерация C# для определения метода
private static string GetMethod(MethodInfo method, bool includeTypes = true)
{
	var result = new StringBuilder();

	if (includeTypes)
	{
		result.Append(method.ReturnType == typeof(void) ? "void " :
			method.ReturnType.FullName + " ");
	}

	result.Append(method.Name + "(");
	result.Append(string.Join(", ",
		from parameter in method.GetParameters()
		select (includeTypes ?
			parameter.ParameterType.FullName + " " + parameter.Name :
			parameter.Name)));
	result.Append(")");
	return result.ToString();
}
private Type CallbackType { get; set; }
public string Code { get; private set; }
private Type InterfaceType { get; set; }
private string MockName { get; set; }
private string InterfaceTypeName { get; set; }

Опять же, обратите внимание, что вы используете полные имена типов для возвращаемого типа (если он не void) и типов параметров.

Давайте рассмотрим небольшой пример, чтобы увидеть, как выглядит сгенерированный код. Рассмотрим следующий интерфейс:

public interface ITest
{
	void CallMe(string data);
	int CallMe();
}

И предположим, что вы определили объект обратного вызова следующим образом:

public sealed class TestCallback
{
	public int Callback()
	{
		return new Random().Next();
	}
}

Обратите внимание, что метод Callback() соответствует сигнатуре CallMe() в ITest, но TestCallback не реализует ITest. Сгенерированный mock для ITest, который использует TestCallback, будет выглядеть примерно так:

[System.Serializable]
internal sealed class TestCallbackMock: DynamicMocks.Roslyn.Tests.ITest
{
	private DynamicMocks.Roslyn.Tests.TestCallback callback;

	public TestCallbackMock(DynamicMocks.Roslyn.Tests.TestCallback callback)
	{
		this.callback = callback;
	}

	public void CallMe(System.String data) { }

	public System.Int32 CallMe()
	{
		return this.callback.Callback();
	}
}

Mock определяет два метода для реализации метода ITest, но метод CallMe(), который принимает string, ничего не делает. CallMe() вызывает метод Callback для объекта TestCallback, если сигнатура соответствует.

Теперь у вас есть возможность создавать mock-объекты в C#. В следующем разделе вы увидите, как вы можете скомпилировать это при помощи Roslyn.

Компиляция mock кода

Листинг 10-4: Компиляция кода во время выполнения при помощи Roslyn
public static class Mock
{
	private static readonly Lazy<ModuleBuilder> builder =
		new Lazy<ModuleBuilder>(() => Mock.CreateBuilder());
	public static T Create<T>(object callback)
		where T : class
	{
		var interfaceType = typeof(T);
		if (!interfaceType.IsInterface)
		{
			throw new NotSupportedException();
		}
		var callbackType = callback.GetType();
		var mockName = callbackType.Name +
			Guid.NewGuid().ToString("N");
		var template = new MockCodeGenerator(mockName,
			interfaceType, callbackType).Code;
		var compilation = Compilation.Create("Mock",
			options: new CompilationOptions(
				OutputKind.DynamicallyLinkedLibrary),
			syntaxTrees: new[]
			{
				SyntaxTree.ParseCompilationUnit(template)
			},
			references: new MetadataReference[]
			{
				new AssemblyFileReference(
				typeof(Guid).Assembly.Location),
				new AssemblyFileReference(
				interfaceType.Assembly.Location),
				new AssemblyFileReference(
				callbackType.Assembly.Location)
			});
		var result = compilation.Emit(Mock.builder.Value);
		if (!result.Success)
		{
			throw new NotSupportedException(
				string.Join(Environment.NewLine,
				from diagnostic in result.Diagnostics
				select diagnostic.Info.GetMessage()));
		}
		return Activator.CreateInstance(
			Mock.builder.Value.GetType(mockName), callback) as T;
	}
	private static ModuleBuilder CreateBuilder()
	{
		var name = new AssemblyName
		{
			Name = Guid.NewGuid().ToString("N")
		};
		var builder = AppDomain.CurrentDomain.DefineDynamicAssembly(
			name, AssemblyBuilderAccess.Run);
		return builder.DefineDynamicModule(name.Name);
	}
}

Строки 8-12: Убедиться, что дженерик тип является экземпляром

Строки 13-17: Создать mock код

Строки 18-33: Компилировать mock код

Строки 34-41: Выпустить mock код в динамическую сборку, проверить, что это прошло успешно

Строки 42-43: Вернуть новый экземпляр mock-объекта

Строки 45-54: Создать новую динамическую сборку

Первое, что нужно сделать, это проверить, что Т является интерфейсом. После того как вы в этом убедитесь, вы используете класс MockCodeGenerator, чтобы создать код mock. Вы передаете этот сгенерированный код методу Create() класса Compilation с помощью синтаксического дерева. Это синтаксическое дерево создается SyntaxTree.ParseCompilationUnit() (мы расскажем о деревьях в следующем разделе). Вы также передаете объекты AssemblyFileReference, поэтому компилятор знает, где находятся типы, на которые ссылаются, в mock коде. Как только Create() сработал, вы можете ввести результаты в динамический модуль. Если Emit() прошел не успешно, вы можете изучить свойство Diagnostic, чтобы узнать, что не правильно в коде. Наконец, создается новый экземпляр mock-объекта со ссылкой на объект обратного вызова. Обратите внимание, что динамическая сборка создается отложено.

Когда все это в месте, теперь вы можете создать mock, используя классы ITest и TestCallback следующим образом:

var callback = new TestCallback();
var mock = Mock.Create<ITest>(callback);
var result = mock.CallMe();

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

С Roslyn вы можете легко создавать динамический код, который выполняет сложные операции. Вместо того чтобы прибегать к System.Reflection.Emit и IL, вы можете написать C# код и скомпилировать его. Барьер для выполнения мощных реализаций на основе метапрограммирования значительно ниже с Rolsyn, чем с другими подходами, которые вы видели в этой книге.

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

Понимание деревьев

В последнем примере вы использовали SyntaxTree.ParseCompilationUnit() для создания древовидной структуры, представляющей код, который вы передаете методу. Как вы можете себе представить, эти деревья богатые и сложные; на самом деле, когда вы устанавливаете Roslyn, вы получаете несколько визуализаторов, которые помогают легко увидеть дерево. На рисунке 10-7 показан визуализатор отладки, который является представлением mock кода в виде дерева.

Рисунок 10-7: Визуализатор отладки в Roslyn. Если вы рассматриваете конкретный объект в представлении SyntaxTree, вы можете посмотреть на дерево (и его соответствующий код) в визуализаторе.

Совет

Если вы хотите больше узнать о визуализаторах Roslyn, посетите http://mng.bz/59EU.

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

Деревья в Roslyn являются неизменными. Вы не можете изменить содержимое дерева. Неизменяемые структуры имеют много преимуществ, например, они могут сильно облегчить параллельное программирование, но поскольку они неизменны, вы не можете их менять. К счастью, Roslyn поставляется с несколькими классами-визитерами: вы можете использовать SyntaxWalker и SyntaxWriter для поиска по содержанию дерева и для создания нового дерева, основываясь на данном дереве. В следующем разделе вы часто будете использовать деревья и классы-визитеры, когда вы будете взаимодействовать с кодом, написанном в Visual Studio.

Примечание

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