Creando un FileSystemWatcher extendido

Aplicación que utiliza un FileSystemWatcher para vigilar directorios con algunas opciones muy interesantes.

 

Fecha: 02/Sep/2005 (18/Ago/2005)
Ultima actualización: 10/Abr/2006
Autor: Gerardo Contijoch (mazarin9@yahoo.com.ar)

 

Nota:
Este artículo fue actualizado por última vez el día 10/Abr/2006. Las actualizaciones en el artículo no son importantes, si ya lo leiste antes, no es necesario que lo vuelvas a hacer, en cambio, el codigo fuente fue actualizado y corregido, por lo que si quieres probarlo, te sugiero que lo bajes otra vez.


Hace un tiempo me vi en la necesidad de hacer una aplicación para vigilar un directorio utilizando un FileSystemWatcher. Lo que necesitaba era que al modificarse un archivo determinado, este se copiara automáticamente en un lugar que yo le especificara. No pasó mucho tiempo hasta que me di cuenta del potencial de esta aplicación, por lo que la modifiqué agregándole algunas características muy interesantes, como el filtrado de los archivos a vigilar y la posibilidad de realizar "acciones" sobre los archivos vigilados. Este artículo explica fue que la hice.

Conceptos iniciales

El FileSystemWatcher

Para los que no la conocen aún, FileSystemWatcher es una clase incluida en el .NET Framework que nos permite vigilar directorios y archivos. Cuando un cambio se produce en el directorio o archivo vigilado, es lanzado un evento, permitiéndonos monitorear los cambios.
Su uso es muy sencillo y se encuentra muy bien documentado aquí.

Los "Watches"

Para poder trabajar cómodamente con las modificaciones de archivos, creé una clase llamada Watch para que represente estos cambios. Un Watch tiene las siguientes propiedades:

PropiedadDescripción
PathPath del archivo que disparó el evento
PathAnteriorEn caso de que el archivo haya sido renombrado, path anterior del archivo
DescripcionDescripcion (no utilizado como descripción, sino como el nombre del archivo, pero puede ser utilizado para otra cosa ya que el nombre del archivo se encuentra en Path)
TipoDeCambioDetermina el tipo de cambio que lanzó el evento
FechaCreacionFecha de creación del Watch (se supone que es la fecha en la que se lanzó el evento)

Es una clase sencilla y su única finalidad es hacer de contenedor de la información de la modificación.

Los comandos

Un comando es una "acción" que se ejecuta sobre un Watch. Por ejemplo, cuando se modifica el nombre de un archivo, se crea un Watch que refleje esa modificación y podemos ejecutar un comando que registre esa modificación en un archivo de logs (el Watch va a tener el nombre del archivo y el path anterior del mismo). Otro comando puede encargarse de enviar un mail cuando se detecte la creación de un archivo (una vez más, el Watch guarda el tipo de cambio y el nombre del nuevo archivo).

Cómo trabaja la aplicación

Inicialmente la aplicación no tenia comandos. Como sólo tenía que copiar archivos, el codigo que realizaba la copia se encontraba en los eventos del FileSystemWatcher (más precisamente en el evento Changed, que era lanzado cuando se modificaba un archivo). Si quería hacer este proceso más flexible iba a tener que cambiar esto.

El mayor desafío al ampliar la aplicación fue dar la posibilidad de ejecutar más de un comando a elección del usuario, pero sin tener que recompilar toda la aplicación. Ahí fue cuando se me ocurrió basarme en un patrón de diseño: el patrón Command (Comando). Hay mucha información en internet sobre patrones por lo que no voy a entrar en detalles sobre cómo se aplica el patrón. Básicamente el patrón consiste en crear una interface que exponga un método (comúnmente llamado Execute o algo parecido) que se encargue de realizar una tarea. Las clases que implementan esa interface son los comandos. Así, es posible crear una colección de comandos y ejecutarlos a todos (por medio del método Execute()), sin importar que hacen, de que tipo son (siempre y cuando implementen la interface) o donde se encuentran (podemos colocar nuevos comandos en nuevas dlls sin modificar el .exe original). En esta aplicación, la interface en cuestión es IComando. Esta no sólo posee un método llamado Ejecutar, sino que también presenta las siguientes propiedades:

