Создаём по-настоящему надёжные плагины на платформе Managed Add-In Framework

Однажды мы поняли, что для качественной и быстрой реализации разносторонних требований пользователей нам срочно нужны плагины. Изучив разнообразные платформ, мы выяснили, что наилучшим образом нам подойдёт Managed Add-In Framework (далее — MAF) от Microsoft. Во-первых, она позволяет создавать плагины на базе .NET Framework, во-вторых, даёт возможность обмена данными и пользовательским интерфейсом между плагином и приложением-хостом, и в-третьих, обеспечивает безопасность и версионность, что делает плагины надёжными.

Жизнь показала, что мы были правы — плагины работают, пользователи довольны, заказчик счастлив. Правда, у MAF есть ещё одна проблема — недостаточное количество информации. Всё, что мы нашли — это скудная документация да несколько постов на StackOverflow. Но этот пробел я частично заполню, описав, как создать плагин с нуля, с учетом всех нюансов работы с MAF. Эта статья будет полезна в качестве быстрого старта для тех, кто тоже решит освоить MAF для создания плагинов на базе .NET Framework.

Введение

Один из проектов, над которым мы работаем, — система категоризации и архивирования документов. Программой пользуются юридические и телевизионные компании, адвокатские конторы и клиники — в общем, те, кому важно хранить большой объём документов в безопасности. Сначала система представляла собой де-факто систему контроля версий, но постепенно появлялся дополнительный функционал, ускоряющий работу с документами: шаблоны документов и наборов документов, теги, отправка файлов по почте, предпросмотр файлов и прочее, и прочее. Со временем пользователи системы стали просить всё больше специфических функций: кому-то нужен экспорт набора документов со штампом даты и времени; кто-то хочет добавлять в документы форматированный колонтитул; а другим нужен интуитивно понятный способ создания документов по новому проекту на базе шаблона. Если просто добавлять весь желаемый функционал, то вскоре мы бы получили что-то вроде этого:

Браузер, в котором слишком много тулбаров

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

Изучив различные платформы для создания плагинов, мы остановили свой выбор на MAF, которая позволяет создавать плагины на базе .NET Framework и содержит все необходимые для этого функции:

  • Поиск плагинов, которые придерживаются контрактов, поддерживаемых хост-приложением.
  • Активация: загрузка, запуск и установление связи с плагином.
  • Изоляция: использование доменов приложений или процессов для установления границ изоляции, которые защищают хост-приложение от потенциальных проблем безопасности и выполнения в плагинах.
  • Взаимодействие: позволяет надстройкам и хост-приложению взаимодействовать друг с другом через границы изоляции при помощи вызовов методов и передачи данных.
  • Управление длительностью использования: предсказуемая и простая в использовании загрузка и выгрузка доменов приложений и процессов.
  • Версионность: проверка, что хост-приложение и плагин могут взаимодействовать при создании новых версий любого из них.

Основным элементом в MAF является контракт — это то, что определяет, как хост-приложение и плагин взаимодействуют друг с другом. Хост-приложение использует контракт в особом представлении для хост-приложения, а плагин — в особом представлении для плагина. Для обмена данными и вызова методов между хост-приложением и плагином через соответствующие представления контрактов используются адаптеры:

Граница изоляции

Представления, контракт и адаптеры являются сегментами, а связанные друг с другом сегменты являются конвейером (pipeline). Конвейер — это основа, за счёт которой реализуется поиск и активация плагинов, изоляция, взаимодействие, управление длительностью использования и версионность.

Довольно часто плагину требуется отображение интерфейса, интегрированного внутри хост-приложения. Так как разные технологии .NET Framework имеют разные реализации пользовательского интерфейса, WPF расширяет модель плагинов .NET Framework, поддерживая отображения интерфейса плагинов.

Технология появилась в рамках .NET Framework 3.5, и с момента первого релиза она почти не развивалась. Из-за этого технология выглядит устаревшей, а её сложность отпугивает. Необходимость реализации 5 промежуточных слоёв между хостом и плагином может показаться избыточной, но именно благодаря этой многослойности MAF обеспечивает безопасность и версионность.

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

Начало

Проекты и структура папок

Для начала нужно посмотреть на структуру подключения плагинов через MAF:

Представления хоста (Host views of add-ins)

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

Контракты (Contracts)

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

Требования: Все контракты должны наследоваться от IСontract, класс ContractBase содержит базовую реализацию IСontract. Для безопасности в контактах допускается использование только типов, унаследованных от IСontract, примитивных типов (целочисленные и булевые), сериализуемых типов (типы из mscorlib.dll; типы, определённые в контрактах, и ссылочные типы), коллекций из mscorlib.dll.

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

Представления плагина (Add-in views)

Содержат интерфейсы или абстрактные классы, представляющие, как выглядит хост для плагина и какими типами данных они обмениваются; также содержат интерфейс плагина.

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

Адаптеры хоста (Host-side adapters)

Конвертируют представления хоста в контракт и наоборот в зависимости от направления вызова.

Адаптеры представления в контракт должны реализовывать соответствующий контракт (а значит, наследоваться от ContractBase), в то время как адаптеры контракта в представление должны реализовывать части представления, которые они конвертируют.

Для конструирования конвейера адаптеры необходимо отметить атрибутом HostAdapterAttribute.

Адаптеры плагина (Add-in-side adapters)

То же, что и адаптеры хоста, только они, наоборот, конвертируют объекты контрактов в представления плагина.

Для конструирования конвейера адаптеры отмечаются HostAdapterAttribute.

Каждому слою на физическом уровне соответствует свой проект и своя папка (кроме хоста). Создаём все необходимые проекты для конвейера (тип проекта — Class Library):

  1. Pipeline.Contracts. Добавляем ссылки на System.AddIn и System.AddIn.Contract.
  2. Pipeline.AddInViews. Добавляем ссылку на System.AddIn.
  3. Pipeline.HostViews.
  4. Pipeline.AddInAdapters. Добавляем ссылки на System.AddIn, System.AddIn.Contract, Pipeline.AddInViews, Pipeline.Contracts. Для двух последних проектов отключаем в свойствах копирование (Copy local = False), иначе MAF при сборке конвейера не сможет понять, какую dll надо загрузить в данной папке и не подгрузит весь уровень.
  5. Pipeline.HostAdapters. То же самое, что и для Pipeline.AddInAdapters, только вместо проекта Pipeline.AddInViews ссылаемся на Pipeline.HostViews.
  6. DemoPlugin (хост-приложение).

Все эти библиотеки должны располагаться в строгой иерархии папок:

Иерархия папок

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

Важно: каждый плагин должен лежать в отдельной папке, иначе MAF их не найдётВажно: каждый плагин должен лежать в отдельной папке, иначе MAF их не найдёт

Реализация взаимодействия хоста и плагина

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

[AddInBase]
public interface IExportPluginView
{
  string DisplayName { get; }
}

Когда используется MAF, интерфейсы на уровне хоста и плагина могут отличаться, а адаптеры будут подстраивать их друг под друга. Для простоты будем использовать одинаковые интерфейсы на всех уровнях конвейера. Так что создаём IExportPluginView в проектах Pipeline.AddInViews, Pipeline.Contracts и Pipeline.HostViews. Отличия между ними будут в том, что интерфейс в представлении плагина необходимо отметить атрибутом AddInBase, чтобы MAF по нему смог найти нужный плагин, а интерфейс в контрактах должен быть унаследован от IСontract, как упоминалось выше.

В свою очередь, в адаптерах прописываем «стыковочные» классы адаптеров. Сначала от представления плагина в контракты (проект Pipeline.AddInAdapters):

[AddInAdapter]
public class ExportPluginViewToContractAdapter : ContractBase, Pipeline.Contracts.IExportPlugin
{	
  private readonly Pipeline.AddInViews.IExportPluginView view;
  public string DisplayName => view.DisplayName;
  
  public ExportPluginViewToContractAdapter(Pipeline.AddInViews.IExportPluginView view)
  {
  	this.view = view;
  }
}

В качестве аргумента конструктор принимает соответствующий интерфейс представления плагина. Также необходимо отметить этот класс атрибутом AddInAdapter для MAF.

Затем пишем адаптер от контрактов в представление хоста (проект Pipeline.HostAdapters):

[HostAdapter]
public class ExportPluginContractToHostAdapter : IExportPluginView
{
  private readonly Contracts.IExportPlugin contract;
  private readonly ContractHandle handle;
  
  public string DisplayName => contract.DisplayName;
  
  public ExportPluginContractToHostAdapter(Contracts.IExportPlugin contract)
  {
  	this.contract = contract;
    handle = new ContractHandle(contract);
  }
}

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

Активация и интеграция плагина

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

string pipelinePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Pipeline"); 

AddInStore.Update(pipelinePath); 

MAF создаёт два файла:

  • “Pipeline/PipelineSegments.store” — кеш сегментов конвейера
  • “Pipeline/AddIns/AddIns.store” — кеш плагинов в папке AddIns

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

