Thursday, October 14, 2010

Benefits of MEF

До MEFа...

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

Parsers

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

Давайте посмотрим на пример, который я собрал по мотивам реального проекта. Интерфейс IParser описывыет контракт, который должны реализовывать все парсеры:

public interface IParser
{
IEnumerable<string> Parse();
}

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

public class Parser : IParser
{
public IEnumerable<string> Parse()
{
return new List<string> { "TxtParser" };
}
}

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

<parseConfiguration>
<parsers>
<add extension="docx" type="DocxParser.Parser, DocxParser" />
<add extension="txt" type="TxtParser.Parser, TxtParser" />
</parsers>
</parseConfiguration>

Для того чтобы можно было работать с содержимым parseConfiguration секции, мы реализовали три класса:

  1. ParseSettings – описывающий саму секцию
  2. ParserDataCollection – описывающий коллекцию элементов нашей секции
  3. ParserData – описывающий отдельный элемент коллекции, т.е. отдельный парсер

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

И наконец, самый интересный момент. Давайте посмотрим, как же приложение находит нужный парсер и создает его экземпляр. Тут все достаточно просто:

  1. Загружаем из конфигурационного файла нужную нас секцию.
  2. Среди элементов этой секции ищем тот, у которого свойство Extension соответствует типу полученного от пользователя файла.

private static IEnumerable<string> Parse(string fileName)
{
IParser parser = null;
string fileExtension =
Path.GetExtension(fileName).Substring(1);

ParseSettings settings =
(ParseSettings)ConfigurationManager.GetSection(
ParseSettings.ParseSettingsSection);

ParserData parserSettings = (
from parserData in settings.Parsers.Cast<ParserData>()
where string.Equals(
parserData.Extension,
fileExtension,
StringComparison.OrdinalIgnoreCase)
select parserData
).FirstOrDefault();

Type parserType = Type.GetType(parserSettings.ParserType, true);
parser = Activator.CreateInstance(parserType) as IParser;

return parser.Parse();
}

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

Если вы дочитали до этого места, то наверняка уже не раз подумали: «Ну а где же собственно про MEF?» Не переживайте, сейчас все будет :)

Немного теории

Мне лениво придумывать свое определение для MEF, поэтому я просто переведу немного текста с официального сайта: «MEF (Managed Extensibility Framework) упрощает создание расширяемых приложений, предлагая функционал по обнаружению и подключению модулей.»

Основные понятия MEFа:

  • Composable part — модуль, который предоставляет сервисы другим модулям или использует сервисы других модулей. Модули в MEF могут находиться как в отдельных сборках, так и в одной с основным приложением сборке.
  • Export—Функциональность которую модуль предоставляет для использования другими модулями.
  • Import—Функциональность, которую модуль хочет получить от других модулей.
  • Contracts—Строковый идентификатор для экспорта и импорта. Экспортер указывает контракт для каждого экспорта, который он предоставляет. Импортер со своей стороны указывает контракт для каждого импорта, который он потребляет.
  • Composition—Используя коллекцию модулей, MEF строит граф зависимостей между ними, пытаясь каждому импорту найти соответствующий экспорт.
  • Catalog – содержит коллекцию доступных модулей
  • Container – используя каталог, строит композицию модулей.

MEF

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

MEF в деле...

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

Интерфейс IParser оставляем без изменений:

public interface IParser
{
IEnumerable<string> Parse();
}

А вот парсеры получат новые атрибуты:

[Export(typeof(IParser))]
[PartMetadata("Extension", "txt")]
public class Parser : IParser
{
public IEnumerable<string> Parse()
{
return new List<string> { "TxtParser" };
}
}

Здесь атрибут Export означает, что мы хотим пометить класс Parser как доступный для инфраструктуры MEF при построении композиции. typeof(IParser) в экспорте означает, что контрактом (строковым идентификатором) для экспорта будет “PluginsViaMEF.Contracts.IParser”. Позже я покаже как мы сможем использовать этот экспорт.

Давайте теперь посмотрим на второй атрибут парсера – PartMetadata. Этот атрибут позволяет привязывать к модулям различную вспомогательную информацию. Он позволяет множественное использование и представляет собой словарь со строковым ключом. Позже вы увидите как можно из кода получить доступ к метаданным модуля.

Как вы видите, мы сохранили в метаданные парсера тип файлов, с которым этот парсер может работать. Т.е больше нет необходимости хранить эту информацию в файле конфигурации. Кроме того список доступных парсеров мы теперь будем получать с помощью MEF. Получается что нам боьше нечего хранить в конфигурации. Мы уберем из нее секцию связанную с парсерами. И заодно удалим из проекта классы ParseSettings, ParserDataCollection и ParserData. Они нам больше не нужны.

Давайте теперь посмотрим что изменилось в основном приложении и как оно теперь будет находить и подключать парсеры. Во-первых мы добавим в класс Programm поле _parser:

[Import]
public IParser _parser;

Обратите внимание на атрибут Import. Мы не указываем здесь явно контракт. По умолчанию MEF использует как контракт тип объекта помеченного атрибутом. Т.е. в нашем случае это будет “PluginsViaMEF.Contracts.IParser”. Вы помните, что именно с таким контрактом мы экспортировали наши парсеры. Теперь, при построении композиции, MEF проинициализирует поле _parser экземпляром одного из парсеров. Наша задача помочь ему выбрать конкретный парсер. Т.к. если MEF обнаружит несколько экспортов соответствующих одному импорту, то он выкинет исключение и перестанет строить композицию.

private void Compose(string fileExtension)
{
var container = new CompositionContainer();
var catalog = new DirectoryCatalog(@".\");

CompositionBatch batch = new CompositionBatch();
batch.AddPart(this);
batch.AddPart(
catalog
.Parts
.Where(
p => p.Metadata.ContainsKey("Extension") &&
string.Equals(
p.Metadata["Extension"].ToString(),
fileExtension,
StringComparison.OrdinalIgnoreCase)
)
.First().CreatePart());

container.Compose(batch);
}

Итак, для того чтобы получить список доступных парсеров, мы создаем DirectoryCatalog и скармливаем ему папку в которой должны лежать наши парсеры. Далее мы используем мета-данные парсеров, чтобы получить только один парсер, который соответствует типу полученного от пользователя файла. И наконец, контейнеру для построения композиции мы даем только основную сборку программы и найденный парсер. Контейнер создаст экземпляр парсера и проинициализирует им поле _pаrser в главном модуле.

На этом у меня все. Проекты с исходным кодом можно скачать здесь.

Ссылки:

  1. MEF community site on Codeplex