Coder son propre magasin de persistance pour Windows Workflow Foundation 4

Téléchargez dès à présent le document en format pdf et les codes sources

Article par Jérémy Jeanson (MVP Connected System Developer)

Je suis actuellement consultant chez Peaks (société de conseil en informatique filiale de Trekk, Gold Partner Microsoft). Développant avec .net depuis 2001, j’ai vécu les différentes évolutions du Framework, des premières beta de la version 1.0 à aujourd’hui. Je surfe donc sur la vague .net depuis ses débuts, et j’adore cela.

Spécialisé dans les architectures n-tiers exploitant Windows Communication Foundation, Windows Workflow Foundation et l’interopérabilité, j’interviens régulièrement comme consultant / formateur pour des équipes désireuses d’intégrer les dernières nouveautés en la matière.

Sommaire de l'article

  • Description rapide de la persistance et du besoin de coder son propre magasin de persistance
  • Présentation sommaire du code qui sert de support à cet article
  • Le workflow d’exemple
  • L’InstanceStore
  • Implémentation d’un InstanceStore basé sur des fichiers
  • Utilisation d’un InstanceStore via un fichier de configuration
  • Implémentation d’un InstanceStore basé sur Azure Table
  • « Happy End »
  • Références

Description rapide de la persistance et du besoin de coder son propre magasin de persistance

Dans le cadre d’une utilisation courante de Windows Workflow Foundation 4 (WF4), il n’est pas rare d’avoir besoin d’utiliser la persistance. Ce mécanisme fort sympathique permet d’enregistrer l’état d’une instance de Workflow à un instant « t » et de la recharger à un instant « t+1 » et reprendre son exécution normale.

On distingue principalement deux scénarii d’utilisation de la persistance :

  1. Une instance  de Workflow est en attente d’un évènement extérieur :
    Plutôt que de garder en mémoire des instances inactives, on préfèrera les persister et les relancer quand l’évènement attendu se produit. Ex : workflows de demande de congés.
  2. Le processus hébergeant le Workflow a besoin d’être interrompu momentanément :
    Les instances de workflows ont donc besoin d’être persistées avant la fermeture de l’hôte de workflows. Ex : Mise à jour de l’OS.

Pour mettre en œuvre la persistance, il faut disposer d’un magasin de stockage (InstanceStore) que l’on associera à son hôte de workflow.

Attention : Seul le WorkflowApplication et le WorkflowServiceHost permettent l’exploitation de la persistance. N’imaginez donc pas utiliser le WorkflowInvoker.

Par défaut Microsoft fourni avec WF4 un magasin de stockage se reposant sur SQL Server pour stocker les instances de Workflow (SQL Express 2005 minimum). Celui-ci suffira à la plupart des cas. Mais alors, comment faire si l’on se trouve dans une situation où :

  • SQL Server n’est pas disponible ?
  • Toutes les fonctionnalités de SQL Server ne sont pas supportées ? (ex : SQL Azure)

Pas de panique, les choses ont bien été pensées, et nous sommes en mesure d’implémenter nos propres magasins de persistance pour WF4.

Présentation sommaire du code qui sert de support à cet article

Plutôt que de vous présenter l’implémentation d’un magasin fortement associé à son stockage, j’ai décidé de vous montrer ici une architecture vous permettant d’intégrer facilement votre propre dispositif de stockage.

Afin de démontrer, la facilité de mise en place d’un tel dispositif, j’ai joint à cet article trois solutions :

  • Demo.WF4.TestConsole : Une application console qui utilise un InstanceStore donc le stockage est basé sur des fichiers.
  • Demo.WF4.TestWebApp : Une application web qui utilise le même InstanceStore que Demo.WF4.TestConsole mais qui utilise le fichier de configuration pour associer son InstanteStore avec son WorkflowServiceHost.
  • Demo.WF4.TestAzure : Une application web déployée dans Azure qui utilise un InstanceStore dont le stockage se fait sur Azure Table.

On a donc là trois solutions particulières qui couvrent les cas les plus courants. Volontairement, les cas à plusieurs hôtes de workflows ne sont pas traités, ceci aurait rendu cet article bien moins accessible.

Chaque projet a en référence une assembly contenant son InstanceStore spécifique qui lui-même référence l’assembly MyLib.WF4.DurableInstancing.

Tout reposera sur les classes de cette assembly :

L’InstanceStore<T> qui contient le plus gros du travail. Il hérite d’InstanceStore qui est la classe de base permettant la création d’un magasin de persistance pour WF4.  Les magasins de persistance personnalisés implémentés dans cet article héritent de l’InstanceStore<T> et T respecte l’interface IStore suivante.

Le code spécifique au magasin sera donc entièrement porté dans cette classe implémentant IStore. Pour obtenir un magasin de persistance différent de ceux qui sont proposés dans cet article, il vous faudra donc uniquement fournir le code suivant :

  • Une classe « Store » qui implémente IStore
  • Une classe « InstanceStore » qui hérite de InstanceStore<T> où T est votre « Store »

Les InstanceStoreElement et InstanceStoreBehavior<T> sont dédiés à la création de Behaviors personnalisé pour vos fichiers de configuration.

Le workflow d’exemple

Pour offrir un exemple de base concret, j’ai réalisé le workflow suivant :

Il s’agit d’un simple service WCF exploitant WF. Il comprend donc les deux méthodes suivantes :

String SetVariable1(Int32 id, Int32 arg1) {} String SetVariable2(Int32 id, Int32 arg2) {}

Chaque méthode a pour mission de recueillir un argument qui servira dans le cadre d’une addition. Rien de bien compliqué, mais il s’agit d’un scénario courant ou l’instance de workflow devra persister. Ce qui donne un cycle de vie tel que celui-ci.

L’InstanceStore

L’InstanceStore est la pièce maitresse d’une persistance personnalisée réussie. Son rôle consiste à répondre aux demandes de notre hôte de workflow :

  • Enregistrement d’une instance de workflow.
  • Chargement d’une instance de workflow persistée.
  • Suppression d’une instance de workflow persistée.
  • Verrouillage/Déverrouillage d’une instance de workflow persistée (interdire/autoriser à un autre hôte d’utiliser l’instance)

