Umbraco: Event handlers pipeline via interception (Unity et PIAB)

Le framework d’Umbraco propose deux fonctionnalités pour la capture d’événements dans le back-office : les Event handlers et les Action handlers. Il est possible d’inscrire plusieurs observateurs mais il n’est pas possible de définir un ordre d’éxécution. De plus, il est difficile de créer des handlers sous forme de librairies réutilisables sur différents sites. Cet article propose un concept basé sur l’interception via Unity (et Entreprise Library 5) pour répondre à ces limitations.


Cet article s’inscrit dans une suite:

Note importante : ces trois articles sont adaptés à Umbraco 4.7.

Rappels sur les Event handlers et les Action handlers d’Umbraco

Ceuxi-ci permettent d’exécuter une méthode tierce suite à une action de l’utilisateur dans le back-office (création, mise à jour ou suppression de contenu, publication d’un document, etc.).

Les Actions handlers sont plus anciens et sont limités aux noeuds de contenu. Les Event handlers sont apparus dans la version 4 et sont une évolution des premiers (il existe beaucoup plus d’événements et ils ne sont pas limités au seul contenu). Cependant, Umbraco permet de créer de nouvelles actions, ce qui rend les Action handlers parfois encore pertinents.

Le principe consiste à implémenter une classe ou une interface (respectivement ApplicationBase pour les event handlers et IActionHandler pour les action handlers) et à déposer l’assemblage du handler dans le dossier /bin du site Umbraco.

Event handlers

Dépendances sur les assemblages suivants: cms.dll, businesslogic.dll et umbraco.dll.


using umbraco.BusinessLogic;
using umbraco.cms.businesslogic;
using umbraco.cms.businesslogic.web;

public class DoSomethingAfterDocumentSave : ApplicationBase
{
    public DoSomethingAfterDocumentSave()
    {
        Document.AfterSave += DoSomeStuff;
    }

    private static void DoSomeStuff(Document sender, SaveEventArgs e)
    {
        // [...]
    }
}

Action handlers

Dépendances sur les assemblages suivants: cms.dll, businesslogic.dll, et interfaces.dll.

Rappel: les actions n’interceptent que des opérations effectuées sur les noeuds de contenu (documents).


using System.Collections.Generic;
using System.Text;
using umbraco.BusinessLogic.Actions;
using umbraco.cms.businesslogic.web;

public class DoSomethingAfterDocumentNewActionHandler : IActionHandler
{
    public string HandlerName()
    {
        return this.GetType().Name;
    }

    public umbraco.interfaces.IAction[] ReturnActions()
    {
        return new umbraco.interfaces.IAction[] { new umbraco.BusinessLogic.Actions.ActionNew() };
    }
    
    public bool Execute(Document documentObject, umbraco.interfaces.IAction action)
    {
        // [...]
        return true; // or false, same thing!
    }
}

Bien que ce mécanisme soit plus ancien et plus limité que les Event handlers, je le trouve personnellement plus « joli » et plus naturel pour certaines réactions. Je pense donc que les Action handlers restent complémentaires aux Event handlers.

Interception

Cet article ne contient malheureusement pas de code prêt à l’emploi. Par manque de temps, je me limite simplement à décrire le concept (assez précisément je l’espère) que j’utilise avec succès depuis maintenant plus de deux ans sur plusieurs sites.

Ce concept présente notamment les deux avantages suivants:

  • Possibilité de créer un pipeline d’handlers.
  • Découplage des handlers par rapport au site.

Le premier avantage est probablement le plus important. Quand on y réfléchit, le second ne l’est pas beaucoup moins. L’intérêt d’un pipeline est de pouvoir créer des handlers centrés sur une tâche spécifique et réutilisables sur différents sites. Les différents handlers peuvent ainsi être utilisés de façon personnalisée d’un site à l’autre. Ce qui nous amène au second avantage: le découplage. Dans le modèle natif proposé dans Umbraco, le fait d’implémenter la classe ApplicationBase pour inscrire les handlers rend ceux-ci intimement liés au site. L’interception permet d’inscrire des handlers qui ne dépendent plus de la classe ApplicationBase.

La beauté de l’interception est de pouvoir inscrire les mêmes handlers à la fois à des événements et des actions d’Umbraco. Dans le même ordre d’idée, on peut inscrire un même handler à des événements ayant des signatures différentes (arguments différents). Il faut évidemment que le handler supporte les signatures des méthodes interceptées.

Principe

Voici les grandes étapes, que je détaille juste après:

  1. Inscription aux événements et/ou actions via un objet proxy.
  2. Interception des méthodes du proxy.
  3. Injection d’un pipeline de « call handlers » .

