Spring Web Flow – Gestion manuelle du pool d’exécution

spring-webflow

Dans un billet précédent, je vous présentais comment dans le cadre d'un projet utilisant Spring Web Flow (SWF) nous avions géré l'initialisation d'un deuxième flow indépendant au sein d'une même session ouverte dans une autre fenêtre/onglet.

Voici un petit plus ajouté à notre solution pour permettre de réguler le nombre de flows ouverts en parallèle.

Rappel de la problématique :

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

Prenons le cas d'un wizard qui sert à générer un courrier. L'utilisateur peut décider plusieurs fois au cours de sa session de générer différents courriers. A chaque nouvelle demande de génération un nouveau flow va être créé. La création de ces flows ne poserait pas de problème, si l'application est capable de garantir la fermeture du flow à la fin de la séquence de saisie de wizard ou via un bouton d'annulation. Mais les utilisateurs étant ce qu'ils sont, rien ne nous garantit qu'ils ne vont pas ouvrir plusieurs générations de courriers en parallèle ou fermer brutalement le wizard via la fermeture de la fenêtre ou de l'onglet du navigateur. Nous nous retrouvons donc avec des flows inutilisés et non fermés.

De plus SWF définit par défaut un nombre maximum de flows pouvant être ouverts au sein d'une même session, avec comme politique d'éviction en cas de dépassement, la suppression du flow le plus ancien. Ce qui dans notre cas, aboutirait à la fermeture du flow principal.

Le paramétrage du nombre de flows pouvant être ouverts au sein d'une même session (max-executions), ainsi que le nombre de snapshots (historique de navigation) à conserver pour chaque exécution (max-execution-snapshots) se fait sous JSF dans le dispatcher-servlet.xml :

<flow:flow-executor id="flowExecutor" flow-registry="flowRegistry">
<flow:flow-execution-listeners>
...
</flow:flow-execution-listeners>
<flow:flow-execution-repository max-executions="5" max-execution-snapshots="2" />
</flow:flow-executor>

Il est possible de ne pas limiter le nombre maximum d'exécutions (max-executions="-1"), mais cela engendrerait alors une consommation mémoire proportionnelle au nombre d'exécutions inachevées.

Solution envisagée :

Pour éviter l'accumulation de flows non-utilisés, nous décidons de bloquer la multiplicité des exécutions secondaires. Pour ce faire, nous marquons les nouvelles exécutions par un TAG d'unicité et forçons en amont de l'ouverture d'une nouvelle exécution, la fermeture des exécutions existantes avec le même TAG.

Par défaut, SWF gère son pool d'exécution grâce au org.springframework.webflow.conversation.impl.SessionBindingConversationManager qui encapsule les exécutions (ou conversations) dans un org.springframework.webflow.conversation.impl.ConversationContainer. Ce container est stocké dans la session sous le nom "webflowConversationContainer". L'idée est donc de pouvoir à la création d'une nouvelle exécution, consulter ce container et supprimer les conversations marquées avec le TAG désigné.

Solution détaillée :

Le premier problème réside dans le fait que le ConversationContainer est une classe avec une visibilité default et dont la liste de conversations encapsulées est une propriété privée non accessible par getters.

Première étape : Créer un utilitaire permettant d'accéder aux conversations en cours d'exécution qui se situera dans le package de ConversationContainer :

package org.springframework.webflow.conversation.impl;

import org.springframework.webflow.conversation.Conversation;
import org.springframework.webflow.conversation.ConversationId;
...

/**
* Helper permettant d’accéder au ConversationContainer.
*/
public class ConversationContainerHelper {

private static final Logger LOG = LoggerFactory.getLogger(ConversationContainerHelper.class);

/**
* Retourne la conversation d'id donnés encapsulés dans le webflowConversationContainer.
*
* @param conversationId
* @return
*/
public static Conversation getConversation(ConversationId conversationId) {
final ConversationContainer conversationContainer = (ConversationContainer) SessionHelper
.getSessionAttribute("webflowConversationContainer");
return conversationContainer.getConversation(conversationId);
}

/**
* Retourne la liste des conversations encapsulées dans le webflowConversationContainer par introspection.
*
* @return
*/
public static List<Conversation> getConversations() {
final ConversationContainer conversationContainer = (ConversationContainer) SessionHelper
.getSessionAttribute("webflowConversationContainer");
try {
final List<Conversation> conversations = (List<Conversation>) ReflectionUtils.getProperty(
conversationContainer, "conversations");
return conversations;
} catch (final SecurityException e) {
LOG.error(e.getMessage(), e);
} catch (final IllegalArgumentException e) {
LOG.error(e.getMessage(), e);
} catch (final NoSuchFieldException e) {
LOG.error(e.getMessage(), e);
} catch (final IllegalAccessException e) {
LOG.error(e.getMessage(), e);
}
return new ArrayList<Conversation>();
}
}

Deuxième étape : supprimer lors de la préparation d'une instanciation d'exécution pour un TAG d'unicité donné les exécutions en cours portant ce même TAG (cf. blog précédent transition goOtherFlow) :