Если плагины располагаются в других папках, нужно сгенерировать кеши и в них:

AddInStore.UpdateAddIns(otherPluginsPath);

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

Затем нужно найти все плагины, которые реализуют нужный нам интерфейс, то есть IExportPlugin:

var addInTokens = AddInStore.FindAddIns(typeof(IExportPluginView), pipelinePath);

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

Есть несколько вариантов активации плагина в зависимости от требуемой изоляции от хоста:

  1. Без изоляции: плагины и хост запускаются в одном процессе и одном домене приложения.

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

  2. Средняя изоляция: каждый плагин запускается в своём домене приложения.

    В этом варианте можно управлять безопасностью и конфигурацией запуска (параметры метода Activation), а так же можно выгрузить плагин, если что-то пошло не так. Но это может не сработать, так что плагин всё-таки может привести к сбою всего приложения.

  3. Высокая изоляция: каждый плагин запускается в своём процессе.

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

Мы для себя выбрали второй вариант, так как он даёт достаточно безопасности и гибкости, но при этом не требует танцев с бубнами для налаживания взаимодействия между процессами. Для нашего демо выберем тоже средний уровень изоляции с минимальным уровнем безопасности:

var plugin = addInTokens.First().Activate<IExportPluginView>(AddInSecurityLevel.FullTrust);

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

MessageBox.Show($"Plugin '{plugin.DisplayName}' has been activated""MAF Demo message", MessageBoxButton.OK);

Расширение функционала

Реализация обратного взаимодействия от плагина к хосту

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

Пусть пока API для плагинов содержит метод получения даты последнего изменения файла. Создадим IPluginApi интерфейс в сегментах HostView, AddInView и Contracts (здесь не забываем отметить атрибутом AddInContract и отнаследоваться от IContract — так же, как и в случае с IPluginView):

[AddInContract]
public interface IPluginApi : IContract
{
	DateTime GetLastModifiedDate(string path);
}

Для него нужны адаптеры, так же как и для IPluginView, но в обратную сторону. Соответственно, на уровне адаптеров хоста это будет адаптер из представления хоста в представление контракта, а на уровне адаптеров плагина это будет адаптер из представления контракта в представление плагина:

[HostAdapter]
public class PluginApiHostViewToContractAdapter : ContractBase, IPluginApi
{
  private readonly HostViews.IPluginApi view;
  
  public PluginApiHostViewToContractAdapter(HostViews.IPluginApi view)
  {
  	this.view = view;
  }
  
  public DateTime GetLastModifiedDate(string path)
  {
  	return view.GetLastModifiedDate(path);
  }
}

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

Адаптер хоста:

public void Initialize(IPluginApi api)
{
  contract.Initialize(new PluginApiHostViewToContractAdapter(api));
}

Адаптер плагина:

public void Initialize(IPluginApi api)
{
  view.Initialize(new PluginApiContractToPluginViewAdapter(api));
}

В качестве финального штриха реализуем интерфейс Pipeline.HostView.IPluginApi на стороне хоста и инициализируем им наш плагин:

var plugin = addInTokens.First().Activate<IExportPluginView>(AddInSecurityLevel.FullTrust); 

plugin.Initialize(new PluginApi()); 

Добавляем UI для плагина

Для отображения пользовательского интерфейса плагина в хост-приложении в MAF существует специальный интерфейс INativeHandle, который предоставляет доступ к дескриптору окна (Hwnd). INativeHandle, получив дескриптор окна из ресурсов, передаётся между доменами приложений, таким образом хост может показать объект пользовательского интерфейса плагина.

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

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

FrameworkElement GetPanelUI()

А в сегменте контрактов уже используется этот самый интерфейс дескриптора, который может пройти через границу изоляции:

INativeHandleContract GetPanelUI();

Для его создания в адаптере плагина используется конвертер, интегрированный в MAF:

public INativeHandleContract GetPanelUI()
{
  FrameworkElement frameworkElement = view.GetPanelUI();
  return FrameworkElementAdapters.ViewToContractAdapter(frameworkElement);
}

А для получения элемента интерфейса из дескриптора используется обратный метод конвертера:

public FrameworkElement GetPanelUI() 
{
  INativeHandleContract handleContract = contract.GetPanelUI();
  return FrameworkElementAdapters.ContractToViewAdapter(handleContract); 
} 

Для использования класса FrameworkElementAdapters необходимо подключить библиотеку System.Windows.Presentation.

Сделав всё это, остаётся только нарисовать контрол, который плагин будет отдавать хост-приложению по запросу:

public FrameworkElement GetPanelUI() 
{
  return new PanelUI(); 
} 

