[Arquitectura] Como construir un sistema de Plug-ins (Parte 1)

En esta serie voy a explicar todo lo referente a la creación de sistemas de plug-ins, pasando por su arquitectura, aspectos de isolation, seguridad, etc.

Introducción

No hace falta que explique aquí que es un plug-in, ¿verdad?. Así que comenzaré planteando un ejemplo y listando algunas características que todo sistema que soporte plugins debe tener.

Un ejemplo

Para esta entrada he creado un servicio de windows el cual vigila un directorio específico a la espera de cualquien cambio en él, como por ejemplo, la creación de un nuevo archivo, la eliminación de uno existente, renombrado de archivos, cambios en sus propiedades, tamaños, fechas de último acceso o escritura, etc.

Este servicio es sumamente útil para tareas de integración de aplicaciones, al mejor estilo Biztalk Server. La idea es que una vez detectado un cambio en un archivo del directorio que hemos especificado, este servicio recorra la lista de plugins instalados y les comunique el evento de modo que cada uno de ellos realizen la acción para la que fueron desarrollados. Estas acciones podrían ser:

  • Mediante EventLogger, mantener un tracking de los cambios en los archivos,
  • Enviar un email con el contenido del archivo (si es de texto claro),
  • Implementar una segunda papelera de reciclaje,
  • Mantener versiones de los archivos,
  • Organizar los archivos que bajamos de interner en subcarpetas según ciertos criterios,
  • Salvar el contenido de un nuevo archivo en una base de datos o desencadenar algún otro proceso,
  • Hacer de demonio de impresión,
  • etc.

El código de ejemplo se encuentra aquí: FileSystemWatcherService.zip

Características

Todo software que soporte plug-ins debe considerar os siguientes puntos importantes:

  • Isolation: cada plugin debe cargarse y correr en un AppDomain separado. El plugin no debe tener acceso al host o debe estar bien planeado, en el ejemplo FileSystemWatcherService.zip la comunicación es unidireccional HOST->PLUGIN.
  • Tolerancia o manejo de fallos: cuando un plugin no funciona o lanza excepciones no esperadas debe descargarse.
  • Seguridad: el plugin debe tener los mínimos privilegios necesarios para correr. Estos privilegios varian según el tipo de aplicación que estamos haciendo, por ejemplo, si en este ejemplo los plugins corrieran bajo un Internet Security Zone, ninguno de las tareas que arriba listo podrían realizarse pero lo cierto es que la mayoria de los plugins no deberían tener más que esos privilegios y en su defecto solo permitirles ejecutarse y nada más.
  • Detección de nuevos plugins o nuevas versiones de los instalados: no es buena idea tener que reiniciar una aplicación o un servicio para instalar un nuevo plugin. En muchos casos, como el de ejemplo nuestro ejemplo, la instalción de nuevos plugins se hace vigilando un directorio «Plugins» para detectar cambios e los archivos «.dll» que contiene. Cuando detecta algún cambio simplemente descarga los plugins instalados y los carga de nuevo. Esto evita además el tener que realizar cambios en los archivos de configuración o en el registro de windows. Esta es a mi manera de entender, la mejor opción para manejar la carga de plugins. Otros productos trabajan de igual manera como LiveWriter y Notepad++.

 

Arquitectura

Isolation

Como dijimos arriba, es importante que cada plugin corra en un AppDomain propio separado del AppDomain del Host. Para desacoplar dos partes de un sistema, ambas deben conocer y respetar ciertos contratos, es decir que las dos partes deben compartir un conjunto de interfaces. Este patrón se llama Service Interface cuyos componentes se pueden ver en la figura de abajo.

ServiceInteface 
Tomado de MSDN->MSDN Library->Servers and Enterprise Devepment->Enterprise Architecture, Patterns and Practices->Microsoft patterns and practices->Guides->Enterprise Solution Patterns Using Mricrosoft .NET->Service Patterns

Para decirlo de manera facil, tanto el Consumer como el Provider comparten al menos un assembly con las interfaces (contratos). En la figura de arriba también podemos ver un Service Gateway que implementa la cumunicacion desde el Customer hacia el Provider. A diferencia de como se implementa en la página a la que hago referencia, mediante WS, en el ejemplo que proveo lo hago mediante una clase Gateway que hereda de MarshalByRefObject en el assembly compartido FileSystemWatcherService.Interfaces.dll.

Básicamente existe una interface que debe implementar todo plugin, la interface IPlugin la cual posee un método Execute(IMessage message). El código:

C# Code
namespace FileSystemWatcherService.Interfaces
{
    public interface IPlugin
    {
        bool Execute(IWatchMessage message);
    }
}

