Spring Web Flow – Initialisation d’une nouvelle exécution et de son contexte.

spring-webflow

Comment sous Spring Web Flow, peut-on ouvrir en parallèle un deuxième flow de page pré-initialisé avec un contexte de données spécifiques? Voici une solution qui a été trouvée dans le cadre d'un projet web utilisant Spring Web Flow 2.2.1 associé à JSF 1.2 et la surcouche RichFaces 3.3.3.FINAL.

Avant de parler de la problématique et de notre solution, commençons par une petite introduction de ce framework.

Introduction à Spring Web Flow :

Spring Web Flow (SWF) est un sous-projet de Spring Framework. Il permet de gérer des 'transitions' (enchaînements) entre différentes 'view-state' (pages) d'une application web.

Le principe est simple, le projet peut définir un ou plusieurs 'flow' contenant un ensemble de 'view-state'. Chaque view est associée à une page et autorise un certain nombre de 'transitions' qui peuvent permettre l’exécution d'un traitement, notamment par l'appel d'une méthode d'un bean spring (ex: un controller java), et/ou la redirection vers une autre 'view-state'.

Il est possible d'aller encore plus loin en utilisant des :

  • decision-state : équivalent d'un if/else dans la dynamique des enchaînements
  • action-state : équivalent d'un switch permettant notamment en fonction du résultat d'une action de déterminer la 'view-state' cible.
  • sub-flow : qui définit un sous-enchaînement d'écrans
  • et bien d'autres fonctionnalités

Lorsque l'on appelle l'url d'un flow, SWF crée une nouvelle exécution (ou conversation) lui permettant de créer un contexte de gestion des informations en définissant une série de 'scope'. Chaque scope correspond à une map avec un cycle de vie propre et permettant de stocker les informations de traitement ou d'affichage de la vue. Voici les principaux scopes :

  • flashScope : map spécifique des informations d'un traitement interne, réinitialisé à tout affichage de vue. Il permettra par exemple à une transition de fournir un paramètre ne servant qu'à l'initialisation d'une vue et ne devant pas être conservé.
  • viewScope : map spécifique des informations d'une vue, réinitialisée à tout changement de vue. Par exemple le formulaire contenant les données spécifiques d'affichage de la vue.
  • flowScope : map spécifique des informations d'un flow, on y stocke donc les données partagées entre toutes les vues d'un même flow, réinitialisée à la sortie du flow.
  • conversationScope : map spécifique d'une même exécution. Elle est partagée entre tous les sous-flow et ne se réinitialise qu'en sortie du flow principal et donc de l'exécution courante.

Problématique :

Dans notre projet, nous utilisons un flow principal permettant de consulter et saisir des données sous la forme de formulaires et des flows secondaires appelés wizards nous permettant de saisir de nouvelles données complexes nécessitant plusieurs étapes (écrans).

Le client a demandé que certaines de ces saisies de type 'wizard' puissent être réalisées en parallèle du flow principal. Il est tout à fait trivial d'ouvrir une nouvelle page pointant sur un autre flow. Cependant le sujet devient plus intéressant quand il faut pouvoir initialiser cette nouvelle exécution en fonction du contexte dans lequel nous étions.

Solution envisagée :

Pour ne pas avoir à quitter le flow principal pendant la saisie de ces nouvelles données, il suffit d'appeler dans une autre fenêtre/onglet du navigateur une url pointant vers le flow du wizard désiré. SWF crée alors automatiquement une nouvelle exécution permettant d'avoir des scopes de données spécifiques non partagés avec l'exécution du flow principal.

SpringWebFlow utilise par défaut un SessionBindingConversationManager permettant de pouvoir dans une même session utilisateur gérer plusieurs exécutions en parallèle. Afin d'initialiser les scopes de cette nouvelle exécution nous utilisons la session commune à ces 2 exécutions pour y stocker temporairement les paramètres d'initialisation.

Solution détaillée :

Première étape : créer un lien permettant d'ouvrir une nouvelle fenêtre/onglet vers le flow/wizard et appelant une action côté serveur

Dans notre cas nous avions une implémentation jsf avec la surcouche RichFaces nous permettant de réaliser une ouverture en 2 étapes :

a - Appeller une action avant de cliquer sur un lien

<a4j:commandLink value="Ouvrir autre flow">
<a4j:support event="onclick" action="goOtherFlow" oncomplete="document.getElementById('openOtheFlowExecution').click();"/>
</a4j:commandLink>

b - Cliquer sur le lien avec fonction javaScript pour nous permettre de nous assurer de garder le focus sur cette nouvelle fenêtre si une fenêtre de même nom existe

