Писать плагины с AppDomain — весело

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

Кто хочет повелосипедить — вперед под кат.Чат находится тут.А об архитектуре приложения можно почитать вот тут. В данном случае нас интересует только модель. Кардинально она не менялась, и достаточно будет знать про основные сущности (Корень модели/API/Команды).

О том, что необходимо реализовать в приложении:

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

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

Для чего нужен AppDomain?

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

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

Выполнить код в другом домене. Создать объект и продвинуть его по значению. Создать объект и продвинуть его по ссылке. Немного о продвижении: Продвижение, может происходить по ссылке или по значению.Со значением все относительно просто. Класс сериализуется в одном домене, передается массивом байт в другой, десериализуется, и мы получаем копию объекта. При этом необходимо чтобы сборка была загружена в оба домена. Если необходимо чтобы сборка не грузилась в основной домен, то лучше чтобы папка с плагинами не была добавлена в список папок, где ваше приложение будет искать сборки по умолчанию (AppDomain.BaseDirectory / AppDomainSetup.PrivateBinPath). В таком случае будет исключение о том, что тип не удалось найти, и вы не получите молча загруженную сборку.

Для выполнения продвижения по ссылке класс должен реализовывать MarshalByRefObject. Для каждого такого объекта, после вызова метода CreateInstanceAndUnwrap, в вызывающем домене создается представитель. Это объект который содержит все методы настоящего объекта (полей при этом там нет). В этих методах, с помощью специальных механизмов он вызывает методы настоящего объекта, находящегося в другом домене и соответственно методы тоже выполняются в домене в котором объект был создан. Также стоит сказать, что время жизни представителей ограничено. После создания они живут 5 минут, и после каждого вызова какого-либо метода, их время жизни становится 2 минуты. Время аренды можно изменить, для этого у MarshalByRefObject можно переопределить метод InitializeLifetimeService.Продвижение по ссылке не требует загрузки в основной домен сборки с плагином.

Отступление про поля: Это одна из причин пользоваться не открытыми полями, а свойствами. Доступ к полю через представитель получить можно, но работает это все намного медленнее. Причем, для того чтобы это работало медленнее, не обязательно использовать кросс-доменные операции, достаточно унаследоваться от MarshalByRefObject.

Детальнее о выполнении кода:

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

Это экземплярный метод, а класс-хозяин не может быть продвинут. Как известно делегат для каждого подписанного метода хранит 2 основных поля, ссылка на экземпляр класса, а также указатель на метод. Вы использовали замыкания. По умолчанию класс который создает компилятор не помечается как сериализуемый и не реализовывает MarshalByRefObject. (Далее см. пункт 1) Если унаследовать класс от MarshalByRefObject, создать его в домене 1 и пытаться выполнить его экземплярный метод в другом домене 2, то граница доменов будет пересечена 2 раза и код будет выполнен в домене 1. ПриступимВ первую очередь нужно узнать какие плагины приложение может загрузить. В одной сборке может быть несколько плагинов, а нам необходимо обеспечить для каждого плагина отдельный домен. Поэтому нужно написать загрузчик информации, который тоже будет работать в отдельном домене, и по окончании работы загрузчика этот домен будет выгружен.

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

[Serializable] struct PluginInfo { private string assemblyPath; private string typeName;

public PluginInfo (string assemblyPath, string typeName) { this.assemblyPath = assemblyPath; this.typeName = typeName; }

public string AssemblyPath { get { return assemblyPath; } } public string TypeName { get { return typeName; } } } Сам загрузчик информации. Можете обратить внимание, что класс Proxy наследуется от MarshalByRefObject, т.к. его поля будут использоваться для входных и выходных параметров. А сам он будет создан в домене загрузчика.