Le CallHandler correspond à la classe qui contient le code exécuté (injecté) suite à l’interception. La liaison entre interception et injection est gérée par des règles (IMatchingRule). Par exemple, nous pouvons avoir les règles suivantes : DocumentNewMatchingRule, DocumentBeforeSaveMatchingRule, ActionNewMatchingRule, ActionPublishMatchingRule, etc.. Le branchement des call handlers aux matching rules se fait dans un fichier de configuration au travers de « polices d’injection ». Techniquement, n’importe quel call handler peut être branché à n’importe quelle matching rule. En pratique, il faut que le call handler prenne en charge la méthode interceptée, et donc la matching rule qui lui correspond.

Nous nous basons sur Unity 2 pour l’interception et sur le Policy Injection Application Block (PIAB) d’Entreprise Library 5 pour les polices d’injection (inclut les matching rules).

Proxy d’interception

La première étape est de créer un proxy qui utilise le modèle natif d’Umbraco pour s’inscrire aux événements. Le proxy est séparé en deux classes:

  • une première classe contenant les observateurs d’événements,
  • une seconde classe chargée de mettre en place l’interception avec la première.

Ce proxy dépend donc du framework Umbraco (notamment cms.dll, businesslogic.dll, umbraco.dll et interfaces.dll) et du PIAB d’Entreprise Library. (notamment Microsoft.Practices.Unity.dll, Microsoft.Practices.Unity.Interception.dll, Microsoft.Practices.ServiceLocation.dll, Microsoft.Practices.EnterpriseLibrary.PolicyInjection.dll et Microsoft.Practices.EnterpriseLibrary.Common.dll) .

Les méthodes de la classe interceptée pourront se voir injecter un pipeline de call handlers avant — et éventuellement après — qu’elles ne soient exécutées.

Voici le code partiel de la première classe (observateurs d’événements):

#define LOG_EVENT
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace UmbracoInterception
{
    public class EventInterceptor
    {
        internal static class Method
        {
            public const string 
                        DOCUMENT_NEW = "OnDocument_New",
                        DOCUMENT_AFTER_NEW = "OnDocument_AfterNew";
            // [...]  "OnDocument_BeforeSave", "OnDocument_AfterSave", etc.
        }

        #region umbraco.cms.businesslogic.web.Document
        public virtual void OnDocument_New(umbraco.cms.businesslogic.web.Document sender, umbraco.cms.businesslogic.NewEventArgs e)
        {
            #if LOG_EVENT
            umbraco.BusinessLogic.Log.Add(umbraco.BusinessLogic.LogTypes.Debug, -1, "EventInterceptor: OnDocument_New");
            #endif
        }

        public virtual void OnDocument_AfterNew(object sender, umbraco.cms.businesslogic.NewEventArgs e)
        {
            #if LOG_EVENT
            umbraco.BusinessLogic.Log.Add(umbraco.BusinessLogic.LogTypes.Debug, -1, "EventInterceptor: OnDocument_AfterNew");
            #endif
        }

        /* [...] 
           OnDocument_BeforeSave, 
           OnDocument_AfterSave, 
           OnDocument_AfterDelete, 
           OnDocument_BeforeDelete,
           OnDocument_AfterCopy,
           OnDocument_BeforeCopy,
           OnDocument_AfterPublish,
           OnDocument_BeforePublish,
           OnDocument_AfterMoveToTrash,
           OnDocument_BeforeMoveToTrash,
           OnDocument_AfterRollback,
           OnDocument_BeforeRollback,
           OnDocument_AfterUnPublish,
           OnDocument_BeforeUnPublish */
        #endregion

        #region umbraco.cms.businesslogic.media.Media
        /* [...] 
           OnMedia_AfterDelete, 
           OnMedia_BeforeDelete, 
           OnMedia_AfterSave, 
           OnMedia_BeforeSave,
           OnMedia_AfterNew,
           OnMedia_New,
           OnMedia_AfterMoveToTrash,
           OnMedia_BeforeMoveToTrash */
        #endregion

        #region umbraco.cms.businesslogic.Content
        // [...]
        #endregion

        #region umbraco.content
        // [...]
        #endregion

        #region umbraco.cms.presentation.Trees.BaseContentTree
        // [...]
        #endregion
    }
}

C’est de la plomberie. Chaque méthode correspond à un observateur d’événement. Le corps des méthodes est vide. Noter que chaque méthode est virtuelle : c’est ce qui permettra leur interception. La classe statique EventInterceptor.Method contient le nom de chaque méthode et servira pour les Matching rules. Une liste des événements d’Umbraco est disponible sur le wiki. Cette liste n’est pas exhaustive, un outil comme Reflector peut être utile, sinon il suffit de télécharger le code source d’Umbraco et de rechercher les événements disponibles.

