Monday, January 20, 2014

Пишем свой плагин на MEF

Здравствуйте, уважаемые читали моего блога. Эта статья посвящена Managed Extensibility Framework (MEF). Недавно я описал использование популярных IoС контейнеров, таких как Unity, Autofac, Castle Windsor, StructureMap, Spring.Net и Ninject. Теперь очередь дошла к MEF. MEF не является IoC контейнером. Он был разработан для возможностей расширения существующего программного обеспечения на основе плагинов (add-in). Залог состоявшейся успешности MEF как инструмента кроется в его простоте. MEF построен на трех функциональных частях: импорт, экспорт и композиция. Используя импорт, мы характеризуем части нашего приложения как способные к расширяемости. Сторонний разработчик, используя функции экспорта, создает отдельный компонент (часть, плагин), предназначенный для нашего приложения. И в ходе выполнения мы используем функции композиции, чтобы соединить части импорта с частями экспорта. Возьмём за основу уже рассмотренный ранее проект электронной библиотеки, немного изменив реализацию, и построим возможности работы с данной электронной библиотекой на основе плагина. Это делается для того, чтобы наше приложение теоретически могло работать с разными библиотеками (домашней, электронной, школьной библиотекой и т.д.). Теперь приступим к реализации.

Импорт
Когда мы помечаем нужный метод атрибутом Import, то эта часть нашего приложения подлежит расширению.
[ImportMany(typeof (ILibraryBookService))]
private IEnumerable<ILibraryBookService> _libraryDataServices { get; set; }
Здесь мы указываем, что в нашем приложении используются написанные сторонними разработчиками плагины, которые мы хотим использовать в нашем приложении. В примере я использовал атрибут ImportMany, потому что подразумеваю наличие нескольких плагинов. Если Вы знаете, что у Вас один плагин внешний, то можете использовать просто атрибут Import. Для указания расширяемости у нас доступно немного атрибутов, поэтому давайте их рассмотрим.
Import Properties
class Program
{
    [Import]
    public IMessageSender MessageSender { get; set; }
}
В данном примере интерфейс IMessageSender помечен как импортируемый. Атрибут Import имеет несколько вариантов объявления. Ниже перечислены те варианты, которые Вы будете использовать, вероятно, чаще всего.
[Import]
[Import("MessageSender")]
[Import(typeof(IMessageSender))]
[Import("MessageSender", typeof(IMessageSender))]
[Import("MessageSender")] позволяет указать имя контракта, по которому будет производиться привязка. Удобно в том случае, если Вы хотите привязать именно к конкретному плагину, зная, что их может быть много.
[Import(typeof(IMessageSender))] позволяет осуществить  привязку по типу контракта.
Constructor Parameters
class Program
{
    [ImportingConstructor]
    public Program(IMessageSender messageSender)
    {
    }
}
Второй способ импорта - через конструктор. Мы указываем, что интерфейс IMessageSender является обязательным для импортирования. В большинстве сценариев импорт с использованием атрибута [ImportingConstructor] чаще встречается, чем импорт с использованием атрибутов Import и ImportMany для импорта классов в виде проперти. Это, по сути, является внедрением зависимостей (dependency injection). Выше показаны два способа внедрения зависимостей:
Constructor Injection (CI) – представлен использованием атрибута ImportingConstructor;
Property Injection (PI) – представлен использованием атрибутов Import и ImportMany.
В принципе, Вам решать, какой из перечисленных вариантов более приемлемый. Вариант с использованием CI встречается чаще при использовании IoC контейнеров, потому что он проще, его легче контролировать и писать юнит-тесты, чем тот же вариант с использованием PI.

С использованием Constructor Injection мы можем указать, что импортируемый нами параметр не является обязательным. 
[Export]
public class OrderController
{
    private ILogger _logger;