Ces demandes de l’hôte sont appelées « commandes ». Elles sont soumises à l’InstanceStore via sa méthode TryCommand dont le rôle est de tenter de traiter la commande et de retourner un Boolean à True en cas de succès. Celle-ci dispose aussi de sa version asynchrone basée sur le pattern exploitant IAsyncResult. Pour coder notre propre InstanceStore, nous devons donc réécrire trois méthodes :

protected override Boolean TryCommand(InstancePersistenceContext context, InstancePersistenceCommand command, TimeSpan timeout){} protected override IAsyncResult BeginTryCommand(InstancePersistenceContext context, InstancePersistenceCommand command, TimeSpan timeout, AsyncCallback callback, object state){} protected override Boolean EndTryCommand(IAsyncResult result){}

Passons maintenant à la description des arguments de la méthode TryCommand :

  • contexte : représente le contexte qui sert à l’hôte pour échanger des données avec son InstanceStore. Il contient donc des données à persister ou des données restituées par l’InstanceStore.
  • command : représente la commande dont l’hôte souhaiterait voir l’exécution se produire. Le type InstancePersistenceCommand est en fait le type de base dont héritent les commandes. Voici les types dérivés qui peuvent être utilisés :
  • timeout : cet argument représente un timeout conditionnant la fin d’exécution de la commande et donc le temps d’attente que l’hôte est prêt à accorder à son InstanceStore.

Les types de commandes étant nombreux j’ai décidé de découper leur implémentation en autant de méthodes. Celles-ci seront exécutées par la méthode TryCommand en fonction des demandes de l’hôte.

Note : Dans le cas d’une persistance moyennement sollicitée ou sollicitée par un seul hôte, l’implémentation du code de base est suffisant pour certaines commandes qui n’interviennent pas avec le dispositif de stockage physique choisi. Il n’est pas impératif de réécrire le code relatif aux commandes :  System.Activities.DurableInstancing.DeleteWorkflowOwnerCommand
System.Activities.DurableInstancing.QueryActivatableWorkflowsCommand
System.Activities.DurableInstancing.TryLoadRunnableWorkflowCommand.

Les premières lignes de l’InstanceStore<T> sont donc les suivantes :