class PluginInfoLoader { private class Proxy: MarshalByRefObject { public string[] PluginLibs { get; set; } public string FullTypeName { get; set; }

public List PluginInfos { get; set; }

public void LoadInfos () { foreach (var assemblyPath in PluginLibs) { var assembly = AppDomain.CurrentDomain.Load (AssemblyName.GetAssemblyName (assemblyPath).FullName); foreach (var type in assembly.GetExportedTypes ()) { if (type.IsAbstract) continue;

var currentBaseType = type.BaseType; while (currentBaseType!= typeof (object)) { if (string.Compare (currentBaseType.FullName, FullTypeName, StringComparison.OrdinalIgnoreCase) == 0) { PluginInfos.Add (new PluginInfo (assemblyPath, type.FullName)); break; } currentBaseType = currentBaseType.BaseType; } } } } }

public List LoadFrom (string typeName, string[] inputPluginLibs) { var domainSetup = new AppDomainSetup (); domainSetup.ApplicationBase = AppDomain.CurrentDomain.BaseDirectory; domainSetup.PrivateBinPath = «plugins; bin»;

var permmisions = new PermissionSet (PermissionState.None); permmisions.AddPermission (new ReflectionPermission (ReflectionPermissionFlag.MemberAccess)); permmisions.AddPermission (new SecurityPermission (SecurityPermissionFlag.Execution)); permmisions.AddPermission (new UIPermission (UIPermissionWindow.AllWindows)); permmisions.AddPermission (new FileIOPermission (FileIOPermissionAccess.PathDiscovery | FileIOPermissionAccess.Read, inputPluginLibs));

List result;

var pluginLoader = AppDomain.CreateDomain («Plugin loader», null, domainSetup, permmisions); try { var engineAssemblyPath = Path.Combine (AppDomain.CurrentDomain.BaseDirectory, @«bin\Engine.dll»); var proxy = (Proxy)pluginLoader.CreateInstanceAndUnwrap (AssemblyName.GetAssemblyName (engineAssemblyPath).FullName, typeof (Proxy).FullName);

proxy.PluginInfos = new List(); proxy.PluginLibs = inputPluginLibs; proxy.FullTypeName = typeName;

proxy.LoadInfos ();

result = proxy.PluginInfos; } finally { AppDomain.Unload (pluginLoader); }

return result; } } Для ограничения возможностей загрузчика, в домен к нему я передаю набор разрешений. Как видно в листинге устанавливается 3 разрешения:

ReflectionPermission разрешение на использование отражений. SecurityPermission разрешение на выполнение управляемого кода. FileIOPermission разрешение на чтение файлов переданных во втором параметре. Некоторым из разрешений (почти всем) можно указать как частичный доступ, так и полный. Частичный дается с помощью конкретных для каждого разрешения перечислений. Для полного доступа или, наоборот, запрета можно отдельно передать состояние: PermissionState.None — для запрета.PermissionState.Unrestricted — для полного разрешения.Детальнее о том какие еще есть разрешения можно почитать тут. Также можно посмотреть какие параметры у доменов по умолчанию вот здесь.

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

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

Плагины:

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

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

Вот собственно базовый класс плагина:

public abstract class Plugin: CrossDomainObject where TModel: CrossDomainObject { public static TModel Model { get; private set; }

private Thread processThread;

public void Initialize (TModel model) { Model = model; processThread = new Thread (ProcessThreadHandler); processThread.IsBackground = true; processThread.Start ();

Initialize (); }

private void ProcessThreadHandler () { while (true) { Thread.Sleep (TimeSpan.FromMinutes (1));

Model.Process (); OnProcess (); } }

public abstract string Name { get; } protected abstract void Initialize (); protected virtual void OnProcess () { } } CrossDomainObject — это объект который содержит только 1 метод — Process, обеспечивающий продление времени жизни представителя. Со стороны чата менеджер плагинов раз в минуту вызывает его у всех плагинов. Со стороны плагина, он сам обеспечивает вызов метода Process у обертки модели.

public abstract class CrossDomainObject: MarshalByRefObject { public void Process () { } } Базовые классы для серверного и клиентского плагина:

public abstract class ServerPlugin: Plugin { public abstract List Commands { get; } }

public abstract class ClientPlugin: Plugin { public abstract List Commands { get; } public abstract string MenuCaption { get; } public abstract void InvokeMenuHandler (); } Менеджер плагинов ответственен за выгрузку, загрузку плагинов и владение ими.Рассмотри загрузку:

