📜 ⬆️ ⬇️

Writing plugins with AppDomain is fun

How often have you written plugins for your applications?

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

Who wants to bike - go under the cat.

Chat is here .
And you can read about the architecture of the application here . In this case, we are only interested in the model. It has not changed fundamentally, and it will be enough to know about the main entities ( Root of the model / A PI / Coma ndy ).
')
About what needs to be implemented in the application:

It is necessary to have a possibility to extend the command set with the help of plug-ins, while the code in plug-ins should be executed 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 ability to add menu items, as well as create your own windows.

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

What is AppDomain for?

The application domain is needed to execute code with limited rights, as well as to unload libraries while the application is running. As you know, assemblies from the application domain cannot be unloaded, but please, the domain.

In order to unload the domain was possible, the interaction between them is minimized.
In fact, we can:

A little bit about promotion:

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

To perform a link promotion, the 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 a real object (there are no fields there). In these methods, with the help of special mechanisms, it calls the methods of a real object located in another domain and, accordingly, the methods are also performed in the domain in which the object was created. It is also worth saying that the lifetime of the representatives is limited. After creation, they live 5 minutes, and after each call of a method, their lifetime becomes 2 minutes. The lease time can be changed; for this, you can override the InitializeLifetimeService method in MarshalByRefObject.
Promotion through the link does not require uploading to the main domain of the assembly with a plugin.

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

More details about code execution:

Code execution is performed using the AppDomain.DoCallBack () method.
At the same time, the delegate moves to another domain, so you need to be sure that this is possible.
These are small problems that I stumbled upon:
  1. This is an instance method, and the host class cannot be advanced. As is known, the delegate for each signed method stores 2 main fields, a reference to an instance of the class, and a pointer to the method.
  2. You used closures. By default, the class that creates the compiler 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 boundary will be crossed 2 times and the code will be executed in domain 1.

Let's get started

First of all, you need to know which plug-ins the application can download. In one assembly there can be several plug-ins, and we need to provide for each plug-in a separate domain. Therefore, you need to write an information loader, which will also work in a separate domain, and after the loader has completed its work, this domain will be unloaded.

The structure for storing the boot information about the plugin is marked with the Serializable attribute, since it will be promoted 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; } } } 


Loader information itself. You can note that the Proxy class is inherited from MarshalByRefObject, because 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<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; } } 


To limit the loader's capabilities, I transfer a set of permissions to the domain to it. As you can see in the listing, 3 permissions are set:

Some of the permissions (almost all) can be specified as partial access, and full. Partial is given using specific permissions enumerations. For full access or, conversely, a ban, you can separately transfer the status:
PermissionState.None - to ban.
PermissionState.Unrestricted - for full resolution.

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

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

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

Plugins:

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

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 loaded plug-in is unknown and it will be detected only after the creation of the object. Suddenly a plugin with that name has already been added? Then it should be unloaded. If there is still no plug-in namesake, then initialization is performed. This ensures initialization only in case of successful loading.

This is the base class of the plugin itself:

  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 is an object that contains only 1 method - Process, which ensures the extension of the lifetime of the representative. From the chat side, the plug-in manager calls it once per minute to all plug-ins. From the side of the plugin, he himself calls 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<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(); } 


The plug-in manager is responsible for unloading, loading and owning plug-ins.
Consider downloading:

  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 loader, at the beginning we initialize and create a domain. Then, using the AppDomain.CreateInstanceFromAndUnwrap method, we create an object. After its creation, the name of the plugin is analyzed, if one has already been added, then the domain is unloaded along with the plugin. If there is no such plug-in, it is initialized.

More code manager can be found here .

One of the problems that was solved quite simply was the provision of plug-in 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 being promoted. Model objects only needed to be added to the MarshalByRefObject base classes. The exception is the client and server (server just out of symmetry) API that also had to be wrapped. The client API is created after the plug-in manager, and at the time of loading the add-ons it simply does not exist. An example of 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 the implementation of the GetCommand API method.

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 handler of the button, a command will be sent to the user, the nickname of which we entered in the field. The team will take a snapshot and save it to a folder. After that, post it in the main room and send us an answer.
This will be a P2P interaction, so you won't need to write a server plugin.

To begin, create a project, choose a class library. And add to it in 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 am going to have 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 opens a dialog box.
It is worth noting that the plug-in managers on their side are caching teams, so it is necessary for the plugin to keep links to them. And the Commands property returned the same command instances.

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


The dialog box looks like this:


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



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 to add a file to the room. After that we send the command response. At a peer, a method overload is called that takes a set of bytes, since our object cannot be serialized in the main chat build.

Below is the implementation of the command that will receive the answer. She will announce to the entire main room, that we have taken a screenshot of the 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<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; } } } } 


I can post a complete project with a plugin, but I don’t know where. (For a separate repository on the githab, it is very small, I think).

UPD : posted a plugin on github .

UPD 2 : The article uses pooling to support the lifetime of the plugins, which is weird. In the github implementation, I later implemented a normal model.

Source: https://habr.com/ru/post/242209/


All Articles