Voici le code partiel de la seconde classe (mise en place de l’interception):


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using Microsoft.Practices.Unity; // IUnityContainer.AddNewExtension()
using Microsoft.Practices.Unity.InterceptionExtension;

using Microsoft.Practices.EnterpriseLibrary.Common.Configuration.ContainerModel.Unity; // UnityContainerConfigurator 
using Microsoft.Practices.EnterpriseLibrary.Common.Configuration; // EnterpriseLibraryContainer, ConfigurationSourceFactory

namespace UmbracoInterception
{
    public class EventHandlerInterceptors : umbraco.BusinessLogic.ApplicationBase
    {
        static EventInterceptor GetInterceptor()
        {
            IUnityContainer container = new UnityContainer();

            var configurator = new UnityContainerConfigurator(container);
            EnterpriseLibraryContainer.ConfigureContainer(configurator, ConfigurationSourceFactory.Create());

            container.RegisterType<EventInterceptor>            
                    (
                    new Interceptor<VirtualMethodInterceptor>(), 
                    new InterceptionBehavior<PolicyInjectionBehavior>()
                    );

            return container.Resolve<EventInterceptor>();
        }

        public EventHandlerInterceptors()
        {
            try
            {
                var interceptor = GetInterceptor();

                umbraco.cms.businesslogic.web.Document.New += new umbraco.cms.businesslogic.web.Document.NewEventHandler(interceptor.OnDocument_New);
                umbraco.cms.businesslogic.web.Document.AfterNew += new EventHandler<umbraco.cms.businesslogic.NewEventArgs>(interceptor.OnDocument_AfterNew);
                /* [...]
                   umbraco.cms.businesslogic.web.Document.BeforeSave,
                   umbraco.cms.businesslogic.web.Document.AfterSave,
                   [...]
                   umbraco.cms.businesslogic.media.Media.New,
                   umbraco.cms.businesslogic.media.Media.AfterNew,
                   [...] */
            }
            catch (Exception ex)
            {
                umbraco.BusinessLogic.Log.Add(umbraco.BusinessLogic.LogTypes.Error, -1, "EventHandlerInterceptors: " + ex.ToString());
            }
        }  
    }
}

Ici, plusieurs choses sont à noter. La classe EventHandlerInterceptors dérive de ApplicationBase, permettant à Umbraco de l’instancier au démarrage de l’application. Dans son constructeur, on inscrit chaque événements à intercepter avec notre proxy. Ce dernier est obtenu par la méthode statique GetInterceptor() qui retourne une instance de EventInterceptor. Il s’agit en fait d’un objet qui dérive de la classe définie juste avant (dont les méthodes sont virtuelles). L’instance IUnityContainer est configurée à partir du fichier de configuration qui doit contenir la section policyInjection que nous décrirons plus loin. L’invocation de container.RegisterType définit le type à intercepter (EventInterceptor), la façon de l’intercepter (VirtualMethodInterceptor) et enfin un comportement d’interception, à savoir à partir de polices d’injection (PolicyInjectionBehavior).

Nous venons de terminer les deux premières étapes décrites plus haut: inscription aux événements via un objet proxy et interception des méthodes du proxy. Il reste à mettre en place l’injection.

Injection

Rappelons que la liaison entre méthode interceptée et injection est faite au travers de Matching rules. Les Matching rules permettent notamment de créer cette relation à partir du fichier de configuration. Au final, il suffira de déposer dans le dossier /bin les assemblages contenant les call handlers (code injecté) et de modifier le fichier de configuration pour spécifier les méthodes dans lesquelles injecter ces call handlers.

Matching rules

Il s’agit encore une fois de pure tuyauterie. Chaque règle correspond à une classe. Pour identifier la méthode à intercepter par une règle, cette dernière se base sur l’assemblage et le nom de la méthode cible. La règle n’a accès qu’au code compilé et ne peut pas évaluer de variables, par exemple.

Il faut créer une règle par méthode interceptée. Comme toutes nos méthodes sont dans la même classe (EventInterceptor), nous définissons une première règle abstraite pour identifier cette classe. Toutes les autres règles hériteront de celle-ci:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Reflection;
using System.Collections.Specialized;

using Microsoft.Practices.Unity.InterceptionExtension;

namespace UmbracoInterception.MatchingRule
{
    public abstract class EventMatchingRuleBase : IMatchingRule
    {
        public virtual bool Matches(MethodBase member)
        {
            return member.DeclaringType.Equals(typeof(EventInterceptor));
        }
    }
}