    [ImportingConstructor]
    public OrderController([Import(AllowDefault = true)] ILogger logger)
    {
        if (logger == null)
            logger = new DefaultLogger();
        _logger = logger;
    }
}
Будьте осторожны при использовании такого способа с property injection, так как если у Вас не будет найдено необходимого параметра, помеченного как экспортируемый, то у Вас в переменной будет null, и если Вы попытаетесь неосторожно обратиться к этой переменной, полагая, что она инициализирована, то получите NullReferenceException. Это еще одна из причин, почему лучше использовать CI вместо PI. Используя CI, можно проверить параметр на null и при необходимости выдать ошибку.
Import Collections
class Program
{
    [ImportMany]
    public IEnumerable<IMessageSender> MessageSender { get; set; }
}
Единственное отличие от использования атрибута Import – в том, что мы говорим MEF, что у нас на входе коллекция. Атрибуты у ImportMany такие же, как и у атрибута Import, поэтому Вы можете посмотреть описание для данного атрибута выше.

Экспорт
С помощью атрибута Export из инфраструктуры MEF класс помечается как экспортируемая часть.
Composable Part Export
Используется для экспорта класса как составной части. Пример:
[Export(typeof(ILibraryBookService))]
public class HomeLibraryBookService : ILibraryBookService
{
   ...
}
Property Export
Используется для того, чтобы указать, что экспортируемая часть является свойством.
public class Configuration
{
    [Export("Timeout")]
    public int Timeout
    {
        get { return int.Parse(ConfigurationManager.AppSettings["Timeout"]); }
    }
}
[Export]
public class UsesTimeout
{
    [Import("Timeout")]
    public int Timeout { get; set; }
}
Method Export
Используется для экспорта методов.
public class MessageSender
{
    [Export(typeof(Action<string>))]
    public void Send(string message)
    {
        Console.WriteLine(message);
    }
}

[Export]
public class Processor
{
    [Import(typeof(Action<string>))]
    public Action<string> MessageSender { get; set; }

    public void Send()
    {
        MessageSender("Processed");
    }
}
Стоит упомянуть о времени жизни объектов. В MEF, в отличие от большинства IoC контейнеров, не такой богатый выбор для указания того, как создавать экспортируемый объект. По сути, их всего два. Рассмотрим таблицу по времени жизни объектов.
Вариант с использованием Shared создает экземпляр импортируемой переменной как синглетон. Использование NonShared на каждый вызов будет возвращать новый экземпляр объекта.

Композиция
Композицией называется процесс поиска всех определенных частей MEF, их инстанцирования и присвоения экземпляров экспортируемых частей частям импорта. Иными словами, в процессе композиции плагины, помеченные атрибутом экспорта, подключаются к частям когда, помеченными атрибутами импорта.
//Create the CompositionContainer with the parts in the catalog.
var container = new CompositionContainer(catalog);

//Fill the imports of this object
container.ComposeParts(this);
Рассмотрим практический пример. Чтобы понимать, какие интерфейсы будут использоваться, их описание приведено ниже.
public interface IBook
{
    string Author { getset; }
    string Title { getset; }
    DateTime Year { getset; }
    string SN { getset; }
    int Count { getset; }
}
Этот интерфейс предназначен для хранения информации о книге.
public interface ILibraryBookService
{
    string Title { get; set; }
    void GetData(Action<ObservableCollection<IBook>, Exception> callback);
    IBook FindBook(IBook findBook);
    void CreateNewBook();
    void RemoveBook(IBook book);
}
Интерфейс ILibraryBookService используется для описания классов, которые будут использоваться для поиска книг, байндинга с основной моделью проекта.

Для реализации этого интерфейса было реализовано два класса: LibraryBookService и HomeLibraryBookService. Первый реализует электронную библиотеку, например, компьютерного магазина, второй реализует поиск книг по домашней коллекции книг. Эти классы для демонстрации работы плагинов были вынесены в отдельные библиотеки. Посмотрите, как нужно пометить классы о том, что они являются экспортируемыми для того, чтобы можно было выполнить композиции.
[Export(typeof(ILibraryBookService))]
public class HomeLibraryBookService : ILibraryBookService
{
   //code here
}
[Export(typeof(ILibraryBookService))]
public class LibraryBookService : ILibraryBookService
{
}
Теперь наши классы готовы для того, чтобы их можно было использовать в программе. Рассмотрим, как используется импорт в модели представления (ViewModel).
public class MainViewModel : NotifyModelBase
{
    [ImportMany(typeof (ILibraryBookService))]
    private IEnumerable<ILibraryBookService> _libraryDataServices { get; set; }