private void LoadPlugin (PluginInfo info) { var domainSetup = new AppDomainSetup (); domainSetup.ApplicationBase = AppDomain.CurrentDomain.BaseDirectory; domainSetup.PrivateBinPath = «plugins; bin»;

var permmisions = new PermissionSet (PermissionState.None); permmisions.AddPermission (new UIPermission (PermissionState.Unrestricted));

permmisions.AddPermission (new SecurityPermission ( SecurityPermissionFlag.Execution | SecurityPermissionFlag.UnmanagedCode | SecurityPermissionFlag.SerializationFormatter | SecurityPermissionFlag.Assertion));

permmisions.AddPermission (new FileIOPermission ( FileIOPermissionAccess.PathDiscovery | FileIOPermissionAccess.Write | FileIOPermissionAccess.Read, AppDomain.CurrentDomain.BaseDirectory));

var domain = AppDomain.CreateDomain ( string.Format («Plugin Domain [{0}]», Path.GetFileNameWithoutExtension (info.AssemblyPath)), null, domainSetup, permmisions);

var pluginName = string.Empty; try { var plugin = (TPlugin)domain.CreateInstanceFromAndUnwrap (info.AssemblyPath, info.TypeName); pluginName = plugin.Name;

if (plugins.ContainsKey (pluginName)) { AppDomain.Unload (domain); return; }

plugin.Initialize (model);

var container = new PluginContainer (domain, plugin); plugins.Add (pluginName, container);

OnPluginLoaded (container); } catch (Exception e) { OnError (string.Format («plugin failed: {0}», pluginName), e); AppDomain.Unload (domain); return; } } Аналогично загрузчику, в начале мы инциализируем и создаем домен. Далее уже с помощью метода AppDomain.CreateInstanceFromAndUnwrap создаем объект. После его создания имя плагина анализируется, если такой уже был добавлен, то домен вместе с плагином выгружается. Если же такого плагина нет — он инициализируется.

Детальнее код менеджера можно посмотреть тут.

Одной из проблем, которая решилась достаточно просто, было предоставление доступа плагинов к модели. Корень модели у меня статический, и в другом домене он будет не инициализирован, т.к. типы и статические поля у каждого домена свои.Решилась проблема написанием обертки, в которую сохраняются объекты модели, и продвигается уже экземпляр этой обертки. Модельным объектам только необходимо было добавить в базовые классы MarshalByRefObject. Исключение это клиентский и серверный (серверный просто из симметрии) API которые пришлось также обернуть. Клиентский API создается после менеджера плагинов, и в момент загрузки дополнений его еще просто нет. Пример клиентской обертки.

Для клиентских и серверных плагинов я написал 2 различных менеджера, которые реализуют базовый PluginManager. У обоих есть метод TryGetCommand, который вызывается в соответствующем API, если не найдена родная команда с таким айдишником. Ниже реализация метода API GetCommand.

Код public IClientCommand GetCommand (byte[] message) { if (message == null) throw new ArgumentNullException («message»);

if (message.Length < 2) throw new ArgumentException("message.Length < 2");

ushort id = BitConverter.ToUInt16(message, 0);

IClientCommand command; if (commandDictionary.TryGetValue (id, out command)) return command;

if (ClientModel.Plugins.TryGetCommand (id, out command)) return command;

return ClientEmptyCommand.Empty; } Написание плагина: Теперь на основе написанного кода, можно попробовать реализовать плагин.Напишу я плагин который, по нажатию на пункт меню, открывает окно с кнопкой и текстовым полем. В обработчике кнопки будет посылаться команда юзеру, ник которого мы ввели в поле. Команда будет делать снимок и сохранять его в папку. После этого выкладывать его в главную комнату и оправлять нам ответ.Это будет P2P взаимодействие, поэтому написание серверного плагина не понадобится.

Для начала создадим проект, выберем библиотеку классов. И добавим к нему в ссылки 3 основные сборки: Engine.dll, Lidgren.Network.dll, OpenAL.dll. Не забудьте поставить правильную версию .NET Framework, я собираю чат под 3.5, и соответственно плагины тоже должны быть такой же версии, или ниже.

Далее реализуем основной класс плагина, который предоставляет 2 команды. А по обработчику пункта меню открывает диалоговое окно.Стоит отметить что менеджеры плагинов на своей стороне кешируют команды, поэтому необходимо чтобы плагин удерживал на них ссылки. И свойство Commands возвращало одни и те же экземпляры команд.

public class ScreenClientPlugin: ClientPlugin { private List commands;

public override List Commands { get { return commands; } }

protected override void Initialize () { commands = new List { new ClientMakeScreenCommand (), new ClientScreenDoneCommand () }; }

public override void InvokeMenuHandler () { var dialog = new PluginDialog (); dialog.ShowDialog (); }

public override string Name { get { return «ScreenClientPlugin»; } }

public override string MenuCaption { get { return «Сделать скриншот»; } } } Диалоговое окно выглядит вот так: d9b0d9598a9e41078425be433633262e.png

Код