PropiedadDescripción
NombreNombre del comando
InicioEjecucionFecha en que comenzó la ejecución del comando
FinEjecucionfecha en que finalizó la ejecución del comando
EjecutarEnNuevoHiloDetermina si el comando debe ejecutarse en un nuevo hilo o en el mismo que la aplicación (puede ser útil si la ejecución del comando es larga)
WatchWatch sobre el cual se ejecuta el comando
EstaEjecutandoseDetermina si el comando aun se esta ejecutando o no

Esta aplicación no hace uso de todas las propiedades, pero eso no impidió que las agregara. Uno no sabe cuando las puede llegar a necesitar.

Para hacer que la ejecución de los comandos sea lo más flexible posible, opté por hacer que esta sea configurada desde el archivo de configuración. De este modo al iniciarse la aplicación se cargan todos los comandos configurados para ejecutarse y cuando ocurra un evento, se procede a la ejecución de los mismos.

La configuración

Para poder cargar los comandos dinámicamente es necesario indicarle a la aplicación ciertos parámetros propios de los comandos. Es por ello que decidí armar el archivo de configuración con una estructura "no estándar" para tener todo más organizado. Las opciones generales de la aplicación se encuentran en la sección appSettings, pero las de los comandos se encuentran en su propia sección: Comandos. Este es un ejemplo de archivo de configuración:

<configuration>
  
  <configSections>
    <section name="Comandos" type="FileWatcher.Comandos.ComandosSectionHandler, FileWatcher" />
  </configSections>
  
  <appSettings>
    <add key="Path" value="d:\" />
    <add key="Patron" value="*" />
    <add key="WatcherActivado" value="True" />
    <add key="IncluirSubdirectorios" value="False" />
    <add key="NotificarCambioAtributos" value="True" />
    <add key="NotificarCambioNombreArchivo" value="True" />
    <add key="NotificarCambioTamanoArchivo" value="True" />
    <add key="NotificarCambioAcceso" value="True" />
    <add key="NotificarCambioCreacion" value="True" />
    <add key="NotificarCambioModificacion" value="True" />
  </appSettings>
  
  <Comandos>
    <comando nombre="MessageCommand" tipo="FileWatcher.Comandos.MessageCommand, FileWatcher" nuevoHilo="false" />
  </Comandos>
  
</configuration>

En la sección appSettings podemos configurar el path por defecto a vigilar, el patrón o filtro a utilizar para vigilar los archivos y los tipos de cambios a los cuales reaccionar, entre otras cosas. Lo realmente interesante es la última sección. Como se puede apreciar, desde aca se puede setear el nombre de los comandos, el tipo y si se ejecutan en un nuevo hilo. En este ejemplo solo creamos un comando llamado MessageCommand (muestra un mensaje cada vez que se modifica algo) y que es de tipo MessageCommand (tipo que se encuentra en el assembly FileWatcher), pero nada nos impide agregar mas comandos a la sección Comandos.

Leyendo la configuración

Para leer el archivo de configuración tenemos la propiedad AppSettings de la clase ConfigurationSettings del namespace System.Configuration, pero este método de acceso tiene un problema: si el archivo de configuración no existe o simplemente no se encuentra el parámetro que buscamos, se lanza una excepción. Por supuesto que podría atrapar la excepción y evitar que se propage, pero preferí utilizar mi propia técnica, la cual no sólo nos evita tener que validar la existencia del parámetro, sino que podemos especificar un valor por defecto para que nos devuelva en caso de no encontrarse el parámetro.

El archivo de configuración no es más que un simple archivo en formato XML, por lo que si queremos leerlo vamos a tener que abrirlo como tal. El siguiente código nos dará el path del archivo:

string path = System.AppDomain.CurrentDomain.SetupInformation.ConfigurationFile;

Una vez abierto, ubicamos el nodo "appSettings" y usamos la función LeerParametro() (que se encuentra en la clase frmPrincipalHelper) para encontrar la configuración deseada. Esta función tiene 2 sobrecargas. Una es para leer parámetros de tipo string (formato estandar de la configuración) y la otra para leer parámetros de tipo booleano, por lo que no va a ser necesario parsear los parámetros ya que la función se encargará de ello. Podría haber una sobrecarga que devuelva enteros, pero no la hice porque no la necesitaba.

Para leer la sección Comandos del archivo de configuración vamos a tener que trabajar un poco más. Debido a que esa sección tiene un formato personalizado, vamos a tener que crear clases propias que la parseen y nos devuelvan un objeto del tipo correcto. La técnica consiste en crear una clase que implemente la interface IConfigurationSectionHandler y hacer una referencia a esta en el archivo de configuración (en la sección configSections). En nuestro caso la clase será ComandosSectionHandler. Para ver cómo crear configuraciones personalizadas pueden ir aquí.