    public MainViewModel()
    {
        DoImport();
    }

    public void DoImport()
    {
        //An aggregate catalog that combines multiple catalogs
        var catalog = new AggregateCatalog();

        //Add all the parts found in all assemblies in
        //the same directory as the executing program
        var path = Path.Combine(Directory.GetCurrentDirectory(), "Plugins");
        catalog.Catalogs.Add(new DirectoryCatalog(path));

        //Create the CompositionContainer with the parts in the catalog.
        var container = new CompositionContainer(catalog);

        //Fill the imports of this object
        container.ComposeParts(this);
    }

    public IEnumerable<ILibraryBookService> LibraryDataServices
    {
        get { return _libraryDataServices; }
    }

    private ILibraryBookService _selectedLibrary;
    public ILibraryBookService SelectedLibrary
    {
        get { return _selectedLibrary; }
        set
        {
            _selectedLibrary = value;
            if(value == null)
                Books.Clear();

            _selectedLibrary.GetData((items, error) =>
            {
                Books = items;
            });
            OnPropertyChanged("SelectedLibrary");
        }
    }

    #region Public Properties

    private ObservableCollection<IBook> _books;
    public ObservableCollection<IBook> Books
    {
        get { return _books; }
        set {
            _books = value;
            OnPropertyChanged("Books");
        }
    }

    private IBook _selectedBook;
    public IBook SelectedBook
    {
        get { return _selectedBook; }
        set
        {
            _selectedBook = value;
            OnPropertyChanged("SelectedBook");
            RemoveBookCommand.RaiseCanExecuteChanged();
        }
    }
    #endregion

    #region Command
    private DelegateCommand _addBookCommand;
    public DelegateCommand AddBookCommand
    {
        get
        {
            return _addBookCommand ?? (_addBookCommand = new DelegateCommand(AddNewBook));
        }
    }

    private void AddNewBook(object args)
    {
        if(SelectedLibrary != null)
            SelectedLibrary.CreateNewBook();
    }

    private DelegateCommand _removeBookCommand;
    public DelegateCommand RemoveBookCommand
    {
        get
        {
            return _removeBookCommand ?? (_removeBookCommand = new DelegateCommand(RemoveBook, CanRemoveBook));
        }
    }

    private void RemoveBook(object args)
    {
        Books.Remove(SelectedBook);
    }

    private bool CanRemoveBook(object args)
    {
        if (SelectedBook == null)
            return false;
        if (SelectedLibrary == null)
            return false;
        var book = SelectedLibrary.FindBook(SelectedBook);
        if (book == null)
            return false;

        return true;
    }
    #endregion
}
Мы говорим ViewModel использовать плагины ILibraryBookService, указав атрибут ImportMany.
[ImportMany(typeof (ILibraryBookService))]
private IEnumerable<ILibraryBookService> _libraryDataServices { get; set; }

Чтобы плагины заработали, для них необходимо выполнить композицию. В примере, приведенном в данной статье, композиция выполняется с помощью метода DoImport().
public void DoImport()
    {
        //An aggregate catalog that combines multiple catalogs
        var catalog = new AggregateCatalog();

        //Add all the parts found in all assemblies in
        //the same directory as the executing program
        var path = Path.Combine(Directory.GetCurrentDirectory(), "Plugins");
        catalog.Catalogs.Add(new DirectoryCatalog(path));

        //Create the CompositionContainer with the parts in the catalog.
        var container = new CompositionContainer(catalog);

        //Fill the imports of this object
        container.ComposeParts(this);
    }