// ouverture d'une nouvelle exécution
<h:outputLink id="openOtheFlowExecution" value="#{request.contextPath}/flows/otherFlow" onclick="closeTargetWindow('_otherFlowWindow');" target="_otherFlowWindow"/>

NB : fonction javaScript testée sous firefox pour garder le focus sur l'onglet ouvert (ferme la potentielle page existante avec le même nom avant de la rouvrir)

closeTargetWindow = function(targetName) {
/* on ouvre la fenêtre pour se la réapproprier*/
var targetWindow = window.open('',targetName,'');
targetWindow.close();
}

Deuxième étape - Mettre en session le contexte d'initialisation de la future exécution

a - Dans notre flow principal nous déclarons une transition redirigeant vers un contrôleur (singleton spring) préparant une Map<String, Object> de données d'initialisation par scope SWF.

<transition on="goOtherFlow" validate="false">
<evaluate expression="otherFlowControleur.prepareExecutionOtherFlow()" />
</transition>

b - Le controleur appelle ensuite transitivement une classe utilitaire qui va se charger de stocker cette map en session sous un nom reconnaissable

NB : pour plus de facilité, nous avons créé une énumération des différents scopes dont nous avions l'utilité dans SWF pour déterminer l'initialisation

public enum WebFlowScope {
FLASH, VIEW, FLOW, CONVERSATION;
}
package ...
import org.springframework.webflow.core.collection.SharedAttributeMap;
import org.springframework.webflow.engine.RequestControlContext;
import org.springframework.webflow.execution.FlowExecutionContext;
import org.springframework.webflow.execution.RequestContext;
import org.springframework.webflow.execution.RequestContextHolder;
...
/**
* Classe utilitaire permettant de gérer des paramètres d'initialisation du flow.
*/
public class FlowExecutionHelper {

/** Field INIT_FLOW_EXECUTION_MAP. */
private static final String INIT_FLOW_EXECUTION_MAP = "INIT_FLOW_EXECUTION_MAP";

/**
* Met en session la map d'attributs nécessaire à l'initialisation de la prochaine exécution.
*/
public static void prepareExecution(final Map<WebFlowScope, Map<String, Object>> scopesAttributeMap) {
SharedAttributeMap sessionMap = getSessionMap();
sessionMap.put(INIT_FLOW_EXECUTION_MAP, new HashMap<WebFlowScope, Map<String, Object>>());
if (MapUtils.isNotEmpty(scopesAttributeMap)) {
for (final WebFlowScope scope : scopesAttributeMap.keySet()) {
final Map<String, Object> attributeMap = scopesAttributeMap.get(scope);
addToExecutionSessionMap(scope, attributeMap);
}
}
}

/**
* Ajoute les attributs nécessaires à l'initialisation de la prochaine exécution en leur associant le scope dans
* lequel ils doivent être initialisés.
*
* @param scope
* @param attributeMap
*/
private static void addToExecutionSessionMap(final WebFlowScope scope, final Map<String, Object> attributeMap) {
if (MapUtils.isNotEmpty(attributeMap)) {
for (final String attributeName : attributeMap.keySet()) {
final Object attributeValue = attributeMap.get(attributeName);
addToExecutionSessionMap(scope, attributeName, attributeValue);
}
}
}

/**
* Ajoute l'attribut nécessaire à l'initialisation de la prochaine exécution en lui associant le scope dans
* lequel il devra être initialisé.
*
* @param scope
* @param attributeName
* @param attributeValue
*/
@SuppressWarnings("unchecked")
private static void addToExecutionSessionMap(final WebFlowScope scope, final String attributeName,
final Object attributeValue) {
SharedAttributeMap sessionMap = getSessionMap();
Map<WebFlowScope, Map<String, Object>> flowExecutionSessionMap =
(Map<WebFlowScope, Map<String, Object>>) sessionMap.get(INIT_FLOW_EXECUTION_MAP);
if (flowExecutionSessionMap == null) {
flowExecutionSessionMap = new HashMap<WebFlowScope, Map<String, Object>>();
sessionMap.put(INIT_FLOW_EXECUTION_MAP, flowExecutionSessionMap);
}
Map<String, Object> attributeMap = flowExecutionSessionMap.get(scope);
if (attributeMap == null) {
attributeMap = new HashMap<String, Object>();
flowExecutionSessionMap.put(scope, attributeMap);
}
LOG.debug("executionSessionMap - add '{}' -> '{}'.", attributeName, scope.name());
attributeMap.put(attributeName, attributeValue);
}

/**
* Retourne la session rattachée au contexte courant.
*
* @return
*/
private static SharedAttributeMap getSessionMap() {
return RequestContextHolder.getRequestContext().getExternalContext().getSessionMap();
}
}