Una vez que tengamos la clase creada, sólo será necesario la siguiente línea para crear un Hashtable de comandos desde la configuración:

System.Collections.Hashtable config =
        (System.Collections.Hashtable)System.Configuration.ConfigurationSettings.GetConfig("Comandos");

En la aplicación podrán ver que uso 2 clases para leer el archivo. Eso se debe a que una de las clases (ComandosSectionHandler) lee la sección "Comandos", mientras que la otra (ComandoSectionHandler) lee los nodos "comando". ComandosSectionHandler utiliza ComandoSectionHandler para crear un comando por cada nodo "comando" que encuentre y con ellos arma un Hashtable y lo devuelve. Parece complicado pero en realidad es muy sencillo, sólo hay que tener en claro cómo crear y usar una clase que implemente IConfigurationSectionHandler, lo demás sale solo.

Cabe aclarar que la clase ComandoSectionHandler no tiene porque implementar la interface IConfigurationSectionHandler ya que no va a ser usada por el .NET Framework para leer la configuración (ComandosSecionHandler si es usada, como ven, nunca vamos a tener que llamarla nosotros mismos). Podríamos haber puesto ese código dentro de la misma clase ComandosSectionHandler si quisiéramos, pero lo deje de ese modo ya que una version anterior de la aplicación hacia uso de ambas clases y al tener una clase que implemente IConfigurationSectionHandler puede sernos util en una futura versión de la aplicación, en donde los nodo "comando" puedan tener un formato más complejo.

Guardando la configuración

Guardar la configuración tampoco tiene ningún misterio. Abrimos el archivo y modificamos o agregamos los nodos correspodientes a la configuración actual. Nada más.

Ejecución de comandos en otros hilos

Como mencioné anteriormente, es posible configurar los comandos para que se ejecuten en un hilo separado. Veamos cómo hacerlo.
En el form principal hay un método Ejecutar() que se encarga de ejecutar un comando sobre un Watch:

private void Ejecutar(Comandos.IComando c, Watch w){
    c.Ejecutar(w);
}

Normalmente este método, al ser llamado, se va a ejecutar en el hilo de ejecución actual por lo que vamos a tener que llamarlo de otro modo para ejecutarlo. Podemos crear nuestros propios hilos y ejecutar el método en ellos, pero para simplificar el trabajo decidí utilizar la clase ThreadPool, la cual se encargará de crear (o reciclar) los hilos en donde se ejecuta nuestra aplicación. Este es el código del método EjecutarComandos(), el cual se encarga de ejecutar todos los comandos que hayan sido configurados en el archivo de configuración:

private void EjecutarComandos(Watch w){
    FileWatcher.Comandos.IComando com;
    //Por cada comando...
    foreach(object c in frmPrincipalHelper._comandos.Values){
        com = c as FileWatcher.Comandos.IComando;
        //Si el objeto era efectivamente un comando...
        if (com != null){
            if (com.EjecutarEnNuevoHilo){
                //Llamamos al método y dejamos que el ThreadPool se encarge de su ejecución
                System.Threading.ThreadPool.QueueUserWorkItem(new System.Threading.WaitCallback(Ejecutar),
                                                            new object[2]{com, w});
            }else{
                //Llamamos al método como siempre
                com.Ejecutar(w);
            }
        }
    }
}

Para que este código funcione vamos a tener que agregar el siguiente método:

private void Ejecutar(object obj){
    object[] a = (object[])obj;
    Ejecutar((FileWatcher.Comandos.IComando)a[0], (Watch)a[1]);
}

¿Porqué es necesario dar tantas vueltas? Pues la razón es que el método QueueUserWorkItem sólo acepta dos parámetros, uno es el delegado WaitCallback (utilizado para llamar al método Ejecutar()) y el segundo, un objeto que es pasado como parámetro al método a ejecutar. Es por ello que este parámetro consiste en un objeto (que es un array) formado por el comando y el Watch. Así, el segundo Ejecutar() (el que acepta un objeto como parámetro) se encarga de transformar ese objeto en un comando y un Watch y, una vez que los tiene separados, ejecuta ese comando sobre el Watch. Esto último se hace en el primer Ejecutar() (el que acepta un comando y un Watch como parámetros), pero tranquilamente podría hacerse en el mismo lugar, lo hice así para más claridad.