Чтобы посмотреть, как это все будет выглядеть на экране, необходимо добавить несколько финальных штрихов. Нужно реализовать необходимую логику через XAML-разметку и запустить проект.
<Window x:Class="CompositeOnlineLibrary.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Online Book Library" Height="350" Width="525">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <Grid Grid.Column ="0">
            <Grid.RowDefinitions>
                <RowDefinition  Height="*"/>
                <RowDefinition  Height="*"/>
            </Grid.RowDefinitions>
            <ListView Grid.Row="0" ItemsSource="{Binding LibraryDataServices}" SelectedItem="{Binding SelectedLibrary}">
                <ListView.ItemTemplate>
                    <DataTemplate>
                        <TextBlock Text="{Binding Title}" />
                    </DataTemplate>
                </ListView.ItemTemplate>
            </ListView>
            <ListView x:Name ="library" Grid.Row="1" ItemsSource="{Binding Books}" SelectedItem="{Binding SelectedBook}">
                <ListView.ItemTemplate>
                    <DataTemplate>
                        <WrapPanel>
                            <TextBlock Text="Author: " />
                            <TextBlock Text="{Binding Author}" FontWeight="Bold" />
                            <TextBlock Text=", " />
                            <TextBlock Text="Caption: " />
                            <TextBlock Text="{Binding Title}" FontWeight="Bold" />
                            <TextBlock Text="Count: " />
                            <TextBlock Text="{Binding Count}" FontWeight="Bold" />
                        </WrapPanel>
                    </DataTemplate>
                </ListView.ItemTemplate>
            </ListView>
        </Grid>
        <Grid Grid.Column="1">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
            </Grid.RowDefinitions>
            <Grid Grid.Row="0" DataContext="{Binding ElementName=library, Path=SelectedItem}">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="*" />
                </Grid.ColumnDefinitions>
                <Label Grid.Row="0" Grid.Column="0" Content="Author" />
                <TextBox Grid.Row="0" Grid.Column="1" Text="{Binding Author}" />
                <Label Grid.Row="1" Grid.Column="0" Content="Title" />
                <TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Title}" />
                <Label Grid.Row="2" Grid.Column="0" Content="Year" />
                <DatePicker Grid.Row="2" Grid.Column="1" SelectedDate="{Binding Year}" />
                <Label Grid.Row="3" Grid.Column="0" Content="Serial Number" />
                <TextBox Grid.Row="3" Grid.Column="1" Text="{Binding SN}" />
                <Label Grid.Row="4" Grid.Column="0" Content="Count" />
                <TextBox Grid.Row="4" Grid.Column="1" Text="{Binding Count}" />
            </Grid>

            <StackPanel Grid.Row="1" Orientation="Horizontal">
                <Button Content="Add new book" Margin="3"  Command="{Binding AddBookCommand}" />
                <Button Content="Remove book" Margin="3" Command="{Binding RemoveBookCommand}"/>
            </StackPanel>
        </Grid>
    </Grid>
</Window>
XAML-код получился не очень громоздкий. После запуска приложения на экране можем наблюдать, как система работает с плагинами.  
На экране видно результат выбора плагина OnlineBookLibrary. Мы можем переключаться между плагинами, добавлять новые книги, изменять данные и т.д. А главное – Вы можете написать свой плагин и в нём реализовать свою электронную библиотеку. Если в Вашу программу нужно добавлять какую-то функциональность по мере разработки, посмотрите в сторону плагинов. Использовать MEF в своих приложениях очень просто. Это основное достоинство MEF.

Итоги
Подведем краткие итоги данной статьи. Мы рассмотрели основные части MEF и создали WPF/MVVM-приложение, чтобы продемонстрировать, как это работает. Чтобы понять принципы работы MEF и начать использовать его в своих приложениях, не нужно глубоких познаний в языке C#. Если Вы начинающий разработчик и знаете, как работает наследование, то для Вас освоить MEF не составит труда. Слабая сторона MEF: по сравнению с популярными IoC контейнерами, MEF очень медленный. Наверное, единственный IoC контейнер, который проигрывает мефу, – это Ninject. За простоту приходится платить. Но не все так плохо, как кажется. Для тех целей, для которых был написан данный фреймфорк, он выполняет свою миссию на "отлично". Он также очень популярен и используется в библиотеке Prism (доступна возможность выбора между Unity и MEF), а также в современной реализации компилятора как сервиса, – сейчас я говорю о проекте Roslyn. MEF играет далеко не последнюю роль. Вы можете посмотреть исходники примера в списке литературы. Надеюсь, что данная статья станет для вас отправной точкой для расширения своего  программного обеспечения с помощью плагинов.

Список литературы

No comments:

Post a Comment