Mientras que el Gateway se implementa mediante una clase «Gateway» que hereda de MarshalByRefObject para poder cruzar los límites del AppDomain. Esta clase contiene solo un método LoadPlugin que dado el nombre de un assembly, lo carga y mediante reflection busca un tipo que implemente la interface IPlugin y que esté decorado con el atributo PluginAttribute. Si encuentra un tipo que satisfaga estas condiciones, crea una instancia de este y retorna un objeto PluginInfo que contendrá una referencia al plugin e información del mismo que se detalla mediante el PluginAttribute.

C# Code
using System;
using System.Reflection;

namespace FileSystemWatcherService.Interfaces
{
    public sealed class Gateway : MarshalByRefObject
    {
        public PluginInfo LoadPlugin(string assemblyFilename)
        {
            Assembly dynamic = Assembly.LoadFrom(assemblyFilename);
            Type typeofIPlugin = typeof(IPlugin);
            foreach (Type type in dynamic.GetExportedTypes())
            {
                if (typeofIPlugin.IsAssignableFrom(type))
                {
                    PluginAttribute pluginAttr = null;
                    foreach (Attribute attr in type.GetCustomAttributes(false))
                    {
                        pluginAttr = attr as PluginAttribute;
                        if (pluginAttr != null) break;
                    }

                    if (pluginAttr == null) 
                        throw new PluginInformationMissing();
                    
                    ConstructorInfo constructor = type.GetConstructor(Type.EmptyTypes);
                    if (constructor != null)
                    {
                        IPlugin instance = (IPlugin)constructor.Invoke(null);
                        return new PluginInfo(instance, pluginAttr); 
                    }
                }
            }
            return null;
        }
    }
}

El Plugin

por ejemplo, aquí va un plugin sencillo que mantiene un registro de los archivos txt que han cambiado en el registro de eventos de windows.

C# Code
using System;
using System.Diagnostics;
using FileSystemWatcherService.Interfaces;

namespace Logger.Plugins
{
    [Serializable]
    [PluginAttribute("Logger", Mask="*.txt", Description="Log *.txt file changes ")]
    public class Logger : IPlugin
    {
        private readonly EventLog eventLogger;

        public Logger()
        {
            eventLogger = new EventLog();
            eventLogger.Log = "Logger plugin";
            eventLogger.Source = "LoggerPlugin Events";      
        }

        public bool Execute(IWatchMessage message)
        {
            try
            {
                eventLogger.WriteEntry(
                    string.Format("El archivo '{0}' fue tocado", message.FileName), EventLogEntryType.Information, 0, 0);
                return true;
            }
            catch (Exception)
            {
                return false;
            }
        }
    }
}

Esto es todo lo relevante en cuanto al Provider y al Service Interface. Ahora veamos un poco la arquitectura necesaria del lado del Consumer.

La aplicación cliente

Esta es la parte más interesante!. Y se compone de las siguientes clases que implementan funcionalidades necesarias. Aunque existen, como siempre, muchas formas de implementar los mismo, yo propongo la siguiente.

Las clases y sus funcionalidades:

Clases Responsabilidades
PluginLoader Un singleton encargado de realizar la primera carga de los assemblies (plugins) y de vigilar los eventos sobre la carpeta «Plugins» y cuando detecta un cambio descargar los plugins y los cargalos de nuevo. Esta clase mantiene una instancia de PluginsContainer.
PluginsContainer Es una colección de PluginControllers que se encarga de mantener la lista de plugins cargados, registrar y desregistrar/descargar plugins.  Este es el encargado de recorrer la lista de plugin controllers y pedirles que invoquen a los plugins.
PluginController Mantiene una referencia al plugin como así también información sobre el nombre del assembly que lo contiene y el AppDomain en donde se cargó. Esta clase también es la encargada de invocar al método Execute() del plugin con el mensaje adecuado y debería ser capaz (no lo es en este ejemplo) de controlar el tiempo de ejecución del mismo. Controla también que el Plugin no dispare una excepción y si lo hace simplemente descarga el AppDomain en el que se encuentra el Plugin.

El resto de las clases son de soporte. Como ConfigurationSectionHandler  para leer el archivo de configuración, DebugHelpers helpers para depurar principalmente para vigilar que la aplicación no cargue los assemblies en el AppDomain del Host, FileMaskHelper  para chequear si el archivo que cambió concuerda con la máscara que puede manejar un plugin, y Program es el entry point de la aplicación.

En el siguiente código hay que notar que luego de crear el AppDomain se obtiene un proxy trnsparente de la clase Gateway mediante:
CreateInstanceAndUnwrap(«FileSystemWatcherService.Interfaces», «FileSystemWatcherService.Interfaces.Gateway»);

Para más información (aunque no mucha) vea la documentación de este método. Lo único que hay que resaltar es la siguiente nota encontrada en la documentación:

Note:

