Писати плагіни з AppDomain - весело

Як часто ви писали плагіни для своїх програм?

У статті я хочу розповісти як можна написати плагіни використовуючи AppDomain, і крос доменні операції. Плагіни будемо писати для вже існуючого мого додатка TCPChat.

Хто хоче повелосипедить — вперед під кат.

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

Про те, що необхідно реалізувати в додатку:

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

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

Для чого потрібен AppDomain?

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

Для того, щоб вивантажувати домен було можливо, взаємодія між ними зведено до мінімуму.
По суті ми можемо:
  • Виконати-код в іншому домені.
  • Створити об'єкт і просунути його по значенню.
  • Створити об'єкт і просунути його по посиланню.
Трохи про просування:

Просування, може відбуватися за посиланням або значенням.
Зі значенням все відносно просто. Клас серіалізуются в одному домені, передається масив байтів у інший, десериализуется, і ми отримуємо копію об'єкта. При цьому необхідно щоб збірка була завантажена в обидва домена. Якщо необхідно, щоб збірка не вантажилася в основний домен, то краще щоб папку з плагінами не була додана до списку папок, де ваш додаток буде шукати складання за замовчуванням (AppDomain.BaseDirectory / AppDomainSetup.PrivateBinPath). В такому випадку буде виняток про те, що тип не вдалося знайти, і ви не отримаєте мовчки завантажену збірку.

Для виконання просування по посиланню клас повинен реалізовувати MarshalByRefObject. Для кожного такого об'єкту, після виклику методу CreateInstanceAndUnwrap, в зухвалій домені створюється представник. Це об'єкт який містить всі методи цього об'єкта (полів при цьому там немає). В цих методах, за допомогою спеціальних механізмів він викликає методи цього об'єкта, що знаходиться в іншому домені, і відповідно методи теж виконуються в домені в якому об'єкт був створений. Також варто сказати, що час життя представників обмежена. Після створення вони живуть 5 хвилин, і після кожного виклику якого-небудь методу, їх час життя стає 2 хвилини. Час оренди можна змінити, для цього у MarshalByRefObject можна перевизначити метод InitializeLifetimeService.
Просування по посиланню не вимагає завантаження в основний домен складання з плагіном.

Відступ про поля:
Це одна з причин користуватися не відкритими полями, а властивостями. Доступ до поля через представник отримати можна, але працює це все набагато повільніше. Причому, для того щоб це працювало повільніше, не обов'язково використовувати крос-доменних операції, досить унаследоваться від MarshalByRefObject.

Детальніше про виконання коду:

Виконання коду відбувається з допомогою методу AppDomain.DoCallBack().
При цьому виконується просування делегата в інший домен, тому потрібно бути впевненим, що це можливо.
Це невеликі проблеми на які я натрапив:
  1. Це экземплярный метод, а клас-господар не може бути просунуть. Як відомо делегат для кожного підписаного методу зберігає 2 основних поля, посилання на екземпляр класу, а також вказівник на метод.
  2. Ви використовували замикання. За замовчуванням клас який створює компілятор не позначається як сериализуемый і не реалізовує MarshalByRefObject. (Далі див. пункт 1)
  3. Якщо успадкувати клас від 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, @"bin\Engine.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="Авто"/>
<RowDefinition Height="Авто"/>
</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; } }
}
}


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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.