public class InstanceStore<T> : InstanceStore where T : class, IStore, new() { // Id uniques utiles pour la méthode BindInstanceOwner() private readonly Guid m_InstanceOwnerId; private readonly Guid m_LockToken; // Store private readonly T m_Store; /// <summary> /// Constructeur /// </summary> public InstanceStore() { this.m_Store = new T(); this.m_LockToken = Guid.NewGuid(); this.m_InstanceOwnerId = Guid.NewGuid(); } #region "Gestion des commandes demandée par l'hôte d'instances de workflows" /// <summary> /// Demande de traitement d'une commande /// </summary> /// <param name="contexte"></param> /// <param name="command"></param> /// <param name="timeout"></param> /// <returns></returns> protected override Boolean TryCommand(InstancePersistenceContext context, InstancePersistenceCommand command, TimeSpan timeout) { try { // Demande de création d'un propriétaire d'une instance de workflow if (command is CreateWorkflowOwnerCommand) { this.CreateWorkflowOwner(context, command as CreateWorkflowOwnerCommand); } // Demande de chargement d'une instance de workflow pesistée else if (command is LoadWorkflowCommand) { this.LoadWorkflow(context, command as LoadWorkflowCommand); } // Demande de chargement d'une instance de workflow pesistée via une key connue else if (command is LoadWorkflowByInstanceKeyCommand) { this.LoadWorkflowByInstanceKey(context, command as LoadWorkflowByInstanceKeyCommand); } // Demande de persistance d'une instance de workflow else if (command is SaveWorkflowCommand) { this.SaveWorkflow(context, command as SaveWorkflowCommand); } // autres demandes else { return base.TryCommand(context, command, timeout); } return true; } catch (InstancePersistenceException ex) { Trace.TraceError(ex.Message); return false; } }

De son côté, la méthode BeginTryCommand ayant un AsyncCallback en argument et la méthode EndTryCommand devant retourner un Boolean, j’ai choisi de coder une classe InstanceStoreAsyncResult pour faire le lien entre les deux et ainsi permettre l’invocation du callback sans perdre la réponse de ma méthode TryCommand.

Cette classe est donc de la forme suivante :

/// <summary> /// IAsyncResult spécifique permettant de faire remonter le résultat d'une commande /// </summary> internal class InstanceStoreAsyncResult : IAsyncResult { private readonly Object m_AsyncState; private readonly WaitHandle m_AsyncWaitHandle; private readonly Boolean m_Value; public InstanceStoreAsyncResult(IAsyncResult result, Boolean value) { this.m_AsyncState = result.AsyncState; this.m_AsyncWaitHandle = result.AsyncWaitHandle; this.m_Value = value; } public Object AsyncState { get { return this.m_AsyncState; } } public WaitHandle AsyncWaitHandle { get { return this.m_AsyncWaitHandle; } } public Boolean IsCompleted { get { return true; } } public Boolean CompletedSynchronously { get { return false; } } /// Value contenant le retour de la commande (méthode TryCommand) public Boolean Value { get { return this.m_Value; } } }

Et le code de la méthode BeginTryCommand qui permet de lancer la méthode TryCommand de manière asynchrone. Suivi du code de la méthode EndTryCommand qui traite le résultat contenu dans lnstanceStoreAsyncResult (Value) et le retourne à l’hôte de workflow :

/// <summary> /// Demande de traitement d'une commande (Async) /// </summary> /// <param name="contexte"></param> /// <param name="command"></param> /// <param name="timeout"></param> /// <param name="callback"></param> /// <param name="state"></param> /// <returns></returns> protected override IAsyncResult BeginTryCommand(InstancePersistenceContext context, InstancePersistenceCommand command, TimeSpan timeout, AsyncCallback callback, object state) { Func<InstancePersistenceContext, InstancePersistenceCommand, TimeSpan, Boolean> f = this.TryCommand; return f.BeginInvoke( contexte, command, timeout, (r) => { if(callback!=null) callback(new InstanceStoreAsyncResult(r, f.EndInvoke(r))); }, state); } /// <summary> /// Fin de demande de traitement d'une commande (Async) /// </summary> /// <param name="result"></param> /// <returns></returns> protected override Boolean EndTryCommand(IAsyncResult result) { if (result is InstanceStoreAsyncResult) { return ((InstanceStoreAsyncResult)result).Value; } return true; } #endregion

Attaquons maintenant la découverte du code exécutant nos commandes. Celui-ci a été réparti en quatre méthodes :

CreateWorkflowOwner : Cette méthode sert à créer un « propriétaire » de l’instance de workflow à persister. Le propriétaire étant l’InstanceStore, il s’agit d’une association de notre InstanceStore au contexte utilisé par l’hôte.

/// <summary> /// Création d'un propriétaire d'une instance de workflow /// </summary> /// <param name="contexte"></param> /// <param name="command"></param> private void CreateWorkflowOwner(InstancePersistenceContext context, CreateWorkflowOwnerCommand command) { context.BindInstanceOwner(this.m_InstanceOwnerId, this.m_LockToken); }

Note : Dans le cadre du SqlWorkflowInstanceStore, cette méthode provoque la création d’un « verrou » qui interdit à d’autres instances d’SqlWorkflowInstanceStore d’accéder à un Workflow. Ce dispositif est bien entendu couplé au traitement de la commande DeleteWorkflowOwnerCommand et à un mécanisme interne qui va périodiquement raviver ce verrou afin de ne pas bloquer inutilement une instance.

LoadWorkflow et LoadWorkflowByInstanceKey : Ces deux méthodes utilisent la méthode LoadWorkflow pour raviver une instance de workflow persistée. Elles utilisent donc notre « Store » (m_Store) implémentant IStore pour alimenter le contexte en Data et MetaData. La méthode LoadWorkflowByInstanceKey diffère de la méthode LoadWorkflow par le fait qu’il lui faut dans un premier temps demander au Store l’id de l’instance de workflow à raviver.

/// <summary> /// Chargement d'une instance de workflow persistée /// </summary> /// <param name="contexte"></param> /// <param name="command"></param> private void LoadWorkflow(InstancePersistenceContext context, LoadWorkflowCommand command) { if (command.AcceptUninitializedInstance) { context.LoadedInstance( InstanceState.Uninitialized, null, null, null, null); } else { this.LoadWorkflow(contexte, context.InstanceView.InstanceId); } } /// <summary> /// Chargement d'une instance de workflow persistée identifiée via sa key /// </summary> /// <param name="contexte"></param> /// <param name="command"></param> private void LoadWorkflowByInstanceKey(InstancePersistenceContext context, LoadWorkflowByInstanceKeyCommand command) { // Recherche de id d'instance de workflow Guid instanceId = this.m_Store.GetInstanceAssociation( command.LookupInstanceKey); if (instanceId == Guid.Empty) { throw new InstanceKeyNotReadyException( String.Format( "Impossible de charger l'instance avec la key : {0}", command.LookupInstanceKey)); } // Chargement d'une instance de workflow persistée this.LoadWorkflow(context, instanceId); } /// <summary> /// Chargement d'une instance de workflow persistée /// </summary> /// <param name="contexte"></param> /// <param name="instanceId"></param> private void LoadWorkflow(InstancePersistenceContext context, Guid instanceId) { // Test si on a bien une instance déterminée if (instanceId != Guid.Empty) { IDictionary<XName, InstanceValue> instanceData; IDictionary<XName, InstanceValue> instanceMetaData; // Chargement de la Data et de la MetaData XDocument xmlData; XDocument xmlMetaData; // Chargement des Data et MetaData de l'instance persistée if (this.m_Store.LoadInstance(instanceId, out xmlData, out xmlMetaData)) { InstanceStoreSerilizer.GetDataToLoad( xmlData, xmlMetaData, out instanceData, out instanceMetaData); } else { instanceData = new Dictionary<XName, InstanceValue>(); instanceMetaData = new Dictionary<XName, InstanceValue>(); } // Binding de l'instance à charger avec le contexte de persistance if (context.InstanceView.InstanceId == Guid.Empty) { context.BindInstance(instanceId); } // Chargement du workflow context.LoadedInstance( InstanceState.Initialized, instanceData, instanceMetaData, null, null); } else { throw new InstanceNotReadyException("Impossible de charger une instance sans Id!"); } }

SaveWorkflow : Récupère les données du contexte pour les persister. Elle utilise donc notre Store (m_Store) afin de lui demander de persister nos données : Data, MetaData et Key permettant de retrouver l’association existant entre Key et Id d’instance.

/// <summary> /// Persistance d'une instance de workflow /// </summary> /// <param name="context"></param> /// <param name="command"></param> private void SaveWorkflow(InstancePersistenceContext context, SaveWorkflowCommand command) { // Récupération de l'id d'instance Guid instanceId = context.InstanceView.InstanceId; // Test si l'instance s'est terminée normalement if (command.CompleteInstance) { this.m_Store.DeleteInstance(instanceId); return; } // Test si on a des Data et MetaData à persister if (command.InstanceData.Count > 0 || command.InstanceMetadataChanges.Count > 0) { // Persitance des Data et MetaData XDocument docData; XDocument docMetaData; InstanceStoreSerilizer.GetDataToSave( command.InstanceData, command.InstanceMetadataChanges, out docData, out docMetaData); this.m_Store.SaveInstance(instanceId, docData, docMetaData); } // Test si on a bien de keys a associer à l'instance de workflow if (command.InstanceKeysToAssociate.Count > 0) { // Persistance de chaque key devant être associée à l'instance de workflow persistée foreach (var entry in command.InstanceKeysToAssociate) { this.m_Store.SaveInstanceAssociation( instanceId, entry.Key); } } }

Ces méthodes utilisent une classe Helper (InstanceStoreSerilizer) qui facilite le travail de sérialisation/dessérialisation en ne conservant que la donnée pertinente (donnée vive qui n’est pas en lecture seule dans les dictionnaires de Data et MetaData et qui ne doit pas être effacée). Celle-ci permet aussi de manipuler des objets de XDocument qui seront bien plus pratiques à manipuler dans nos classes Store.

internal static class InstanceStoreSerilizer { #region "Constantes" private const String c_XmlRoot = "Instance"; private const String c_XmlNode = "Node"; private const String c_XmlKey = "Key"; private const String c_XmlValue = "Value"; private const String c_XmlOptions = "Options"; #endregion #region "Méthodes publiques" /// <summary> /// Retourner les data pour les persister /// </summary> /// <param name="instanceData"></param> /// <param name="instanceMetaData"></param> /// <param name="docData"></param> /// <param name="docMetaData"></param> public static void GetDataToSave( IDictionary<XName, InstanceValue> instanceData, IDictionary<XName, InstanceValue> instanceMetaData, out XDocument docData, out XDocument docMetaData) { NetDataContractSerializer serializer = new NetDataContractSerializer(); docData = new XDocument( new XElement(c_XmlRoot, GetNodesToSave(serializer, instanceData) )); docMetaData = new XDocument( new XElement(c_XmlRoot, GetNodesToSave(serializer, instanceMetaData) )); serializer = null; } /// <summary> /// Retourner les data pour restaurer une instance de workflows /// </summary> /// <param name="docData"></param> /// <param name="docMetaData"></param> /// <param name="instanceData"></param> /// <param name="instanceMetaData"></param> public static void GetDataToLoad(XDocument docData, XDocument docMetaData, out IDictionary<XName, InstanceValue> instanceData, out IDictionary<XName, InstanceValue> instanceMetaData) { NetDataContractSerializer serializer = new NetDataContractSerializer(); // Recherche des Data de l'instance instanceData = GetNodesToLoad( serializer, docData.Element(c_XmlRoot) .Elements(c_XmlNode) .ToArray()); // Recherche des MetaData de l'instance instanceMetaData = GetNodesToLoad( serializer, docMetaData.Element(c_XmlRoot) .Elements(c_XmlNode) .ToArray()); serializer = null; } #endregion #region "Gestion des sérialisation" /// <summary> /// Chargement des nodes de données d'une instance /// </summary> /// <param name="serializer"></param> /// <param name="nodes"></param> /// <returns></returns> private static IDictionary<XName, InstanceValue> GetNodesToLoad( NetDataContractSerializer serializer, XElement[] nodes) { IDictionary<XName, InstanceValue> result = new Dictionary<XName, InstanceValue>(); foreach (XElement node in nodes) { Object key = Deserialize(serializer, node.Element(c_XmlKey)); Object value = Deserialize(serializer, node.Element(c_XmlValue)); Object options = Deserialize(serializer, node.Element(c_XmlOptions)); result.Add( key as XName, new InstanceValue(value, (InstanceValueOptions)options)); } return result; } /// <summary> /// Retourner les nodes à pesister /// </summary> /// <param name="serializer"></param> /// <param name="data"></param> /// <returns></returns> private static XElement[] GetNodesToSave(NetDataContractSerializer serializer, IDictionary<XName, InstanceValue> data) { return data.Where((node) => !node.Value.IsDeletedValue && !node.Value.Options.HasFlag(InstanceValueOptions.WriteOnly)) .Select((node) => new XElement(c_XmlNode, Serialize(serializer, c_XmlKey, node.Key), Serialize(serializer, c_XmlValue, node.Value.Value), Serialize(serializer, c_XmlOptions, node.Value.Options))) .ToArray(); } /// <summary> /// Sérilizer un élément pour le persistance /// </summary> /// <param name="serializer"></param> /// <param name="name"></param> /// <param name="value"></param> /// <returns></returns> private static XElement Serialize(NetDataContractSerializer serializer, String name, Object value) { XElement element = new XElement(name); MemoryStream stream = new MemoryStream(); serializer.Serialize(stream, value); stream.Position = 0; StreamReader reader = new StreamReader(stream); element.Add(XElement.Load(stream)); // Libération des ressources reader.Close(); reader.Dispose(); stream.Dispose(); return element; } /// <summary> /// Desserilizer un element pour le persistance /// </summary> /// <param name="serializer"></param> /// <param name="element"></param> /// <returns></returns> private static Object Deserialize(NetDataContractSerializer serializer, XElement element) { Object result = null; MemoryStream stream = new MemoryStream(); XmlDictionaryWriter writer = XmlDictionaryWriter.CreateTextWriter(stream); foreach (XNode node in element.Nodes()) { node.WriteTo(writer); } writer.Flush(); stream.Position = 0; result = serializer.Deserialize(stream); // Libération des ressources writer.Close(); stream.Close(); stream.Dispose(); return result; } #endregion }

À ce stade, on dispose des classes utiles pour implémenter nos premiers Store personnalisés pour notre InstanceStore<T>.

Implémentation d’un InstanceStore basé sur des fichiers

Le premier cas pratique utilisera des fichiers Xml stockés sur le disque dur dans un répertoire défini via le fichier de configuration. Il utilisera deux classes :

public class FileStore : IStore {} public class FileInstanceStore : InstanceStore<FileStore> {}

Ce qui est merveilleux avec l’approche choisie ici, c’est qu’il n’y a aucun code à fournir pour la classe FileInstanceStore. Le simple fait qu’elle hérite de InstanceStore<T> suffit.

Pour la classe FileStore, il y a par contre un peu plus de travail à fournir pour implémenter l’interface IStore. Cependant, les choses restent relativement simples, car son rôle consiste uniquement à créer, supprimer, et contrôler des fichiers Xml. De plus l’InstanreStore<T> utilisant des XDocument, la manipulation des fichiers ne sera pas non plus des plus compliquées.

En résumé, le travail du FileStore va consister à :

