Как часто вы писали плагины для своих приложений?
В статье я хочу рассказать как можно написать плагины используя 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<PluginInfo> 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<PluginInfo> 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<PluginInfo> result;
var pluginLoader = AppDomain.CreateDomain("Plugin loader", null, domainSetup, permmisions);
try
{
var engineAssemblyPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"binEngine.dll");
var proxy = (Proxy)pluginLoader.CreateInstanceAndUnwrap(AssemblyName.GetAssemblyName(engineAssemblyPath).FullName, typeof(Proxy).FullName);
proxy.PluginInfos = new List<PluginInfo>();
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<TModel> : 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<ServerModelWrapper>
{
public abstract List<ServerPluginCommand> Commands { get; }
}
public abstract class ClientPlugin :
Plugin<ClientModelWrapper>
{
public abstract List<ClientPluginCommand> 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<ClientPluginCommand> commands;
public override List<ClientPluginCommand> Commands { get { return commands; } }
protected override void Initialize()
{
commands = new List<ClientPluginCommand>
{
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 "Сделать скриншот"; }
}
}
Диалоговое окно выглядит вот так:
<Window x:Class="ScreenshotPlugin.PluginDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Screen"
SizeToContent="WidthAndHeight"
ResizeMode="NoResize">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBox Grid.Row="0"
Margin="10, 10, 10, 5"
MinWidth="200"
Name="UserNameTextBox"/>
<Button Grid.Row="1"
Margin="10, 5, 10, 10"
Padding="5, 2, 5, 2"
Content="Сделать скриншот"
Click="Button_Click"/>
</Grid>
</Window>
public partial class PluginDialog : Window
{
public PluginDialog()
{
InitializeComponent();
}
private void Button_Click(object sender, RoutedEventArgs e)
{
ScreenClientPlugin.Model.Peer.SendMessage(UserNameTextBox.Text, ClientMakeScreenCommand.CommandId, null);
}
}
При пересылке файлов я воспользовался уже написанной функциональностью чата, с помощью API.
public class ClientMakeScreenCommand : ClientPluginCommand
{
public static ushort CommandId { get { return 50000; } }
public override ushort Id { get { return ClientMakeScreenCommand.CommandId; } }
public override void Run(ClientCommandArgs args)
{
if (args.PeerConnectionId == null)
return;
string screenDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "screens");
if (!Directory.Exists(screenDirectory))
Directory.CreateDirectory(screenDirectory);
string fileName = Path.GetFileNameWithoutExtension(Path.GetRandomFileName()) + ".bmp";
string fullPath = Path.Combine(screenDirectory, fileName);
using (Bitmap bmpScreenCapture = new Bitmap(Screen.PrimaryScreen.Bounds.Width, Screen.PrimaryScreen.Bounds.Height))
using (Graphics graphic = Graphics.FromImage(bmpScreenCapture))
{
graphic.CopyFromScreen(
Screen.PrimaryScreen.Bounds.X,
Screen.PrimaryScreen.Bounds.Y,
0, 0,
bmpScreenCapture.Size,
CopyPixelOperation.SourceCopy);
bmpScreenCapture.Save(fullPath);
}
ScreenClientPlugin.Model.API.AddFileToRoom(ServerModel.MainRoomName, fullPath);
var messageContent = Serializer.Serialize(new ClientScreenDoneCommand.MessageContent { FileName = fullPath });
ScreenClientPlugin.Model.Peer.SendMessage(args.PeerConnectionId, ClientScreenDoneCommand.CommandId, messageContent);
}
}
Самое интересное происходит в последних 3-ех строчках. Здесь мы используем API добавляя в комнату файл. После этого отправляем команду ответ. У пира вызывается перегрузка метода, принимающая набор байт, т.к. наш объект не сможет быть сериализован в основной сборке чата.
Ниже приведена реализация команды, которая будет принимать ответ. Она огласит на всю главную комнату, о том что мы сделали снимок экрана у бедного пользователя.
public class ClientScreenDoneCommand : ClientPluginCommand
{
public static ushort CommandId { get { return 50001; } }
public override ushort Id { get { return ClientScreenDoneCommand.CommandId; } }
public override void Run(ClientCommandArgs args)
{
if (args.PeerConnectionId == null)
return;
var receivedContent = Serializer.Deserialize<MessageContent>(args.Message);
ScreenClientPlugin.Model.API.SendMessage(
string.Format("Выполнен снимок у пользователя {0}.", args.PeerConnectionId),
ServerModel.MainRoomName);
}
[Serializable]
public class MessageContent
{
private string fileName;
public string FileName { get { return fileName; } set { fileName = value; } }
}
}
Полный проект с плагином могу выложить, но не знаю куда. (Для отдельного репозитория на гитхабе, он сильно маленький, как мне кажется).
Автор: Nirvano