Il faut ensuite créer chaque règle. Voici un premier exemple qu’il suffit de répéter pour chaque méthode à intercepter:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Collections.Specialized;

using Microsoft.Practices.EnterpriseLibrary.PolicyInjection.Configuration; // CustomCallHandlerData
using Microsoft.Practices.EnterpriseLibrary.Common.Configuration; //  ConfigurationElementTypeAttribute

namespace UmbracoInterception.MatchingRule.Document
{
    [ConfigurationElementType(typeof(CustomMatchingRuleData))]
    public class DocumentNewMatchingRule : EventMatchingRuleBase
    {
        public DocumentNewMatchingRule(NameValueCollection attributes)
        {

        }

        public override bool Matches(System.Reflection.MethodBase member)
        {
            bool baseRule = base.Matches(member);
            if (!baseRule)
                return false;

            return member.Name.Equals(EventInterceptor.Method.DOCUMENT_NEW);
        }
    }
}

Call handlers

Le call handler est une classe qui implémente l’interface ICallHandler. Celle-ci définit notamment la méthode suivante:

IMethodReturn Invoke(IMethodInvocation input, GetNextHandlerDelegate getNext)

L’argument input contient notamment les arguments de la méthode interceptée, getNext permet d’obtenir le call handler suivant dans le pipeline. Voici très simplement comment implémenter cette méthode:

public override IMethodReturn Invoke(IMethodInvocation input, GetNextHandlerDelegate getNext)
{
  // [...] do someting BEFORE next call handler...

  var nextCallHandler = getNext()(input, getNext);

  // [...] do someting AFTER next call handler...

  return nextCallHandler;
}

L’implémentation d’un exemple de call handler concret est détaillé dans l’article suivant. Voici un schéma récapitulatif du pipeline mis en place:

Schema

Configuration

Nous avons terminé. Il suffit maintenant de déposer les assemblages des call handlers et de l’objet proxy dans le dossier /bin d’Umbraco et de configurer l’interception.

Pour faciliter la maintenance, je recommande de définir la section de configuration « policyInjection » dans un fichier de configuration satellite placé dans /config/policyInjection.config:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <configSections>
    <!-- [...] -->
    <section name="policyInjection" type="Microsoft.Practices.EnterpriseLibrary.PolicyInjection.Configuration.PolicyInjectionSettings, Microsoft.Practices.EnterpriseLibrary.PolicyInjection, Version=5.0.414.0, Culture=neutral, PublicKeyToken=null" requirePermission="true" />
  </configSections>
  
  <policyInjection configSource="config\policyInjection.config" />
  <!-- [...] -->

Voici un exemple pour /config/policyInjection.config:

<?xml version="1.0" encoding="utf-8" ?>
<policyInjection>
 <policies>
 <add name="Content AfterSave">
                <matchingRules>
                    <add type="Umbraco.Interception.MatchingRule.Content.ContentAfterSaveMatchingRule, UmbracoInterception, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
                        name="Content.AfterSave" />
                </matchingRules>
                <handlers>
                    <add friendlyName="Md5" type="UmbracoInterception.CallHandler.Md5CallHandler, Umbraco.DefaultCallHandlers, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
                        name="Md5CallHandler" />
                </handlers>
            </add>
     <!-- [...] -->
 </policies>
</policyInjection>

L’exemple ci-dessus définit l’interception d’un seul événement (ContentAfterSaveMatchingRule) et injecte un pipeline d’un seul call handler (Md5CallHandler). Nous pourrions ajouter d’autres call handlers dans le pipeline en insérant autant d’éléments que souhaité sous l’élément <handlers>.

Interception des Action handlers

Les exemples ci-dessus s’appliquent effectivement aux event handlers et non aux actions handlers. Pour ces derniers, il faut créer un proxy pour chaque type d’action. Les matching rules fonctionnent sur le même principe. Tout cela est dans la seconde partie de cet article.
Les call handlers sont identiques (peu importe que le call handler soit injecté lors de l’interception d’une action ou d’un événement Umbraco, du moment qu’il supporte la ou les méthodes interceptées).

Pour aller plus loin…

Bravo pour en être arrivé là :-). J’espère que le principe est suffisamment clair pour être exploité dans vos sites Umbraco. J’ai volontairement omis un certain nombre de détails d’implémentation concernant Unity et PIAB. L’interception par Unity est un mécanisme à la fois extrêmement riche et performant (comparé à l’interception d’interfaces à partir d’un proxy dynamique généré par RealProxy par exemple). L’article de MSDN suivant est une excellente introduction à Unity : Interceptors in Unity.