Главная страница   /   2.4. Практическое использование рефлексии (Метапрограммирование в .NET

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

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

Кевин Хазард

2.4. Практическое использование рефлексии

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

  • Устранение ошибок конфигурации
  • Создание информативного строкового представления объекта
  • Включение упрощенной системы "утиной типизации"

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

Давайте начнем с WCF и вопросов, связанных с известными типами.

Автоматическая регистрация известных типов в WCF

В .NET общий способ определения сервисов осуществляется с помощью Windows Communication Foundation (WCF). Эти классы предоставляют классы и интерфейсы, которые вы можете использовать для создания сервисов и подключаемых видов поведения для расширения система «запрос/ответ». Хотя WCF может уменьшить объем работ, необходимых для настройки и вызова сервиса, он также может создать неуловимые несоответствия концепции программирования. В частности, наследование может действовать не так, как вы ожидаете.

Вот конкретный пример. Допустим, вы хотите создать процессор для обработки различных видов сообщений, которые вы хотите отслеживать в вашем приложении. Вы создаете базовый тип, называемый Message:

[DataContract]
public class Message
{
	[DataMember]
	public string Data;
	public Message() : base()
	{
		this.Data = "Unknown";
	}
}

Затем вы начинаете определять различные сообщения, такие, как отслеживание при закрытии приложения на данной машине:

[DataContract]
public sealed class ApplicationClosedMessage : Message
{
	[DataMember]
	public string MachineName;
	public ApplicationClosedMessage(string machineName) : base()
	{
		this.MachineName = machineName;
		this.Data = "Application has closed.";
	}
}

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

Проблема с WCF заключается в том, что он "не знает" ничего об объектах. Даже если вы создаете классы и объекты в вашем коде, основанном на WCF, в конце концов, это все, что касается отправки сообщений сервисам. Если вы определите ваш контракт вот так:

[ServiceContract]
public interface IMessageProcessor
{
	[OperationContract]
	string Process(Message fruit);
}

а затем реализуете контракт вот так

[ServiceBehavior]
public class MessageProcessor : IMessageProcessor
{
	[OperationBehavior]
	public string Process(Message message)
	{
		return message.Data;
	}
}

у вас проблемы! Конечно, компилятор с радостью скажет, что все хорошо, но все будет работать не так, как надо. Если вы попытаетесь передать объект Message в процессор вот так

var channel = new ChannelFactory<IMessageProcessor>(string.Empty).CreateChannel();
var result = channel.Process(new Message());

вы получите правильный ответ. Но попытайтесь передать ApplicationClosedMessage:

var channel = new ChannelFactory<IMessageProcessor>(string.Empty).CreateChannel();
var result = channel.Process(
	new ApplicationClosedMessage("\SomeMachine"));

Это с треском провалится во время выполнения: вы получите CommunicationException. Опять же, WCF ожидает значение Message, поэтому, когда он "видит" ApplicationClosedMessage, он "расстраивается".

Примечание

В предыдущем примере показана упрощенная версия реальной проблемы, с которой столкнулся один из авторов. Система имела Process(), который определяется как односторонняя операция, что обозначает, что вызов операция идет по пути "запустил-и-забыл": то есть клиенту не нужно ждать, когда завершится сервис. Односторонние операции не возвращают значение, но создание Process(), который возвращает значение, облегчает создание тестов, которые можно читать, не зная много об WCF.

Есть способ решить эту проблему с помощью известных типов. «Известный тип» говорит сам за себя: это способ сказать WCF «знать» о типе. Если вы делаете так, это позволяет сообщениям «проходить», даже если они являются подклассами. Вы можете обрабатывать известные типы WCF несколькими различными способами. Один из способов – через конфигурацию. Вы можете определить известный тип в файле .config, добавив элемент <system.runtime.serialization> (который будет идти под элементом <configuration>):

<system.runtime.serialization>
	<dataContractSerializer>
		<declaredTypes>
			<add type="KnownTypes.Messages.Message, KnownTypes">
				<knownType type="KnownTypes.Messages.ApplicationClosedMessage, KnownTypes"/>
			</add>
		</declaredTypes>
	</dataContractSerializer>
</system.runtime.serialization>

Здесь, однако, могут быть проблемы с поддержкой. Сложность возникает тогда, когда вы добавляете новые сообщения в ваше приложение. Если вы забыли «посетить» файл .config и добавить новый элемент <knownType> или если есть опечатка хотя бы в одном символе, вы получите сообщение об ошибке, когда это новое сообщение будет передано сервису. Конечно, хорошие модульные тесты словят такой момент, но разве не было бы лучше, если вы могли сделать регистрацию известного типа автоматической?

К счастью, вы можете. WCF определяет ServiceKnownTypeAttribute, который можно использовать, чтобы указать метод для класса, который будет предоставлять список известных типов. Вот обновленная версия IMessageProcessor с ServiceKnownTypeAttribute:

[ServiceContract]
[ServiceKnownType("GetMessageTypes", typeof(MessageProcessorKnownTypesProvider))]
public interface IMessageProcessor
{
	[OperationContract]
	string Process(Message fruit);
}

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

  • Он должен быть статическим
  • Он должен принимать один, и только один, аргумент, который реализует ICustomAttributeProvider
  • Он должен возвращать список известных типов

Примечание

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

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

Листинг 2-5: Автоматическое обнаружение известных типов
public static class MessageProcessorKnownTypesProvider
{
	private static Type[] knownTypes;
	public static Type[] GetMessageTypes(ICustomAttributeProvider attributeTarget)
	{
		if (MessageProcessorKnownTypesProvider.knownTypes == null)
		{
			var types = new List<Type>();
			var messageType = typeof(Message);
			foreach (var type in
				Assembly.GetAssembly(typeof(MessageProcessorKnownTypesProvider)).GetTypes())
			{
				if (messageType.IsAssignableFrom(type))
				{
					types.Add(type);
				}
			}
			MessageProcessorKnownTypesProvider.knownTypes = types.ToArray();
		}
		return MessageProcessorKnownTypesProvider.knownTypes;
	}
}

Примечание

В примере из листинга 2-5 все сообщения определены в одной сборке как контракты и сервисы. Мы сделали это для простоты, в реальных WCF приложениях эти сущности, как правило, разделены по разным сборкам. Но код в листинге 2-5 изменить не сложно, чтобы обнаружить известные типы в разных сборках.

Вы просматриваете все типы в текущей сборке и смотрите, наследуются ли они от Message при помощи IsAssignableFrom(). Этот метод смотрит на данный тип и проверяет, является ли он подклассом текущего типа. Если это так, метод добавляет его в список. Хорошим делом в этом методе является то, что он смотрит на иерархию наследования. Обнаружение того, равен ли тип другому типу, не является достаточным. Если бы оператор if выглядел как

if(typeof(Message).IsAssignableFrom(type))

он бы не сработал. Вы также не можете использовать ключевое слово is, потому что так проверяется объект, чтобы определить, является ли он видом типа. В данном случае у вас есть два типа, так что это не вариант. Но IsAssignableFrom() именно то, что вам нужно. Если вы добавляете новое сообщение на эту сборку, оно будет автоматически работать, как только будет развернут обновленный сервис.

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

Динамическая реализация ToString

Большинство .NET разработчиков знают, что есть метод для класса Object, который называется ToString(). Нет никакого требования, чтобы переопределять его, вы можете теоретически все свое время быть разработчиком, пишущим .NET код, и никогда ничего не делать с ToString(). Однако, он может быть маленьким, но удобным методом во время отладки.

Предположим, у вас есть класс Customer, который выглядит вот так:

public abstract class Customer : ICustomer
{
	protected Customer() : base()
	{
		this.Id = Guid.NewGuid();
	}
	public int Age { get; set; }
	public Guid Id { get; set; }
	public string FirstName { get; set; }
	public string LastName { get; set; }
}

А интерфейс ICustomer определяется следующим образом:

public interface ICustomer
{
	int Age { get; set; }
	Guid Id { get; set; }
	string FirstName { get; set; }
	string LastName { get; set; }
}

Теперь, скажем, вы создали реализацию Customer в вашем приложении:

public sealed class CustomerReflection : Customer { }

Как разработка продолжится, вы столкнетесь с ошибкой, которая потребует от вас посмотреть в отладчик. Во время сеанса отладки наведите курсор мыши на экземпляр CustomerReflection. Рисунок 2-2 показывает информацию, которую Visual Studio дает вам об этом объекте.

Рисунок 2-2: Описание объекта в отладчике. Как вы видите, информации не много.

Отладчик вызывает ToString() для объекта и отображает его. Реализация по умолчанию ToString() заключается в возвращении имени класса, что является не особо полезным или информативным в большинстве случаев. Конечно, вы можете углубиться в узел дерева, чтобы получить подробную информацию, но иногда полезно получить быстрый отчет, что ваш объект находится в правильном состоянии, или в зависимости от состояния объекта вы хотите посмотреть, что происходит, когда обычно проверяете содержимое списков и словарей. Рисунок 2-3 показывает, что случится, если вы переопределите ToString() на его описательную реализацию.

Рисунок 2-3: Богатое описание объекта в отладчике. Все читабельные свойства перечислены с их именами и значениями.

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

public static class Constants
{
	public const string Separator = " || ";
}
public sealed class CustomerHardCoded : Customer
{
	public override string ToString()
	{
		return new StringBuilder()
		.Append("Age: ").Append(this.Age)
		.Append(Constants.Separator)
		.Append("Id: ").Append(this.Id)
		.Append(Constants.Separator)
		.Append("FirstName: ").Append(this.FirstName)
		.Append(Constants.Separator)
		.Append("LastName: ").Append(this.LastName).ToString();
	}
}

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

Листинг 2-6: Использование рефлексии для создания описания объекта
public static class ObjectExtensions
{
	public static string ToStringReflection<T>(this T @this)
	{
		return string.Join(Constants.Separator,
			new List<string>(
				from prop in @this.GetType()
					.GetProperties(BindingFlags.Instance | BindingFlags.Public)
				where prop.CanRead
				select string.Format("{0}: {1}",
					prop.Name,
					prop.GetValue(@this, null))).ToArray());
	}
}

Много чего происходит в этих 14 строках кода, так что давайте начнем изнутри и пройдем по всему пути. Запрос LINQ ищет все открытые свойства экземпляра данного типа объекта и фильтры для тех, которые вы можете прочитать. Он принимает те объекты PropertyInfo и использует свойство Name и метод GetValue(), чтобы создать строку описания для каждого объекта. Результаты LINQ запроса затем передаются Join() для класса string, который принимает каждую описательную строку и объединяет их, разделяя ||.

Теперь вы можете создать класс CustomerReflection, который использует метод расширения:

public sealed class CustomerReflection : Customer
{
	public override string ToString()
	{
		return this.ToStringReflection();
	}
}

Если вы создадите экземпляр этого класса

new CustomerReflection()
{
	FirstName = "Jason",
	LastName = "Reflection",
	Age = 20
}

вы получите следующее значение, когда вызовете ToString():

"Age: 20 || Id: e114900f-0257-48e0-8b1e-01453123a4bf ||
	FirstName: Jason || LastName: Reflection"

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

Давайте закроем этот раздел и посмотрим, как рефлексия поддерживает "утиную типизацию".

Вызов произвольных методов для объектов

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

public interface IDriver
{
	void Drive();
}

вы могли бы создать метод, который говорит любому объекту «приводить что-то в движение», пока он реализует этот интерфейс:

public sealed class Golfer : IDriver
{
	public void Drive()
	{
		// Ударить по мячу.
	}
}

Но есть и менее безопасный для типов, но более гибкая версия этой стратегии: «утиная типизация » (“Duck typing,” http://en.wikipedia.org/wiki/Duck_typing). Ключевая разница между «утиной типизацией» и предыдущим подходом заключается в том, что «утиная типизация» не полагается на иерархию наследования. Вместо этого, все, что вам нужно, это метод, который соответствует тому, что вы ищете. Существование метода с правильной подписью заставит это работать. «Утиная типизация » также используется для того, чтобы позволить разработчикам создавать перечисляемые объекты без IEnumerable<T> and IEnumerator<T> (Krzysztof Cwalina (blog), “Duck Notation,” July 2007, http://mng.bz/3lXH).

Вас может удивить, что «утиная типизация» уже существует в той или иной форме в .NET. Одним из примеров является перегрузка операторов. При перегрузке оператора компилятором в итоге создается метод с известным именем. Это то, что используется, когда вы используете оператор.

Вот упрощенный класс Range, который перегружает оператор сложения:

public sealed class Range
{
	public static Range operator +(Range a, Range b)
	{
		return new Range(Math.Min(a.Minimum, b.Minimum), Math.Max(a.Maximum, b.Maximum));
	}
	public Range(double minimum, double maximum)
	{
		this.Minimum = minimum;
		this.Maximum = maximum;
	}
	public override string ToString()
	{
		return string.Format("{0} : {1}", this.Minimum, this.Maximum);
	}
	public double Maximum { get; private set; }
	public double Minimum { get; private set; }
}

Сложение двух объектов Range

var rangeOne = new Range(-10d, 10d);
var rangeTwo = new Range(-5d, 15d);
Console.Out.WriteLine(rangeOne + rangeTwo);

создает "-10:15" в командной строке, как и ожидалось. Но что происходит за кулисами?

При перегрузке операции сложения C# компилятор называет метод op_Addition и маркирует его специальным флагом метаданных, который называется specialname. Вы не можете просто назвать статической метод op_Addition и использовать его для сложения, потому что он не имеет этого флага. Компилятор вставляет это за вас. Но он должен использовать имя op_Addition, потому что это стандартное имя для перегрузки + в .NET. Кроме того, он должен принимать два аргумента. Поэтому, если он «крякает» как +, он должен быть сложением.

Давайте посмотрим, как можно реализовать «утиную типизацию» при помощи рефлексии. Скажем, у вас есть два класса, и у обоих есть методы Drive(), но у них не один базовый класс:

public sealed class Golfer
{
	public string Drive(string technique)
	{
		return technique + " - 300 yards";
	}
}
public sealed class RaceCarDriver
{
	public string Drive(string technique)
	{
		return technique + " - 200 miles an hour";
	}
}

«Утиная типизация» говорит: "Итак, похоже, что они оба Drive(), и они действуют, как будто они Drive(), так что давайте сделаем их Drive()". Вас не волнует, что между ними нет общего класса; определение метода само по себе является тем, что делает их общими.

В C# 4.0 ключевое слово dynamic дает вам несколько ограниченную версию «утиной типизации». Например, вы можете сделать это:

dynamic caller = new Golfer();
Console.Out.WriteLine(caller.Drive("Dynamic"));

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

Листинг 2-7: Вызов метода по его имени
public static class ObjectExtensions
{
	public static object Call(this object @this, string methodName, params object[] parameters)
	{
		var method = @this.GetType()
			.GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public, null,
				Array.ConvertAll<object, Type>(parameters, target => target.GetType()), null);
		return method.Invoke(@this, parameters);
	}
}

Вы ищете метод, основываясь на имени и типах заданных аргументов. В данном случае ищутся только открытые методы экземпляров. Вы получаете типы аргументов через Array.ConvertAll(), который говорит рефлексии, какой конкретно метод использовать, если метод является перегруженным.

Реальное использование вызова метода в рантайме

Есть один известный .NET Framework, который использует идею разрешения вызова метода во время выполнения: CSLA (www.lhotka.net/cslanet/). Он, в основном, используется для разработки бизнес объектов и использует так называемый DataPortal, чтобы управлять сроком существования объекта. Движок CSLA использует метапрограммирование (в некоторой степени), чтобы определить, какой метод DataPortal_XYZ вызвать в зависимости от типа заданных критериев.

Теперь относительно легко вы можете вызвать желаемый метод:

Console.Out.WriteLine(new Golfer().Call("Drive", "Reflection"));
Console.Out.WriteLine(new RaceCarDriver().Call("Drive", "Reflection"));

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

Краткое резюме примеров с рефлексией

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

Первое, что нужно отметить, что в двух из трех примеров код эластичен к изменению имен. Например, в примере с WCF не имеет значения, добавляется ли в сборку новый известный тип: провайдер автоматически подхватывает новые подклассы, не ломаясь во время выполнения. Если вы изменили имя базового класса для сообщений, вы сразу об этом узнаете при компиляции кода. Аналогично, реализация ToString() не выдаст ошибку, если новое свойство добавляется к объекту или старое удаляется; все будет прекрасно работать в любом случае. Пример с вызовом метода является хрупким, так как имя жестко закодировано в строке, и компилятор не поможет вам здесь в выяснении того, что имя является правильным. Мы надеемся, что модульные тесты выявят ошибки, прежде чем код будет запущен в промышленной версии.

Второй аспект, на который мы обратим внимание, заключается в размере кода, который использует рефлексию. Во всех трех примерах требуется не так много строк кода, которые необходимы для создания некоторых интересных (и полезных) реализаций. Это довольно типично, когда рефлексия используется в приложении. А вот использование большого количества рефлексии в коде встречается редко. Она обычно используют в небольших, эффективных порциях, где гибкость перевешивает любые негативные моменты, которые могут появиться с рефлексией. Это не твердое правило, потому что каждая проблема, с которой вы сталкиваетесь как разработчик, может быть решена несколькими способами. Иногда использование большего количества рефлексии может быть лучшим решением. Но если вы используете ее в больших количествах, лучше пересмотреть результат. Небольшое количество рефлексии – это, как правило, все, что нужно, чтобы решить задачу в элегантной манере.