Retrouvez cet article dans : Linux Magazine 83
Fig. 1 : Un serveur JMS actif et un serveur JMS passif.
Étant donné que la consommation des queues n’est pas prédictible, une distribution aléatoire des messages dans une des deux queues est suffisante. Il n’est pas nécessaire d’avoir une répartition de type Round Robin.
Comment le client peut-il voir deux queues comme une seule ? Plusieurs approches sont possibles :
- Écrire un code spécifique pour chaque client des deux queues actives.
- Écrire un driver JMS s’occupant de présenter les deux queues comme une seule.
- Offrir un driver répondant aux spécifications JMS afin de remplacer le driver défaillant permet de ne pas modifier le code client, et ouvre la perspective d’utiliser plus tard, un driver JMS HD (Haute Disponibilité) lorsqu’il sera disponible, sans remettre en cause le code applicatif.
Fig. 2 : Deux serveurs JMS actifs et deux serveurs JMS passifs
Nous allons nous focaliser sur la deuxième approche, même si elle semble plus complexe au premier abord. Nous allons voir que l’utilisation d’une idée forte, les functors, va nous simplifier grandement la tâche de rédaction.
1. La stratégie
Nous devons dans un premier temps regarder comment implémenter un driver JMS. Comme pour la plupart des drivers normalisés, seules des interfaces sont à renseigner. Il n’y a pas de classes à hériter. C’est un bon présage. En effet, cela indique que la construction de chaque instance du driver est sous le contrôle de celui-ci. Il existe obligatoirement des méthodes de construction pour chaque objet. En effet, en regardant un peu plus dans le détail, c’est bien le cas. La toute première classe d’un driver JMS est unclass HAJMSConnection implements Connection
{
private Connection[] connections_;
HAJMSConnection(Connection[] connections)
{
connections_=connections;
}
}
Ainsi, pour une connexion de notre driver, plusieurs connexions sont ouvertes, une par driver JMS correspondant. Une instance
Fig. 3 : Extrait du modèle objet
Une instance HAJMSConnection référence l’instance HAJMSConnectionFactory et possède des instances Connection. L’instance HAJMSSession référence l’instance HAJMSConnection, et possède des instances Session.
L’instance HAJMSConnectionFactory est la mère de tous. Elle connaît l’état des différentes queues grâce aux ConnectionFactory qu’elle agrège.
Notre driver doit parfois appliquer la même méthode à chaque sous-composant agrégé, soit l’appliquer au premier disponible.
Par exemple, si un client du driver JMS souhaite enregistrer un MessageListener (une instance invoquée lors de l’arrivé d’un message), il faudra enregistrer cette instance sur toutes les queues. Ainsi, si un message arrive sur n’importe quelle queue, le client est notifié.
Parfois, les méthodes ne s’appliquent qu’à une seule des queues. Par exemple, demander l’instance enregistrée pour recevoir les évènements d’une queue ne s’applique qu’à une seule queue car nous savons, par construction, que toutes les queues sont paramétrées de façon identique.
Pour schématiser, les méthodes de set s’appliquent à toutes les instances ; les méthodes de get ne s’appliquent qu’à la première active.
Nous pouvons constater qu’il existe trois familles de méthode : les méthodes de construction, les méthodes s’appliquant à toutes les instances et les méthodes s’appliquant à la première active.
Occupons-nous de la situation où un traitement doit s’appliquer à toutes les instances. Par exemple, la méthode setMessageListener() de l’interface QueueReceiver. Elle peut être écrite comme ceci :
class HAJMSQueueReceiver implements QueueReceiver
{
QueueReceiver[] queueReceivers_;
//?
public void setMessageListener(MessageListener msgListener) throws JMSException
{
for (int i=0;i<queueReceivers.length;++i)
{
queueReceivers_[i].setMessageListener(msgListener);
}
}
}
2. Gérer les erreurs
Ce code est simple, mais il ne gère pas les erreurs. En effet, comme l’indique la signature, la méthodethis.connection_.factory_.isFailed(x)La boucle doit être plus complexe. Elle doit vérifier qu’un serveur est valide avant d’invoquer un traitement et capturer les exceptions pour déclarer le serveur HS avant de continuer. Que se passe-t-il si toutes les queues sont en échec ? Il faut le signaler à l’appelant. Nous allons alors garder un pointeur vers la dernière exception pour l’envoyer, si et seulement si, aucune invocation n’a réussi. Voici la version modifiée de notre méthode. (Notez la boucle
class HAJMSQueueReceiver implements QueueReceiver
{
QueueReceiver[] queueReceivers_;
//?
public void setMessageListener(MessageListener msgListener) throws JMSException
{
JMSException last=null;
int nbOk=0;
for (int i=queueReceivers_.length-1;i>=0;--i)
{
try
{
if (queueReceivers_[i]!=null)
{
queueReceivers_[i].setMessageListener(msgListner);
++nbOk;
}
}
catch (JMSException e)
{
last=e;
setError(i);
}
}
if (nbOk==0)
throw last;
}
}
Pour gérer les situations où il faut invoquer une méthode sur la première instance valide, le code est proche mais différent. Il faut continuer la boucle tant qu’il y a des exceptions. Par exemple, la méthode class HAJMSQueueReceiver implements QueueReceiver
{
QueueReceiver[] queueReceivers_;
//?
public MessageListener getMessageListener() throws JMSException
{
JMSException last=null;
int nbBad=0;
for (int i=queueReceivers_.length-1;i>=0;--i)
{
try
{
if (instances[i]==null) continue;
return queueReceivers_[i].getMessageListener();
}
catch (JMSException e)
{
++nbBad;
last=e;
setError(i);
}
}
throw last;
}
}
Notez la présence du 3. Ressusciter un serveur
À la longue, avec ce code, il y aura de moins en moins de serveurs actifs. Il suffit d’une erreur sur chaque serveur pour que l’application ne fonctionne plus. Nous souhaitons un driver HD (Haute Disponibilité). Cela veut dire que nous souhaitons cacher les erreurs lorsqu’elles arrivent s’il existe un chemin de contournement, mais également, nous souhaitons pouvoir réutiliser un serveur lorsqu’il devient à nouveau disponible. Une tâche de fond, branchée sur une tempo, peut tenter une connexion sur un serveur. Si celui-ci répond positivement, il est à déclarer à nouveau disponible. Comment propager cette bonne nouvelle à toutes les instances créées par le driver ? Plusieurs approches sont possibles :- garder un lien de l’instance principale vers toutes les instances créées pour pouvoir les modifier lorsqu’un serveur est disponible ;
- ressusciter une instance de manière paresseuse, lorsqu’une de ses méthodes pourrait en avoir besoin.
4. Factoriser le code : les functors
Nous avons une architecture systématique, une stratégie de reprise et une façon de faire pour chaque type de méthode. Il nous reste à implémenter toutes les interfaces des spécifications JMS (dix-neuf classes, cent quatorze méthodes). C’est là que le bas blesse. En effet, chaque méthode est différente, possède des paramètres différents. Nous avons un modèle de code, mais celui-ci doit être spécialisé pour chaque méthode. Les lignes en gras dans les sources indiquent les endroits des algorithmes devant être spécialisés pour chaque méthode. La première solution pour régler cela consiste à dupliquer le modèle dans chaque méthode, et adapter les quelques lignes pour chaque situation. Cela est très lourd et présente un grand risque de bug. Que faire si on désire modifier le modèle ? Il faut revoir toutes les méthodes ! Même si cela est faisable, ce n’est pas raisonnable. Une autre approche consiste à utiliser des functors. Un functor est une instance portant des fonctions. Elle ne possède pas d’attribut. Ce concept est très utilisé en C++ pour gérer les conteneurs. Par exemple, un algorithme de tri peut être plus ou moins rapide ou complexe. À un moment donné, il doit comparer deux objets. Comment livrer à l’algorithme le morceau s’occupant de la comparaison ? A l’aide d’un functor. Les classes class Functors
{
public interface FunctorBuild
{
Object createObject(int idx) throws JMSException;
boolean isFailed(int idx);
void setError(int idx) throws JMSException;
}
public interface FunctorDoIt
{
Object doIt(Object instance) throws JMSException;
void setError(int idx);
void resurate(int idx);
}
public static Object[] build(
Object[] instances,
Object[] newInstances,
Log logger,
String msg,
FunctorBuild functor)
{
?
}
public static void forEach(
Object[] instances,
Log logger,
String msg,
FunctorDoIt functor) throws JMSException
{
?
}
public static Object forFirst(
Object[] instances,
Log logger,
String msg,
FunctorDoIt functor) throws JMSException
{
?
}
}
Les algorithmes génériques queueReceivers_[i].setMessageListener(msgListener);sera modifiée en :
functor.doIt(queueReceivers_[i])Et ainsi de suite. Notez la perte du paramètre
5. Utiliser les functors
Nous souhaitons rédiger la méthodepublic void setMessageListener(final MessageListener messageListener) throws JMSException
{
log.debug("setMessageListener()");
Functors.forEach(queueReceivers_,
log,"set a message listner",
new FunctorDoIt()
{
public Object doIt(Object instance) throws JMSException
{
((QueueReceiver)instance).setMessageListener(messageListener);
return null;
}
public void setError(int idx)
{
unregister(idx);
}
public void resurate(int idx)
{
doResurate(idx);
}
});
messageListener_=messageListener;
}
L’algorithme public void setMessageListener(Personnellement, je préfère l’écriture compacte. Dans la méthodefinalMessageListener messageListener) throws JMSException { classMonFunctorimplements FunctorForEach { public Object doIt(Object instance) throws JMSException { ((QueueReceiver)instance).setMessageListener(messageListener); return null; } public void setError(int idx) { unregister(idx); } public void resurate(int idx) { doResurate(idx); } } log.debug(“setMessageListener()”); Functors.forEach(queueReceivers_,log,”set a message listener”,new MonFunctor()); messageListener_=messageListener; }
public boolean getTransacted() throws JMSException
{
log.debug("getTransacted()");
return ((Boolean)Functors.forFirst(sessions_,
log,"get a transacted status",
new FunctorDoIt()
{
public Object doIt(Object instance) throws JMSException
{
return new Boolean(((Session)instance).getTransacted());
}
public void setError(int idx)
{
unregister(idx);
}
public void resurate(int idx)
{
doResurate(idx);
}
})).booleanValue();
}
Nous constatons que les nombreux functors public class HAJMSQueueSession implements QueueSession
{
protected abstract class AbstractFunctorDoIt implements FunctorDoIt
{
public abstract Object doIt(Object instance) throws JMSException;
public void setError(int idx)
{
unregister(idx);
}
public void resurate(int idx)
{
doResurate(idx);
}
}
//?
}
Nos méthodes vont maintenant instancier le presque-functor, en remplissant la méthode public void setMessageListener(finalMessageListener messageListener) throws JMSException { log.debug("setMessageListener()"); Functors.forEach(queueReceivers_, log,"set a message listner",new AbstractFunctorDoIt(){ public Object doIt(Object instance) throws JMSException {((QueueReceiver)instance).setMessageListener(messageListener);return null;} }); messageListener_=messageListener; }
Et encore des lignes gagnées et des bugs en moins.Il nous reste à regarder comment construire une instance. Voici la méthode
public ConnectionConsumer createConnectionConsumer(final Queue queue,
final String messageSelector,
final ServerSessionPool serverSessionPool,
final int maxMessage) throws JMSException
{
log.debug("createConnectionConsumer()");
return new HAJMSConnectionConsumer(this,
queue,serverSessionPool,messageSelector,maxMessage,
(ConnectionConsumer[])Functors.build(connections_,
new ConnectionConsumer[connections_.length],
log,"create a connection cusumer",
new AbstractFunctorBuild()
{
public Object createObject(int idx) throws JMSException
{
return ((QueueConnection)connections_[idx])
.createConnectionConsumer(queue,
messageSelector,
serverSessionPool,
maxMessage);
}
})
);
}
La méthode 6. Tester le code
Très bien. Nous pouvons écrire un test unitaire utilisant les API JMS, en exploitant un driver classique. Nous évitons d’utiliser le nôtre pour le moment. Une fois le test déverminé, nous modifions le driver utilisé pour exploiter le nôtre, en lui livrant un tableau defactories[0]=new XXXQueueConnectionFactory(props); factories[1]=new XXXQueueConnectionFactory(props); factory=new HAJMSQueueConnectionFactory(factories);Nous cherchons à vérifier que notre driver fonctionne correctement lors de situation d’échec d’un des drivers sous-jacents. Comme faire cela ? Il n’est pas facile d’obtenir des erreurs lors de chaque invocation des méthodes. Utilisons les possibilités qu’offre la factorisation avancée de notre code ! Nous ajoutons, à des endroits stratégiques des trois méthodes génériques, la production aléatoire d’exceptions.
if (random(100) < PERCENT_ERROR)
throw new JMSException("Test HA");
En choisissant de générer une exception dans 40% des invocations, nous pouvons tester le driver dans de nombreuses situations. Étant donné que nous simulons la perte d’un serveur, celui-ci étant en réalité toujours valide, il est possible de le réactiver immédiatement.
De même, un paramètre de test permet d’indiquer le pourcentage de méthodes pouvant réactiver un serveur HS. Par exemple, une méthode sur deux (50%) peut réactiver un serveur. Ainsi, les mécanismes de reprises des instances se mettent en marche.
Du point de vue du test unitaire, toutes ces perturbations doivent être invisibles, sauf si le hasard entraîne que tous les serveurs sont déclarés HS au même moment.
En utilisant cette approche, de nombreux bugs ont été détectés. Pour pouvoir les retrouver, le générateur aléatoire garde un historique des valeurs et l’affiche à la fin du test. Il suffit d’injecter ces valeurs dans le générateur et de les lui faire rejouer, pour pouvoir partir à la chasse au bug. Très rapidement, en une demi-journée, les tests unitaires étaient systématiquement valides, dans des conditions que je n’aurais jamais imaginées.
Par exemple, comment doit se comporter le code lorsqu’il désire ressusciter une instance et qu’il y a une exception à ce moment-là ? De même, que faire si une instance a besoin d’une autre instance pour pouvoir se ressusciter ? Il faut, auparavant, ressusciter l’autre instance. Sans les tortures-tests, je n’aurais pas imaginé et détecté ces situations.
Nous pouvons alors ajouter une tâche de fond en charge de détecter qu’un serveur HS revient à la vie.
7. Généralisons
Nous avons utilisé une stratégie de développement pour rendre hautement disponible un driver JMS qui ne l’est pas. Cela consiste à agréger plusieurs instances du même type dans une seule et à propager les modifications dans chaque instance. Nous avons identifié trois algorithmes à utiliser systématiquement pour implémenter cette approche. Nous avons utilisé la tactique des functors pour simplifier le code. Cela nous a permis de rédiger un driver JMS HD en moins d’une semaine. De plus, lors des phases de tests, nous avons bénéficié de notre approche pour pouvoir effectuer des tortures-tests. Finalement, les cent quatorze méthodes se résument essentiellement à trois. Cette approche peut être reproduite pour d’autres drivers. Par exemple, un driver JavaMail qui utiliserait plusieurs serveurs SMTP et POP3, un driver JDBC dupliquant les mises à jour sur plusieurs bases de données, etc. Avec une bonne stratégie et une bonne tactique, ce qui semblait être un challenge difficile s’est révélé être beaucoup plus facile que prévu. Plus tard, lorsqu’un driver JMS HD sera disponible, l’architecture pourra être considérablement simplifiée. Une seule modification sera à apporter : la classe du driver JMS à utiliser. Notre code pourra être jeté sans ménagement. Il nous aura permis d’enrichir nos connaissances sur les tolérances aux pannes et sur l’application systématique des functors. Pensez aux functors lorsque vous souhaitez spécialiser finement des algorithmes complexes. Vous retrouverez tout le code sur mon site : http://www.philippe.prados.name.Retrouvez cet article dans : Linux Magazine 83





Donnez votre avis
Vous devez avoir ouvert une session pour écrire un commentaire.