  • Créer un fichier de Data et un de MetaData lors de la persistance
  • Créer un ou plusieurs fichiers d’association entre Key et Id d’instance de workflow lors de la persistance
  • Lire les fichiers Data et MetaData lors de la restauration d’une instance
  • Rechercher les fichiers d’associations entre Key et Id pour permettre à l’hôte de  retrouver l’instance relative à un Key.
  • Supprimer les fichiers  de Data, MetaData et Association dès que l’hôte n’en a plus l’utilité.

Voici son code, dont il suffit  de lire les commentaires pour assimiler l’utilité de chaque méthode.

public class FileStore : IStore { private const String c_FormatFPathData = @"{0}\\{1}.xml"; private const String c_FormatPathMetaData = @"{0}\\{1}.meta.xml"; private const String c_FormatFileAssociation = @"{0}.{1}.key.xml"; private readonly String m_Directory; /// <summary> /// Constructeur : créer le répertoire de persistance si besoin /// </summary> public FileStore() { this.m_Directory = ConfigurationManager.AppSettings["WorkflowStore"]; if (!Directory.Exists(m_Directory)) { Directory.CreateDirectory(this.m_Directory); } } #region "Path utilisés" /// <summary> /// Retourne le chemin complet vers un fichier de Data /// </summary> /// <param name="instanceId"></param> /// <returns></returns> private String GetPathData(Guid instanceId) { return String.Format(c_FormatFPathData, this.m_Directory, instanceId); } /// <summary> /// Retourne le chemin complet vers un fichier de MetaData /// </summary> /// <param name="instanceId"></param> /// <returns></returns> private String GetPathMetaData(Guid instanceId) { return String.Format(c_FormatPathMetaData, this.m_Directory, instanceId); } /// <summary> /// Retourne le non d'un fichier un fichier de Data /// </summary> /// <param name="instanceId"></param> /// <param name="instanceKey"></param> /// <returns></returns> private String GetFileNameAssociation(Nullable<Guid> instanceId, Nullable<Guid> instanceKey) { return String.Format(c_FormatFileAssociation, instanceId.HasValue ? instanceId.ToString() : "*", instanceKey.HasValue ? instanceKey.ToString() : "*"); } #endregion #region "Enregistrement des données de persistance" /// <summary> /// Persister les Data et MetaData de l'instance /// </summary> /// <param name="instanceId"></param> /// <param name="data"></param> /// <param name="metaData"></param> public void SaveInstance(Guid instanceId, XDocument data, XDocument metaData) { try { // Créer un fichier avec la Data SaveDocument(this.GetPathData(instanceId), data); // Créer un fichier avec la MetaData SaveDocument(this.GetPathMetaData(instanceId), metaData); } catch (IOException ex) { Trace.TraceError(ex.Message); throw ex; } } /// <summary> /// Enregistrer un document Xml /// </summary> /// <param name="path"></param> /// <param name="xml"></param> private static void SaveDocument(String path, XDocument xml) { FileStream stream = null; XmlWriter writer = null; try { // Supprimer le fichier si il existe déjà if (File.Exists(path)) { File.Delete(path); } // Ecriture du fichier Xml stream = new FileStream(path, FileMode.Create); writer = XmlWriter.Create( stream, new XmlWriterSettings() { Encoding = Encoding.UTF8 }); writer.WriteRaw(xml.ToString()); writer.Flush(); stream.Flush(); stream.Close(); } catch (IOException ex) { Trace.TraceError(ex.Message); throw ex; } finally { if (stream != null) { stream.Dispose(); stream = null; } } } #endregion #region "Chargement des données de persistance" /// <summary> /// Chargement des données de persistance (Data et MetaData) à partir des deux fichiers Xml (Data et MetaData) /// </summary> /// <param name="instanceId"></param> /// <param name="instanceData"></param> /// <param name="instanceMetaData"></param> /// <returns></returns> public Boolean LoadInstance(Guid instanceId, out XDocument instanceData, out XDocument instanceMetaData) { try { instanceData = new XDocument(); instanceMetaData = new XDocument(); String path = this.GetPathData(instanceId); if (!File.Exists(path)) { return false; } // Chargement des Data de l'instance instanceData = XDocument.Load(path); // Chargement des MetaData de l'instance instanceMetaData = XDocument.Load(this.GetPathMetaData(instanceId)); return true; } catch (Exception ex) { Trace.TraceError(ex.Message); throw ex; } } #endregion #region "Association entre Id de l'instance et key" /// <summary> /// Fait persister l'information d'association entre Key et Id d'instance de workflow sous forme d’un fichiers Xml /// </summary> /// <param name="instanceId"></param> /// <param name="instanceKey"></param> public void SaveInstanceAssociation(Guid instanceId, Guid instanceKey) { FileStream file = null; try { String path = Path.Combine( this.m_Directory, this.GetFileNameAssociation(instanceId, instanceKey)); if (!File.Exists(path)) { // Création d'un fichier file = File.Create(path); // Fermeture du fichier file.Close(); } } catch (Exception ex) { Trace.TraceError(ex.Message); throw ex; } finally { // Libération des ressources if (file != null) { file.Dispose(); file = null; } } } /// <summary> /// Retourne l'id d'instance de workflow correspondant à la Key /// </summary> /// <param name="instanceKey"></param> /// <returns></returns> public Guid GetInstanceAssociation(Guid instanceKey) { try { String[] files = Directory.GetFiles( this.m_Directory, this.GetFileNameAssociation(null, instanceKey)); if (files != null && files.Length > 0) { String file = Path.GetFileNameWithoutExtension(files[0]); return Guid.Parse(file.Substring(0, file.IndexOf('.'))); } else { return Guid.Empty; } } catch (Exception ex) { Trace.TraceError(ex.Message); throw ex; } } #endregion #region "Suppression de l'ensemble des données de persistance et d'association" /// <summary> /// Supprimer les donnée de persistance d'une instance de workflow /// </summary> /// <param name="instanceId"></param> public void DeleteInstance(Guid instanceId) { try { // Création d'une liste de fichiers List<String> files = new List<String>() { this.GetPathData(instanceId), this.GetPathMetaData(instanceId) }; // Récupération de la liste des associations String[] associations = Directory.GetFiles( this.m_Directory, this.GetFileNameAssociation(instanceId, null)); // Merge si on a bien des associations if (associations != null && associations.Length > 0) { files.AddRange(associations); } // Supprimer l'ensemble des fichiers foreach (String path in files) { if (File.Exists(path)) { File.Delete(path); } } } catch (Exception ex) { Trace.TraceError(ex.Message); throw ex; } } #endregion }

Une fois ce code en place, il faut associer une instance de notre FileInstanceStore à notre hôte de workflow. Dans ce premier exemple, j’utilise une application console qui expose le worklfow via WCF et les canaux nommés.

Voici sa méthode Main :

static void Main(string[] args) { // Nom de mon contrat de service // (juste pour les yeux, ne permet pas de respecter un contrat) String contractName = "IService"; // Addresse du service Uri uri = new Uri("net.pipe://localhost/Addition"); // Création de l'hôte WorkflowServiceHost host = new WorkflowServiceHost( new WorkflowService() { // Le Workflow exsposé via le service Body = new Addition(), // Endpoints du workflows Endpoints = { new Endpoint() { ServiceContractName = contractName, Binding = new NetNamedPipeBinding(), AddressUri = uri } } }, uri); // Ajout d'un behavior pour la mise à disposition des metadata via WCF host.Description.Behaviors.Add(new ServiceMetadataBehavior()); // Ajout du endpoint servant aux metadatas host.AddServiceEndpoint( ServiceMetadataBehavior.MexContractName, MetadataExchangeBindings.CreateMexNamedPipeBinding(), "mex"); // Passage de l'hôte de service en écoute InstanceStore store = new FileInstanceStore(); host.DurableInstancingOptions.InstanceStore = store; host.Description.Behaviors.Add(new WorkflowIdleBehavior() { TimeToUnload = TimeSpan.FromSeconds(1) }); host.Open(); // Le service est en ligne Console.WriteLine("Service en ligne"); Console.Read(); host.Close(); }

Notez la présence d’un WorkflowIdleBehavior!

Sa présence est impérative si vous souhaitez voir vos instances de workflow persister. S’il venait à manquer, l’hôte ne saurait pas qu’il doit décharger et persister une instance qui passe en attente.

Le choix d’une seconde d’attente est arbitraire. Il est conseillé de prendre un temps plus long si les workflows ont des temps d’attentes courts. Ceci afin de diminuer la sollicitation du dispositif de stockage des instances persistées.

Bien entendu mon FileInstanceStore attendant un  String WorkflowStore dans le fichier de config  pour déterminer le répertoire de stockage des instances, j’ai ajouté les lignes suivantes à mon fichier app.config :

<appSettings> <add key="WorkflowStore" value="C:\WorkflowStore"/> </appSettings>

Si on lance WcfTestClient (outils livré avec Visual Studio 2010 qui se trouve dans le répertoire "C:\Program Files \Microsoft Visual Studio 10.0\Common7\IDE" répertoire x86 sur une machine x64) on pourra observer l’affichage suivant.

En lançant l’appel de la méthode SetVariable1(), j’obtiens une réponse du service qui m’indique l’état de sa Variable1

Et dans le répertoire servant de stockage des instances de workflow on peut voir de nouveaux fichiers pour l’instance persistée. Si on fait un second appel avec un autre Id de corrélation, on pourra voir d’autres fichiers s’ajouter à ce répertoire. Dans cet exemple on aura toujours 3 fichiers : un pour la Data, un pour la MetaData (Meta), et un pour l’association instance Id et Key(Key).

À cet instant, on peut très bien couper l’application et la relancer, ou bien la laisser fonctionner. Quoi qu’il arrive, notre instance est persistée.

Si on souhaite invoquer la méthode SetVariable2() avec le même Id de corrélation, on obtiendra la restauration de l’instance persistée et donc le résultat suivant :

Et le résultat de l’addition est correct : 9 (persisté lors du premier appel) +10 (ajouté lors du second appel) =19

Notre instance de Workflow ayant pris fin, le répertoire de stockage des instances est maintenant vide.

Utilisation d’un InstanceStore via un fichier de configuration

Tel quel, notre FileInstranceStore ne peut pas être instancié via un fichier de configuration. Ceci posera donc problème si on souhaite l’utiliser dans le cadre d’une application web. Heureusement, pour nous, WCF est extensible à volonté via les Behavior.

La première chose à faire va donc être de coder un ServiceBehavior. En gardant les mêmes notions de modularité que celles qui sont à la base de l’InstanceStore<T>, j’ai codé un  InstanceStoreBehavior<T>. Celui-ci va ajouter un InstanceStore<T> comme InstanceStore du WorkflowServiceHost :

/// <summary> /// Behavior permettant d'ajouter un InstanceStore<T> au WorkflowServiceHost /// </summary> /// <typeparam name="T"></typeparam> public class InstanceStoreBehavior<T> : IServiceBehavior where T : class, IStore, new() { private readonly InstanceStore<T> m_InstanceStore; /// <summary> /// Constructeur /// </summary> public InstanceStoreBehavior() { // Instanciation de l'InstanceStore<T> this.m_InstanceStore = new InstanceStore<T>(); } public void AddBindingParameters(ServiceDescription serviceDescription, System.ServiceModel.ServiceHostBase serviceHostBase, System.Collections.ObjectModel.Collection<ServiceEndpoint> endpoints, System.ServiceModel.Channels.BindingParameterCollection bindingParameters) { // Pas de paramètres } public void ApplyDispatchBehavior(ServiceDescription serviceDescription, System.ServiceModel.ServiceHostBase serviceHostBase) { // Ajout de l'instance de InstanceStore<T> au WorkflowServiceHost WorkflowServiceHost host = serviceHostBase as WorkflowServiceHost; if (host != null) { host.DurableInstancingOptions.InstanceStore = this.m_InstanceStore; } } public void Validate(ServiceDescription serviceDescription, System.ServiceModel.ServiceHostBase serviceHostBase) { // Pas de validation } }

Il reste alors à ajouter un ExtensionElement pour que mon Behavior soir disponible comme extension des Behaviors de base. Ce qui donne l’InstanceStoreElement<T> qui permettra d’instancier des InstanceStoreBehavior<T>.

/// <summary> /// Element permettant d'ajouter l'InstanceStoreBehavior<T> dans un fichier de configuration /// </summary> /// <typeparam name="T"></typeparam> public class InstanceStoreElement<T> : BehaviorExtensionElement where T : class, IStore, new() { /// <summary> /// Type du Behavior (InstanceStoreBehavior<T>) /// </summary> public override Type BehaviorType { get { return typeof(InstanceStoreBehavior<T>); } } /// <summary> /// Creation du Behavior de type InstanceStoreBehavior<T></T> /// </summary> /// <returns></returns> protected override object CreateBehavior() { return new InstanceStoreBehavior<T>(); } }

Et donc si j’utilise ces classes pour mon FileStore, j’obtiens un FileInstanceStoreElement qui se code  en une ligne :

public class FileInstanceStoreElement : InstanceStoreElement<FileStore>{}

Je peux alors l’utiliser dans mon fichier de configuration comme ceci :

<system.serviceModel> <extensions> <behaviorExtensions> <add name="fileStore" type="MyLib.WF4.DurableInstancing.FileInstanceStoreElement, MyLib.WF4.DurableInstancing.File" /> </behaviorExtensions> </extensions> <behaviors> <serviceBehaviors> <behavior> <serviceMetadata httpGetEnabled="true"/> <serviceDebug includeExceptionDetailInFaults="false"/> <workflowIdle timeToUnload="00:00:01"/> <fileStore /> </behavior> </serviceBehaviors> </behaviors> <serviceHostingEnvironment multipleSiteBindingsEnabled="true" /> </system.serviceModel>

Mon FileInstanceStoreElement a été ajouté à la section behaviorEstensions de la  liste des extensions avec le nom fileStore. Dans la section behavior utilisée par mon service je peux donc ajouter la balise <fileStore /> et ainsi associer mon FileStore au WorkflowServicHost.

Bien entendu comme dans la situation précédente, on pensera toujours à ajouter un workflowIdle.

Quand on teste le workflow ainsi exposé via notre site web, on constate exactement le même comportement que pour l’application console.

Implémentation d’un InstanceStore basé sur Azure Table

Si maintenant on souhaite utiliser WF4 et la persistance dans Azure, nous allons avoir besoin de coder un nouvel  InstanceSore. Pour mon AzureStore, j’ai choisi d’utiliser Azure Table pour stocker mes instances de workflows. Les associations Id d’instance et Key seront représentées par une table « Assocation » (nommée WorkflowStoreAssociation). La Data et la MetaData seront sockées dans une seconde table « instance » (nommée WorkflowStoreInstance).

L’AzureStore manipulera donc des données représentées par ces deux classes:

internal class AssociationRow : TableServiceEntity { public Guid Key { get; set; } } internal class InstanceRow : TableServiceEntity { public XDocument Data { get; set; } public XDocument MetaData { get; set; } }

Pour ce qui est de l’AzureStore en lui-même, je l’ai découpé en deux régions. La première a pour objectif de permettre la création des tables et de faciliter leur manipulation via des propriétés retournant les DataServiceQuery<T> correspondantes. (le compte Azure Store a été stocké dans la configuration Azure avec le nom StorageAccountConnectionString)

public class AzureStore : IStore { private const String c_StorageAccountConnectionString = "StorageAccountConnectionString"; private const String c_TableAssociation = "WorkflowStoreAssociation"; private const String c_TableInstance = "WorkflowStoreInstance"; private const String c_PartitionKey = "WorkflowStore"; private readonly TableServiceContext m_Context; /// <summary> /// Constructeur /// </summary> public AzureStore() { try { // Récupération des informations de compte pour Azure Table CloudStorageAccount account = CloudStorageAccount.FromConfigurationSetting(c_StorageAccountConnectionString); CloudTableClient client = new CloudTableClient(account.TableEndpoint.ToString(), account.Credentials); // Récuprération du contexte utilisé pour Azure Table this.m_Context = client.GetDataServiceContext(); // Création des tables si besoin client.CreateTableIfNotExist(c_TableAssociation); client.CreateTableIfNotExist(c_TableInstance); // Cas de la DevFabric pour test avant publication sur Azure if (account.Credentials.AccountName == "devstoreaccount1") { // Forcer l'ajout de données pour que le contexte soit utilisable en test AssociationRow associationRow = new AssociationRow() { PartitionKey = c_PartitionKey, RowKey = Guid.NewGuid().ToString() }; InstanceRow instanceRow = new InstanceRow() { PartitionKey = c_PartitionKey, RowKey = Guid.NewGuid().ToString() }; this.m_Context.AddObject(c_TableAssociation, associationRow); this.m_Context.AddObject(c_TableInstance, instanceRow); this.m_Context.SaveChangesWithRetries(); this.m_Context.DeleteObject(associationRow); this.m_Context.DeleteObject(instanceRow); this.m_Context.SaveChangesWithRetries(); } } catch (Exception ex) { Trace.TraceError(ex.Message); } } #region "Gestion des Azure Tables" /// <summary> /// Azure Table des association /// </summary> private DataServiceQuery<AssociationRow> TableAssociation { get { return this.m_Context.CreateQuery<AssociationRow>(c_TableAssociation); } } /// <summary> /// Azure Table des instances /// </summary> private DataServiceQuery<InstanceRow> TableInstance { get { return this.m_Context.CreateQuery<InstanceRow>(c_TableInstance); } } #endregion

Une fois ce code mis en place, il reste à ajouter le code permettant l’implémentation de l’interface Istore. Comme on peut le voir dans les lignes suivantes, Linq et Azure Table facilitent énormément la tâche et permettent d’avoir un code  réduit à son strict minimum.

#region "Implémentation de IStore" /// <summary> /// Enregister des données de persistance (Data,MetaData) /// </summary> /// <param name="instanceId"></param> /// <param name="data"></param> /// <param name="metaData"></param> public void SaveInstance(Guid instanceId, XDocument data, XDocument metaData) { try { String rowKey = instanceId.ToString(); // Recherche une ligne ne rapport avec l'instanceId IEnumerable<InstanceRow> rows = this.TableInstance .Where((c)=> c.RowKey == rowKey) .AsTableServiceQuery() .ToList(); InstanceRow row = rows.FirstOrDefault(); // test si une ligne correspond if (row == null) { // Création d'une nouvelle ligne row = new InstanceRow() { PartitionKey = c_PartitionKey, RowKey = rowKey, Data = data, MetaData = metaData }; this.m_Context.AddObject(c_TableInstance, row); } else { // Mise à jour d'une ligne existante row.Data = data; row.MetaData = metaData; this.m_Context.UpdateObject(row); } // Enregistrer les changements this.m_Context.SaveChanges(); } catch (Exception ex) { Trace.TraceError(ex.Message); throw ex; } } /// <summary> /// Chargement des données de persistance (Data,MetaData) /// </summary> /// <param name="instanceId"></param> /// <param name="instanceData"></param> /// <param name="instanceMetaData"></param> /// <returns></returns> public Boolean LoadInstance(Guid instanceId, out XDocument instanceData, out XDocument instanceMetaData) { try { String rowKey = instanceId.ToString(); // Recherche une ligne ne rapport avec l'instanceId InstanceRow row = this.TableInstance .Where((c) => c.RowKey == rowKey) .AsTableServiceQuery() .ToList() .FirstOrDefault(); // test si une ligne correspond if (row == null) { instanceData = new XDocument(); instanceMetaData = new XDocument(); return false; } else { // Si oui on conserve les données instanceData = row.Data; instanceMetaData = row.MetaData; return true; } } catch (Exception ex) { Trace.TraceError(ex.Message); throw ex; } } /// <summary> /// Enregister des données d'association entre id et key d'instance persistée /// </summary> /// <param name="instanceId"></param> /// <param name="instanceKey"></param> public void SaveInstanceAssociation(Guid instanceId, Guid instanceKey) { try { String rowKey = instanceId.ToString(); // Création d'une nouvelle ligne AssociationRow row = new AssociationRow() { PartitionKey = c_PartitionKey, RowKey = rowKey, Key = instanceKey }; this.m_Context.AddObject(c_TableAssociation, row); // Enregistrer les changements this.m_Context.SaveChanges(); } catch (Exception ex) { Trace.TraceError(ex.Message); throw ex; } } /// <summary> /// Retourner l'id d'un instance persistée correspondant à la key d'instance /// </summary> /// <param name="instanceKey"></param> /// <returns></returns> public Guid GetInstanceAssociation(Guid instanceKey) { try { // Recherche une ligne ne rapport avec l'instanceId AssociationRow row = this.TableAssociation .Where((c) => c.Key == instanceKey) .AsTableServiceQuery() .ToList() .FirstOrDefault(); // test si une ligne correspond if (row == null) { return Guid.Empty; } else { return Guid.Parse(row.RowKey); } } catch (Exception ex) { Trace.TraceError(ex.Message); throw ex; } } /// <summary> /// Supprimer toute information de persistance (Data,MetaData,Assocation) /// </summary> /// <param name="instanceId"></param> public void DeleteInstance(Guid instanceId) { String rowKey = instanceId.ToString(); // Récupération de la liste des instances à supprimer List<InstanceRow> instanceRows = this.TableInstance .Where((c) => c.RowKey == rowKey) .AsTableServiceQuery() .ToList(); foreach (InstanceRow row in instanceRows) { this.m_Context.DeleteObject(row); } // Récupération de la liste des associations à supprimer List<AssociationRow> associationRows = this.TableAssociation .Where((c) => c.RowKey == rowKey) .AsTableServiceQuery() .ToList(); foreach (AssociationRow row in associationRows) { this.m_Context.DeleteObject(row); } // Enregistrer les modifications this.m_Context.SaveChanges(); } #endregion

Maintenant que notre AzureStore est complet, il reste à ajouter un AzureStoreElement afin d’utiliser notre magasin de persistance custom dans le WebRole Azure exposant le service Addition. Si on souhaitait utiliser notre AzureStore dans un WorkerRole, il faudra envisager d’utiliser l’AzureInstanceStore.

public class AzureInstanceStoreElement : InstanceStoreElement<AzureStore>{} public class AzureInstanceStore : InstanceStore<AzureStore>{}

Nos classes profitant du travail fait sur l’InstanceStore<T>, il n’y a besoin d’aucun autre code ;)

Le fichier web.config est similaire à ce qui a été fait précédemment pour l’application web. Je n’ai changé que les noms de classes et d’assemblies (j’ai aussi choisi d’utiliser le nom complet de l’assembly Azure, mais cela n’a pas d’influence sur son comportement) :

<system.serviceModel> <extensions> <behaviorExtensions> <add name="azureStore" type="MyLib.WF4.DurableInstancing.AzureInstanceStoreElement, MyLib.WF4.DurableInstancing.Azure, Version=1.0.0.0, Culture=neutral, PublicKeyToken=6a6fce4612ad28cf" /> </behaviorExtensions> </extensions> <behaviors> <serviceBehaviors> <behavior> <serviceMetadata httpGetEnabled="true" /> <serviceDebug includeExceptionDetailInFaults="false" /> <workflowIdle timeToUnload="00:00:01" /> <azureStore /> </behavior> </serviceBehaviors> </behaviors> <serviceHostingEnvironment multipleSiteBindingsEnabled="true" /> </system.serviceModel>

Et bien entendu, si on consomme le service, on aura le même comportement que celui qui a été constaté avec l’application Console. La seule différence résidera dans le fait que nos données de persistance seront stockées dans Azure Table.

Après déploiement dans Azure et utilisation de mon service, si j’ouvre mon explorateur de serveurs dans Visual Studio, je peux voir mon application (durableinstancing) avec son WebRole et mon compte Azure Store (demowf4) qui contient les deux tables utilisées pour la persistance :

Bien entendu si vous avez des instances persistées, vous pourrez les voir en explorant les tables.

C’est magique ;)

« Happy End »

J’espère que cet article vous a permis de réaliser que la mise en place d’un dispositif de persistance personnalisé pour Workflow Foundation 4 n’était pas si compliquée que cela; surtout si l'on s’appuie sur une classe générique telle que l’InstanceStore<T> présentée ici.

Il ne vous reste donc plus qu’à appliquer les concepts présentés ici pour implémenter vos propres InstanceStore pour Acces, Oracle, MySql, ou autres.

Références

How to: Create a Custom Instance Store : Documentation MSDN sur les InstanceStore personnalisés.

Pro WF: Windows Workflow in .NET 4 : Livre de Bruce Bukovics dont je me suis inspiré pour la sérialisation des Data et MataData.