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

в 15:26, , рубрики: .net, appdomain, open source, plugins, архитектура, пример в приложении

Как часто вы писали плагины для своих приложений?

В статье я хочу рассказать как можно написать плагины используя 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, @"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 "Сделать скриншот"; }
    }
  }

Диалоговое окно выглядит вот так:
Писать плагины с AppDomain — весело - 1

Код

<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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js