Troisième étape - A l'ouverture de la nouvelle exécution récupérer la map en session pour initialiser le flow

a - Déclarer dans dispatcher-servlet.xml un FlowExecutionListener permettant de réaliser à l'initialisation d'un nouveau flow l'import des données d'initialisation dans les différents scopes prévus.

<bean id="initialisationFlowExecutionListenerAdapter" scope="singleton" />
<flow:flow-executor id="flowExecutor" flow-registry="flowRegistry">
<flow:flow-execution-listeners>
...
<flow:listener ref="initialisationFlowExecutionListenerAdapter" />
...
</flow:flow-execution-listeners>
<flow:flow-execution-repository max-executions="5" max-execution-snapshots="2" />
</flow:flow-executor>

NB : notre initialisation étendra l'adapter FlowExecutionListenerAdapter, nous permettant de ne surcharger que la méthode souhaitée.

/**
* Listener permettant d'initialiser l'exécution avec une map partagée en session.
*/
public class InitialisationFlowExecutionListenerAdapter extends FlowExecutionListenerAdapter {

/**
* {@inheritDoc}
*
* @see org.springframework.webflow.execution.FlowExecutionListenerAdapter#sessionStarting(org.springframework.webflow.execution.RequestContext,
*      org.springframework.webflow.execution.FlowSession,
*      org.springframework.webflow.core.collection.MutableAttributeMap)
*/
@Override
public void sessionStarting(RequestContext context, FlowSession session, MutableAttributeMap input) {
super.sessionStarting(context, session, input);
FlowExecutionHelper.initExecution();
}

b - Le listener appelle alors transitivement le FlowExecutionHelper qui se charge de remplir les différents scopes

/**
* Classe utilitaire permettant de gérer des paramètres d'initialisation du flow.
*/
public class FlowExecutionHelper {

/** Field INIT_FLOW_EXECUTION_MAP. */
private static final String INIT_FLOW_EXECUTION_MAP = "INIT_FLOW_EXECUTION_MAP";

...

/**
* Récupère la map d'attributs nécessaire à l'initialisation de l'exécution et place ces derniers dans les
* scopes webflow.
* Retire ensuite la map de la session pour éviter l'initialisation d'un autre flow avec ce même jeu de données.
*/
@SuppressWarnings("unchecked")
public static void initExecution() {
SharedAttributeMap sessionMap = getSessionMap();
final Map<WebFlowScope, Map<String, Object>> flowExecutionSessionMap = (Map<WebFlowScope, Map<String, Object>>) sessionMap
.get(INIT_FLOW_EXECUTION_MAP);
if (MapUtils.isNotEmpty(flowExecutionSessionMap)) {
for (final WebFlowScope scope : flowExecutionSessionMap.keySet()) {
final Map<String, Object> attributeMap = flowExecutionSessionMap.get(scope);
initExecutionScopeAttributes(scope, attributeMap);
}
}
}

/**
* Ajoute les attributs de la map fournis au scope donné.
*
* @param scope
* @param attributeMap
*/
private static void initExecutionScopeAttributes(final WebFlowScope scope, final Map<String, Object> attributeMap) {
if (MapUtils.isNotEmpty(attributeMap)) {
for (final String attributeName : attributeMap.keySet()) {
final Object attributeValue = attributeMap.get(attributeName);
setScopeAttribute(scope, attributeName, attributeValue);
}
}
}

/**
* Enregistre la valeur de l'attribut correspondant dans le scope du request context fournit.
*
* @param requestContext
* @param scope
* @param attributeName
* @param attributeValue
*/
public static void setScopeAttribute(final WebFlowScope scope,
final String attributeName, final Object attributeValue) {
final RequestContext requestContext = RequestContextHolder.getRequestContext();
final Map<String, Object> scopeMap;
try {
switch (scope) {
case FLASH:
scopeMap = requestContext.getFlashScope().asMap();
case VIEW:
scopeMap = requestContext.getViewScope().asMap();
case FLOW:
scopeMap = requestContext.getFlowScope().asMap();
case CONVERSATION:
scopeMap = requestContext.getConversationScope().asMap();
default:
// Scope inconnu ou non géré
return;
}
scopeMap.put(attributeName, attributeValue);
} catch (final IllegalStateException e) {
LOG.warn(e.getMessage(), e);
}
}

}

Et voilà, nous avons une nouvelle exécution indépendante pré-initialisée avec un contexte issu d'une autre exécution.

Pour approfondir vos connaissances sur SWF, n'hésitez pas à consulter la Doc de référence.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Captcha *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.