En este link pueden encontrar más info sobre la clase ThreadPool.

Listbox Multicolor

El formulario principal de la aplicación tiene un listbox para mostrar los eventos que fueron sidos detectados por la misma. Como es posible que vigilemos a varios tipos de cambio se me ocurrió asignarle un color a cada uno en el ListBox. La verdad es muy sencillo hacer un listbox multicolor. El truco esta en indicarle al control que vamos a ser nosotros los que nos vamos a encargarnos de dibujar los items en pantalla. Para hacerlo hay que setear la propiedad DrawMode a OwnerDrawFixed. A continuación deberemos crear nuestro propio "handler" para el evento DrawItem del listbox. Este evento es disparado cada vez que se dibuja un item del listbox, pero solo ocurre si la propiedad DrawMode esta seteada a OwnerDrawFixed. El código del handler es el siguiente:

private void lbWatches_DrawItem(object sender, DrawItemEventArgs e) {
    if(e.Index > -1){
        // Dibujamos el background
        e.DrawBackground();
        Color c = Color.Black;
        System.IO.WatcherChangeTypes cambio =
            ((Watch)frmPrincipalHelper._watches[this.lbWatches.Items[e.Index].ToString()]).TipoDeCambio;
        
        if( cambio == System.IO.WatcherChangeTypes.Created){
            c = Color.DarkOrange;
        }else if( cambio == System.IO.WatcherChangeTypes.Changed){
            c = Color.Green;
        }else if( cambio == System.IO.WatcherChangeTypes.Deleted){
            c = Color.Red;
        }else if( cambio == System.IO.WatcherChangeTypes.Renamed){
            c = Color.Purple;
        }
				
        // Si el estado es seleccionado, entonces invierto el color
        if((e.State & DrawItemState.Selected) == DrawItemState.Selected)
            c = frmPrincipalHelper.InvertirColor(c);

        // Dibujamos el texto del item
        e.Graphics.DrawString(this.lbWatches.Items[e.Index].ToString(),
                            new Font(FontFamily.GenericSansSerif, (float)8.25, FontStyle.Regular),
                            new SolidBrush(c),
                            e.Bounds);
            
        // Dibujamos el "foco" del item
        e.DrawFocusRectangle();
    }
}

La función InvertirColor() se encarga justamente de eso, de invertir un color. La creé porque con el item seleccionado el fondo no es blanco y por lo tanto la letra en negro no se va a ver bien. Así, el color de la fuente de un item seleccionado se invierte si el item se selecciona. Este el es código:

internal static System.Drawing.Color InvertirColor(System.Drawing.Color color){
    byte red = color.R;
    byte blue = color.B;
    byte green = color.G;

    return System.Drawing.Color.FromArgb((int)(255 - red), (int)(255 - green), (int)(255 - blue));
}

Hay que recordar agregar la siguiente línea en el método InitializeComponent() del formulario para asociar el handler al evento:

this.lbWatches.DrawItem += new System.Windows.Forms.DrawItemEventHandler(this.lbWatches_DrawItem);

Notas finales

El código completo de la aplicación se encuentra en el archivo adjunto a este artículo. Este incluye todas las clases acá nombradas y 3 comandos. El primero es BaseCommand. Esta es una clase base para comandos que evita tener que reescribir el código de acceso a las propiedades comunes a todos los comandos en cada comando. Otro comando es MessageCommand que se encarga simplemente de notificar un cambio mediante un MessageBox. Finalmente, el último comando es CopyCommand, que se encarga de copiar un archivo modificado a la unidad C. A pesar de que es posible cambiar el path destino, la aplicación no lo hace (por la manera en que esta diseñada).
Otra cosa a mencionar es que existe una sobrecarga del método Ejecutar() de la interface IComando que acepta 2 parámetros, el Watch sobre el cual ejecutar el comando y un objeto. Este último parámetro está pensado para pasarle parámetros opcionales a un comando (no sabemos si algún comando puede llegar a necesitar más datos que un simple Watch). En el caso de CopyCommand podemos pasarle el path a donde copiar el archivo por ejemplo.

 


Espacios de nombres usados en el código de este artículo:

System.Threading
System.Drawing
System.Configuration
System.Collections

 


Fichero con el código de ejemplo: qrox_fswextendido.zip - 22 KB

(MD5 checksum: 8F965618A6BACD5E2EC3960285EDE673)


ir al índice