Хост, в свою очередь, может использовать этот контрол внутри любого ContentControl'а. Получается примерно так:

С использованием пользовательского интерфейса, переданного таким образом, есть нюансы:

  • к контролу плагина невозможно применить триггеры;
  • контрол плагина будет отображаться поверх других контролов этого окна вне зависимости от ZIndex, но другие окна приложения (диалоги, всплывающие окна) будут отображаться как положено (нам из-за этого пришлось переделать реализацию тост-сообщений в виде popup-окон);
  • контрол плагина не будет отображаться, если к окну применена прозрачность; кроме того, свойство AllowTransparenсy должно иметь значение false. (В нашем случае отображению контрола мешало свойство WindowChrome.GlassFrameThickness);
  • хост-приложение не может получить события от действий мышки, так же как и события GotFocus и LostFocus, а свойство IsMouseOver для контрола плагина всегда будет false;
  • в контроле плагина нельзя использовать VisualBrush, а также проигрывать медиа в MediaElement;
  • к контролу плагина не применяются трансформации: поворот, масштабирование, наклон;
  • и конечно, такой контрол плагина не поддерживает изменения его XAML «на лету» — изменения не подтянутся во время исполнения;
  • перед выключением плагина все диспетчеры, использованные в плагине, должны быть закрыты вручную. Для этого необходимо реализовать соответствующий метод в контракте плагина, который позволит хост-приложению дать сигнал плагину перед завершением его работы.

Стилизация плагина

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

Для этого необходимо добавить новый проект типа “Class library”, в который добавить словари ресурсов (ResourceDictionary) с необходимыми стилями, а также создать один словарь, который будет агрегировать их все:

AllSTyles.XAML

Для того чтобы использовать эти стили в плагине, в проект плагина нужно добавить созданный проект со стилями и потом указать ссылку на сводный словарь ресурсов в XAML-файле, где необходимо применить общие стили:

<UserControl.Resources> 
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries> 
            <ResourceDictionary Source="pack://application:,,,/Demo.Styles;component/AllStyles.xaml"/> 
         </ResourceDictionary.MergedDictionaries> 
    </ResourceDictionary> 
</UserControl.Resources> 

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

Важно отметить, что если библиотека стилей использует сторонние библиотеки, то необходимо позаботиться о том, чтобы они тоже копировались в конечную директорию с плагином. Например, можно добавить копирование результатов сборки библиотеки стилей в необходимые папки посредством post-build events.

Деактивация и выгрузка плагинов

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

Выгрузка плагина состоит из двух этапов:

1. Закрыть ссылку на контракт.

Это необходимо для освобождения ссылки и ресурсов, которые связывают плагин и хост-приложение через конвейер. К необходимости использования такого метода мы пришли опытным путём, потому что иначе иногда плагины при повторной активации выдавали ошибку о невозможности создать ссылку (handle) на контракт в адаптере плагина.

Для этого в адаптере плагина необходимо создать метод, в котором будет вызвано удаление ссылки:

[AddInAdapter] 
public class PluginApiContractToPluginViewAdapter : AddInViews.IPluginApi
{
  public void Unload()
  {
  	handle.Dispose();
  }
} 

Чтобы этот метод можно было вызвать из хост-приложения, добавим его и в интерфейс IPluginHostView.

2. Выключить плагин.

Выключение плагина совершается через его контроллер, для получения которого используем экземпляр плагина:

var controller = AddInController.GetAddInController(plugin); 

controller.Shutdown();  

Метод Shutdown прерывает связь через конвейер между плагином и хост-приложением. Так же выгружается и домен приложения, если он был создан при активации плагина. Если же плагин был загружен в домен хост-приложения, то сегменты конвейера больше не будут иметь ссылок на плагин.

Заключение

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

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

P. S. Полный код демо-приложения можно найти на github.

Вам также может понравиться

Блог RBAC в Angular-приложениях
18 марта, 2021
Что такое RBAC, для чего он полезен во фронтенде и как использовать этот паттерн в Angular-приложениях при помощи библиотеки @doce/ngx-rbac.
Блог Стратегия тестирования краткосрочного проекта
18 февраля, 2021
К небольшим проектам не подходят стратегия и тактика тестирования длительных проектов. Как составить и внедрить стратегию тестирования в краткосрочном проекте.
Блог Фильтры действий, или Как просто улучшить читаемость кода
25 января, 2021
Одно из решений раздражающей проблемы повторяющихся строчек кода в методах контроллера — фильтры. Идея использования фильтров для поддержания чистоты кода эффективна и проста.
1
12