/**
* Classe utilitaire permettant de gérer des paramètres d'initialisation du flow.
*/
public class FlowExecutionHelper {
...
/**
* Met en session la map d'attributs nécessaires à l'initialisation de la prochaine exécution.
*
* @param scopesAttributeMap la map contenant les maps d'attributs de chaque scope
* @param executionUniqueTypeKey la clé d'unicité à placer dans le flash scope pour marquer l'exécution suivante
*/
public static void prepareExecution(final Map<WebFlowScope, Map<String, Object>> scopesAttributeMap,
final String executionUniqueTypeKey) {
...
// on ajoute dans le flashScope le TAG qui va servir à marquer la prochaine exécution
addToExecutionSessionMap(WebFlowScope.FLASH, "executionUniqueTypeKey", executionUniqueTypeKey);
// on ferme les exécutions en cours portant déjà ce TAG
endExecutionUniqueTypeDoublon(executionUniqueTypeKey);
}

/**
* Parcourt les conversations existantes et supprime celles ayant le marqueur d'unicité fournit.
*
* @param executionUniqueTypeKey la clé d'unicité
*/
private static void endExecutionUniqueTypeDoublon(final String executionUniqueTypeKey) {
if (StringUtils.isBlank(executionUniqueTypeKey)) {
// Si la clé est vide, l'exécution ne respecte pas de contrainte d'unicité particulière
return;
}
// On récupère les exécutions en cours
final List<Conversation> conversations = ConversationContainerHelper.getConversations();
final List<Conversation> doublons = new ArrayList<Conversation>();
for (final Conversation conversation : conversations) {
// On vérifie l'existence du TAG d'unicité
final Object attribute = conversation.getAttribute("executionUniqueTypeKey");
if (executionUniqueTypeKey.equals(attribute)) {
// On ajoute les exécutions dont le TAG correspond à la liste des exécutions à fermer
LOG.debug("Supression de la conversation '{}' car doublon du type '{}'", conversation.getId()
.toString(), executionUniqueTypeKey);
doublons.add(conversation);
}
}
// On force la fermeture des exécutions
for (final Conversation conversation : doublons) {
conversation.end();
}
}
...
}

Troisième étape : Le marquage des conversations avec un tag d'unicité à leur initialisation (cf. blog précédent InitialisationFlowExecutionListenerAdapter) :

/**
* Classe utilitaire permettant de gérer des paramètres d'initialisation du flow.
*/
public class FlowExecutionHelper {
...
/**
* Récupère la map d'attributs nécessaires à l'initialisation de l'exécution et place ces derniers dans les
* scopes webflow.
* Retire ensuite la map de la session.
*/
@SuppressWarnings("unchecked")
public static void initExecution() {
final Map<WebFlowScope, Map<String, Object>> flowExecutionSessionMap = (Map<WebFlowScope, Map<String, Object>>) SessionHelper
.getSessionAttribute(INIT_FLOW_EXECUTION_MAP);
if (MapUtils.isNotEmpty(flowExecutionSessionMap)) {
...
// On récupère s'il y a lieu la clé d'unicité qui servira de TAG de l'exécution en cours d'instanciation
final String executionUniqueTypeKey = (String) RequestContextHelper
.getScopeAttribute(WebFlowAttribute.EXECUTION_UNIQUE_TYPE_KEY);
setExecutionUniqueType(executionUniqueTypeKey);
// Réinitialisation du contexte des futures exécutions
SessionHelper.removeSessionAttribute(INIT_FLOW_EXECUTION_MAP);
}
}

/**
* Place un marqueur d'unicité sur la conversation courante.
*
* @param executionUniqueTypeKey  la clé d'unicité
*/
private static void setExecutionUniqueType(final String executionUniqueTypeKey) {
// On s'assure que l’exécution en cours de création a bien une clé d'identification
final RequestContext currentRequestContext = RequestContextHelper.getCurrentRequestContext();
((RequestControlContext) currentRequestContext).assignFlowExecutionKey();
// On récupère cette clé
final FlowExecutionContext flowExecutionContext = currentRequestContext.getFlowExecutionContext();
final CompositeFlowExecutionKey executionKey = (CompositeFlowExecutionKey) flowExecutionContext.getKey();
final ConversationId conversationId = (ConversationId) executionKey.getExecutionId();
// On marque l'exécution avec comme TAG la clé d'unicité stockée sous forme d'attribut
final Conversation conversation = ConversationContainerHelper.getConversation(conversationId);
LOG.debug("Marquage de la conversation '{}' avec le type '{}'", conversation.getId().toString(),
executionUniqueTypeKey);
conversation.putAttribute("executionUniqueTypeKey", executionUniqueTypeKey);
}
...
}

Et voilà, en mettant en corrélation la taille du pool d'exécutions possibles avec le nombre de types d'exécutions uniques différentes (ex: génération courrier, recherche avancée, ...), on garantit à notre application une gestion intelligente du pool d'exécution.

Comme toujours, 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.