If you make an early-bound call to a method M of an object of type T1 that was returned by CreateInstanceAndUnwrap, and that method makes an early-bound call to a method of an object of type T2 in an assembly C other than the current assembly or the assembly containing T1, assembly C is loaded into the current application domain. This loading occurs even if the early-bound call to T1.M() was made in the body of a DynamicMethod, or in other dynamically generated code. If the current domain is the default domain, assembly C cannot be unloaded until the process ends. If the current domain later attempts to load assembly C, the load might fail.

 

C# Code
        private static PluginController LoadAssembly(string assemblyName)
        {
            AppDomain pluginAppDomain = null;
            try
            {
                pluginAppDomain = AppDomain.CreateDomain(assemblyName);
                DebugHelpers.AssembliesInAppDomain("Before create AuxiliaryDomain");
                Gateway gateway = (Gateway)pluginAppDomain.CreateInstanceAndUnwrap("FileSystemWatcherService.Interfaces", "FileSystemWatcherService.Interfaces.Gateway");
                PluginInfo pluginInfo = gateway.LoadPlugin(assemblyName);
                DebugHelpers.AssembliesInAppDomain("After AuxiliaryDomain was created");
                return new PluginController(pluginInfo, pluginAppDomain, assemblyName);
            }
            catch(Exception)
            {
                DebugHelpers.AssembliesInAppDomain("After exception");
                if (pluginAppDomain != null) AppDomain.Unload(pluginAppDomain);
                return null;
            }
        }

Faltan resolver algunos problemas como el de determinar el conjunto de permisos con los cuales deben correr los plugins, controlar el tiempo de ejecución de los plugins para poder descargarlos si por ejemplo entran en un bucle infinito, resolver el problema de que el FileSystemWatcher de vez en cuando lanza varias veces el mismo evento lo que puede hacer que los plugins se descarguen y carguen muchas veces. También hay que refinar el catcheo de las excepciones pero este es solo un ejemplo 🙂

Continuará….

Lucas Ontivero

Sin categoría

7 thoughts on “[Arquitectura] Como construir un sistema de Plug-ins (Parte 1)

  1. Aun cuando la comunicacion es unidireccional, hay objetos que no pueden serializarse y en diferentes aplicaciones de dominio no pueden enviarse.

    A ver si me explique, voy a exponerlo asi.

    Mi plugin establece un contrato, el cual solo tiene que cargar el plugin y descargarlo, este plugin es un Formulario.

    La interfaz contiene una propieda de tipo Form que representa aun MdiParent el cual se va a enlazar al formulario del plugin.

    Cuando se carguen los plugins le paso el formulario princial al objeto creado dinamicamente.

    Pero envia errores puesto que Form no puede serializarse.

    Asi como este hay algunas cosas que hay que planear bien.

    Solo era un comentario, y esta bueno tu aporte 😉

  2. Hola HardBit! Gracias por comentar aquí. En realidad creo que te refieres al error «….Class is not marked as
    serializable» o «….Class is not serializable», no recuerdo exactamente el mensaje de error (excepción) pero en realidad lo que decis no es exacto. Con 5 lineas de código he modificado el ejemplo para que pase un form entre appdomains. Podes bajarlo desde descargas y buscar la carpeta lontivero.
    Saludos.

  3. Perdón HardBit, en primer lugar una aclaración: no son 5 lineas sino 15 o 20, en realidad no se bien pero son muy pocas. Lo digo porque parece que queda como arrogante y esa no es la idea.
    El link para descargar el cófigo es:
    http://geeks.ms/files/folders/lontivero/entry42779.aspx

    Paso un form para un solo lado pero como te darás cuenta, pasarlo en sentido contrario es lo mismo.
    Te mando otro saludo y espero que leas esto si no habré perdido un poco de tiempo. 🙂
    Saludos.

  4. Hola Lucas.

    Creo que no me explique bien, otra vez jejej, me suele pasar cuando escribo en mi hora de comida.

    La cuestion esta al enlazar un MdiParent de un form que viene de un plugin que se encuentra en un AppDomain con el Formulario principal que se encuentra en el AppDomain principal (host)

    Queda claro que es un error mio de diseño, lo resolvi de una manera «chafa»(como decimos en mexico) pero me parecio una mejor opcion.

    Aclaro que esto no es nuevo, hace unos meses realize un motor para cargar reportes dinamicamente y vi tu post y me gusto.

    Gracias por responder al comentario 😉

  5. Hola U. Gracias por postear un comentario.
    Sobre si habrá o no segunda parte algún dia… la verdad que no lo sé. No se si habrá otros posts. No obstante, la segunda parte era basicamente sobre seguridad y descarga de plug-ins que no cumple las políticas y eso (al menos la parte de seguridad) lo puedes encontrar en cualquier lado.

    Un abrazo.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *