Writing plugins with AppDomain is fun

    How often have you written plugins for your applications?

    In the article I want to tell how you can write plugins using AppDomain, and cross-domain operations. We will write plugins for my existing TCPChat application.

    Who wants to bike - forward under the cat.

    Chat is here .
    And you can read about the application architecture here . In this case, we are only interested in the model. Crucially it has not changed, and will be enough to know about the basic nature ( root model / A the PI / Coma technological command ).

    About what needs to be implemented in the application:

    It is necessary to be able to expand the set of commands using plug-ins, while the code in the plug-ins should run in another domain.
    Obviously, the commands will not be called by themselves, so you also need to add the ability to change the UI. To do this, we will provide the opportunity to add menu items, as well as create your own windows.

    At the end, I will write a plug-in with which you can remotely take a screenshot of any user.

    What is AppDomain for?

    An application domain is needed for executing code with limited rights, as well as for unloading libraries while the application is running. As you know, assemblies cannot be unloaded from the application domain, but the domain, please.

    In order to unload a domain it was possible, the interaction between them was minimized.
    In fact, we can:
    • Execute code in another domain.
    • Create an object and promote it by value.
    • Create an object and promote it by reference.

    A little bit about promotion:

    Promotion can occur by reference or by value.
    With value, everything is relatively simple. The class is serialized in one domain, transferred by an array of bytes to another, deserialized, and we get a copy of the object. In this case, the assembly must be loaded into both domains. If it is necessary that the assembly is not loaded into the main domain, it is better that the folder with the plugins is not added to the list of folders where your application will look for the default assembly (AppDomain.BaseDirectory / AppDomainSetup.PrivateBinPath). In this case, there will be an exception that the type could not be found, and you will not receive the silently loaded assembly.

    To perform a link promotion, a class must implement MarshalByRefObject. For each such object, after calling the CreateInstanceAndUnwrap method, a representative is created in the calling domain. This is an object that contains all the methods of this object (there are no fields there). In these methods, using special mechanisms, he calls the methods of the real object located in another domain and, accordingly, the methods are also executed in the domain in which the object was created. It is also worth saying that the life of representatives is limited. After creation, they live 5 minutes, and after each call to a method, their life time becomes 2 minutes. You can change the rental time, for this you can override the InitializeLifetimeService method on MarshalByRefObject .
    Link promotion does not require loading into the main assembly domain with the plugin.

    Digression about the fields:
    This is one of the reasons to use not open fields, but properties. You can get access to the field through a representative, but it all works much slower. Moreover, in order for this to work more slowly, it is not necessary to use cross-domain operations, it is enough to inherit from MarshalByRefObject.

    More about code execution: Code is

    executed using the AppDomain.DoCallBack () method .
    In this case, the delegate is promoted to another domain, so you need to be sure that this is possible.
    These are the little problems that I came across:
    1. This is an instance method, and the host class cannot be promoted. As you know, the delegate for each signed method stores 2 main fields, a reference to the class instance, as well as a pointer to the method.
    2. You used closures. By default, the class that the compiler creates is not marked as serializable and does not implement MarshalByRefObject . (Further see paragraph 1)
    3. If you inherit a class from MarshalByRefObject, create it in domain 1 and try to execute its instance method in another domain 2, then the domain border will be crossed 2 times and the code will be executed in domain 1.

    Let's get started

    First of all, you need to find out which plugins the application can download. There can be several plugins in one assembly, and we need to provide a separate domain for each plugin. Therefore, you need to write an information loader that will also work in a separate domain, and at the end of the loader, this domain will be unloaded.

    The structure for storing boot information about the plugin is marked with the Serializable attribute, because it will advance between domains.
      [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; } }
      }
    


    The loader of information itself. You may notice that the Proxy class inherits from MarshalByRefObject, as its fields will be used for input and output parameters. And he will be created in the bootloader domain.

      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;
        }
      }
    


    To limit the capabilities of the bootloader, I transfer a set of permissions to the domain to it. As you can see in the listing, 3 permissions are set:
    • ReflectionPermission permission to use reflections.
    • SecurityPermission permission to execute managed code.
    • FileIOPermission permission to read files passed in the second parameter.

    Some of the permissions (almost all) can be specified as partial access, or full. Partial is given using specific listings for each resolution. For full access or, on the contrary, for a ban, you can separately transfer the state:
    PermissionState.None - for a ban.
    PermissionState.Unrestricted - for full permission.

    More details about what other permissions you can read here . You can also see what parameters the default domains have here .

    In the method for creating a domain, I pass an instance of the AppDomainSetup class. Only 2 fields are set for him, by which he understands where he needs to look for assemblies by default.

    Further, after the unremarkable creation of the domain, we call the CreateInstanceAndUnwrap method on it, passing the full name of the assembly and type to the parameters. The method will create an object in the bootloader domain and perform the promotion, in this case, by reference.

    Plugins:

    Plugins in my implementation are divided into client and server. Server-side only provide commands. A separate menu item will be created for each client plug-in and it, like the server one, can issue a set of chat commands.

    Both plugins have an initialization method in which I push the wrapper over the model and save it in a static field. Why is this not done in the constructor?
    The name of the plugin being loaded is unknown and it will be detected only after the object is created. Suddenly a plugin with that name has already been added? Then it must be unloaded. If there is no namesake plugin yet, then initialization is performed. This ensures initialization only in case of a successful download.

    Here is the base class of the plugin itself:

      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 is an object that contains only 1 method - Process, which ensures the extension of the representative's lifetime. On the chat side, the plugin manager calls it once per minute for all plugins. On the part of the plugin, it itself provides a call to the Process method on the model wrapper.

      public abstract class CrossDomainObject : MarshalByRefObject
      {
        public void Process() { }
      }
    


    Base classes for server and client plug-in:

      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();
      }
    


    The plugin manager is responsible for uploading, downloading, and owning the plugins.
    Consider the download:

        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;
            }
        }
    


    Similar to the bootloader, at the beginning we initialize and create a domain. Next, using the AppDomain.CreateInstanceFromAndUnwrap method, create an object. After its creation, the name of the plugin is analyzed, if one has already been added, then the domain together with the plugin is unloaded. If there is no such plugin, it is initialized.

    You can see the manager code in more detail here .

    One of the problems that was solved quite simply was the provision of plug-ins access to the model. My model root is static, and in another domain it will not be initialized, because Types and static fields for each domain are different.
    The problem was solved by writing a wrapper in which the objects of the model are saved, and an instance of this wrapper is already advancing. Model objects only needed to be added to the base classes MarshalByRefObject. An exception is the client and server (server just from symmetry) APIs that also had to be wrapped. The client API is created after the plug-in manager, and at the time of downloading add-ons it simply does not exist yet. An example of a client wrapper .

    For client and server plug-ins, I wrote 2 different managers that implement the basic PluginManager. Both have a TryGetCommand method, which is called in the corresponding API if a native command with such an ID is not found. Below is an implementation of the GetCommand API method.

    The code
        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;
        }
    


    Writing a plugin:

    Now based on the written code, you can try to implement a plugin.
    I will write a plugin which, by clicking on a menu item, opens a window with a button and a text field. In the button handler, a command will be sent to the user whose nickname we entered in the field. The team will take a picture and save it to a folder. After that, put it in the main room and send us the answer.
    This will be a P2P interaction, so writing a server plug-in is not necessary.

    First, create a project, select a class library. And add to it the links 3 main assemblies: Engine.dll, Lidgren.Network.dll, OpenAL.dll. Do not forget to put the correct version of the .NET Framework, I collect chat for 3.5, and accordingly the plugins must also be the same version, or lower.

    Next, we implement the main class of the plugin, which provides 2 commands. And on the menu item handler, it opens a dialog box.
    It is worth noting that plugin managers cache commands on their side, so it is necessary that the plugin maintain links to them. And the Commands property returned the same command instances.

      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 "Сделать скриншот"; }
        }
      }
    


    The dialog box looks like this:


    The code


      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);
        }
      }
    



    When transferring files, I used the already written chat functionality using the 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);
        }
      }
    


    The most interesting thing happens in the last 3 lines. Here we use the API adding a file to the room. After that we send the command response. The peer is called method overload, which takes a set of bytes, because our object cannot be serialized in the main assembly of the chat.

    The following is the implementation of the command that will receive the response. She will announce to the whole main room that we took a screenshot of a poor user.

      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(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; } }
        }
      }
    


    I can post the full project with the plugin, but I don’t know where. (For a separate repository on the github, it is very small, it seems to me).

    UPD : posted the plugin on github .

    UPD 2 : The article uses pulling to support plug-in lifetimes, which is strange. In a github implementation, I later implemented a normal model.

    Also popular now: