Chiron oriente fortement la façon dont communiquent le client et le serveur, de façon à supporter les actualisations impromptues. Pour commencer, il impose sa propre terminologie.
=== Terminologie
Quand nous parlons de client et de serveur, il y a une référence évidente à ce modèle de communication appelé "client-serveur" qui stipule que le client initie la requête vers le serveur, puis attend la réponse. Si le serveur peut initier l'envoi de données vers le client, c'est qu'on parle d'autre chose. De plus, nous vivons dans un monde où la plupart des processus ouvrent des connexions dans tous les sens. Un client réseau en Java peut être un serveur JMX, et un serveur HTTP peut être le client d'un SGBDR. Donc nous gagnerons à utiliser une terminologie spécifique.
Chiron utilise le terme "Upend" pour désigner le serveur applicatif, et le terme "Downend" pour le client applicatif. Que le serveur applicatif soit en "haut" est un choix totalement arbitraire, correspondant à certaines habitudes de représentation, ainsi qu'à l'analogie avec la transmission satellitaire -- il est plus rare d'établir une liaison directe avec un satellite situé sous nos pieds-_.
Les termes "Upend" et "Downend" vont me pourrir mon texte en bon français parce que pour une fois je renonce à trouver une traduction. En revanche, ils facilitent l'orientation. Un contrat établi par l'Upend à l'intention du Downend est un contrat "Upward" ("montant") et inversement, le Downend expose un contrat "Downward" à l'Upend. Ces contrats applicatifs portent le nom de "Duty" ("tâche, assignation").
Mais pourquoi faut-il un contrat dans chaque direction ?
=== Contrats
Considérons le cas d'un appel synchrone comme on a appris à le penser dans le monde des SGBDR. Disons qu'il y a un compteur a incrémenter. Quelque part il y a une table avec un enregistrement qui contient la valeur du compteur, et côté Java on modélise le comportement requête-réponse à l'aide d'une méthode avec une valeur de retour. Ça donne ça :
<<<
/** Pas bon. */
interface Counter {
int increment( int delta ) throws RemoteException ;
}
>>>
Notons l'exception qui peut nous dire si quelque chose s'est mal passé. On imagine que le serveur gère correctement la concurrence d'accès. Comment être informé lorsqu'un autre client connecté modifie la valeur ? La façon classique c'est d'aller sonder, donc on rajoute une méthode et on lit chaque fois que l'envie nous en prend. De toutes façons il y a besoin d'une telle méthode pour initialiser le client.
<<<
/** Toujours pas bon. */
interface Counter {
int value() throws RemoteException ;
int increment( int delta ) throws RemoteException ;
}
>>>
Évidemment pour diminuer la latence d'une mise à jour il faut augmenter la fréquence des appels à ``value``. Pour ne pas ajouter plus de 100 ms de latence, avec 100 utilisateurs connectés on se prend `1000` requêtes par seconde avec un système qui ne fait //rien//. Ça veut dire que n'importe quel problème sera systématiquement noyé dans un bruit assourdissant.
Après si on sort du paradigme client-serveur il faut une interface spécifique pour recevoir les mises à jour poussées par ce que nous appelions le serveur. En utilisant notre terminologie toute neuve avec les "Upend", "Downend" et "Duty" on aurait ça :
<<<
interface DownwardDuty {
void value( int value ) ;
}
>>>
L'Upend appelle ``DownwardDuty`` pour notifier d'une mise à jour, ou pour fournir la valeur à un instant donné lorsque le Downend s'initialise. Sur le Downend le code applicatif implantant cette interface réagit convenablement à la mise à jour, qui peut avoir lieu à n'importe quel moment.
Maintenant, quel besoin de proposer une méthode de mise à jour synchrone, alors qu'on supporte une mise à jour asynchrone ? Modifions les méthodes comme suit :
<<<
interface UpwardDuty {
void sendValue() ;
void increment( int delta ) ;
}
>>>
Attention, un vrai tour de magie vient d'avoir lieu. Nous avons fait disparaître la valeur de retour et l'exception (qui est une autre valeur de retour). Autrement dit, le code appelant demande une mise à jour //et reprend la main aussitôt//. Ça tombe bien, on est dans un monde où les entrées-sorties ne doivent pas être bloquantes.
Mais dans la bagarre nous avons perdu :
- La connaissance de la fin de la mise à jour demandée (on peut recevoir une mise à jour demandée par un autre utilisateur avant que la sienne ait eu lieu).
- La remontée d'erreur.
Tout ça se règle avec une fonction de rappel. Chiron définit une interface ``Tracker`` dont les méthodes sont appelées pour refléter l'état de l'appel en cours. On ne rentre pas dans les détails de la remontée d'erreur pour l'instant mais on voit l'idée :
<<<
interface Tracker {
void afterResponseHandled() ;
void onConnectionLost() ;
void onConnectionRestored() ;
void afterTimeout() ;
void afterRemoteFailure( ... ) ;
>>>
On modifie nos interfaces ``Duty`` pour supporter le ``Tracker`` :
<<<
interface UpwardDuty {
void sendValue( Tracker tracker ) ;
void increment( Tracker tracker, int delta ) ;
}
interface DownwardDuty {
void value( Tracker tracker, int value ) ;
}
>>>
Pour ``UpwardDuty`` l'intérêt est évident, pour ``DownwardDuty`` un peu moins. Mais déjà grâce au ``Tracker`` le code du Downend peut provoquer une mise à jour à partir d'un dialogue, et fermer le dialogue une fois que l'opération a pris fin, ou afficher un message d'erreur.
Maintenant que se passe-t-il dans l'Upend ? L'interface ``Tracker`` n'a rien à faire de ce côté-là. Par contre la logique applicative a besoin d'autres informations, comme la session en cours. C'est l'occasion d'introduire le ``Designator`` :
<<<
class Designator {
public final Stamp stamp ;
public final Stamp cause ;
public final Tag tag ;
public final SessionIdentifier sessionIdentifier ;
// ...
}
>>>
Le ``Designator`` indique des choses sur l'appel en cours. Il fournit notamment :
- Un identifiant unique (``stamp``).
- La cause de l'appel en cours (``cause``) s'il y a des appels en cascade.
- Un identifiant fournit par le Downend (``tag``), plus de détails bientôt.
- Un identifiant de session si l'appel se fait dans le contexte d'une session authentifiée.
Comme il n'y a que le type du premier paramètre qui change on utilise un type paramétrique pour obtenir des définitions utilisables sur la Downend et l'Upend :
<<<
interface UpwardDuty< ENDPOINT_SPECIFIC > {
void sendValue( ENDPOINT_SPECIFIC e ) ;
void increment( ENDPOINT_SPECIFIC e, int delta ) ;
}
interface DownwardDuty< ENDPOINT_SPECIFIC > {
void value( ENDPOINT_SPECIFIC e, int value ) ;
}
>>>
Le rôle de Chiron là-dedans, ça va être d'instancier le ``Designator`` et d'appeler les bonnes méthodes du ``Tracker``.
Dans le code applicatif de l'Upend, après l'incrémentation du compteur, on itère sur toutes les sessions et pour chacune on appelle ``value(_)``. Maintenant comment faire pour que le Downend qui a initié la modification sache que c'est //sa// modification qui prend fin lors d'un appel à ``value(_)`` ? C'est là qu'intervient le ``tag`` dans le ``Designator``. Côté Downend, Chiron crée un identifiant unique qui va se retrouver sur l'Upend durant la séquence sérialisation-désérialisation. C'est la responsabilité du code applicatif de l'Upend de propager le Tag (en créant de nouveaux ~``Designator``s avec les bonnes primitives) pour que celui-ci se retrouve une fois et une seule dans l'appel à ``value(_)``. Après le Downend récupère sa valeur, Chiron s'aperçoit qu'il y a un ``Tracker`` correspondant, et appelle la bonne méthode pour dire que tout s'est terminé correctement.
Maintenant qu'est-ce qu'on a gagné grâce à cette façon de découper le code ?
Déjà on n'a pas perdu la remontée d'erreur ni la possibilité de rendre un appel synchrone, grâce au ``Tracker``. On a aussi une approche unique pour supporter les mises à jour du Downend, qu'elles soient sollicitées ou impromptues. Grâce à la méthode ``UpwardDuty#value(_)`` le Downend peut se remettre à jour à tout moment, par exemple quand il s'initialise, ou après une perte momentanée de la connexion réseau.
=== Concurrence d'accès
Quelles sont les implications concernant la concurrence d'accès ? Imaginons que l'utilisateur ait passé dix minutes à remplir un formulaire, en se basant sur des données qui sont modifiées par quelqu'un d'autre juste avant la soumission. Comme la logique applicative traite toutes les mises à jour séquentiellement, elle détermine que la mise à jour demandée n'est pas possible et renvoie un beau message d'erreur. Et c'est tout ; il n'y a pas d'histoire de pseudo-transactionnalité avec des enregistrements fantômes dans les transactions. Accéder à l'intégralité de l'état de l'application n'est plus un problème parce que tout se fait dans du code séquentiel manipulant des données en mémoire vive uniquement. Si on veut ajouter un compteur de révision pour faire du "verrouillage optimiste"
on peut, mais c'est une décision de niveau applicatif, et il y a plein de cas dans la vraie vie où on n'en a pas besoin.
Que se passe-t-il du côté de l'utilisateur ? Il reçoit un beau message d'erreur qui lui dit "Vous venez de perdre 10 minutes et c'est tant pis pour votre gueule." Ou alors s'il y a beaucoup de budget, un joli système graphique l'aide à corriger ses changements pour qu'ils soient applicables à la dernière mise à jour.
Je reformule parce que c'est important :
<<
Comme sur l'Upend le fil d'exécution de la logique applicative n'est jamais bloqué (principalement parce qu'il n'effectue pas d'entrées-sorties), les traitements sont assez rapides pour être séquentiels. Avec des mises à jour parfaitement séquentielles, on connaît tout l'état du système avant chaque modification, donc le code applicatif effectuer toutes les validations requises, sans faire de pari sur l'état du SGBDR. Ce qui était un problème de concurrence d'accès distribué sur tout le système devient juste une question de confort au sein de l'interface graphique.
>>
Je propose dix secondes de recueillement, à la mémoire des projets où la concurrence d'accès se réglait d'une façon beaucoup moins limpide.
=== Tests unitaires
Dans l'exemple avec le compteur, on manipule juste un ``int``. Mais que se passe-t-il avec des objets plus compliqués ? Pour éviter tout problème de concurrence d'accès (cette fois entre fils d'exécution) et simplifier la sérialisation, ces objets doivent être immuables. Rappelons que les objets immuables sont plus faciles à sérialiser puisqu'ils ne permettent pas les références cycliques. Si on a des objets immuables, on peut également écrire les tests sous forme de constantes, et de fonctions qui appellent la logique applicative en passant ces constantes.
Avec JMockit, on testerait une implémentation de notre ``UpwardDuty`` comme suit :
<<<
/** Testing with JMockit. */
public void test(
@Injectable final DownwardDuty downwardDuty
) {
UpwardDuty logic = createLogic( downwardDuty ) ;
logic.increment( DESIGNATOR_0, DELTA ) ;
new FullVerifications { {
downwardDuty.counterValue( DESIGNATOR_1, VALUE ) ;
} } ;
}
private static final int DELTA = 2 ;
private static final int VALUE = 2 ;
private static final Designator DESIGNATOR_0 = ...
private static final Designator DESIGNATOR_1 = ...
>>>
On saute quelques détails de la vie du ``Designator`` mais on voit l'idée. L'important c'est que le ``FullVerifications`` de JMockit garantit que toutes les appels de méthodes ont eu lieu, et aucun autre.
Le test que nous venons de voir n'effectue aucune entrée-sortie, ne nécessite aucune configuration de SGBDR, il se contente de solliciter le code applicatif qui lui non plus n'effectue pas d'entrées-sorties. L'exécution d'un pareil test prend juste quelques millisecondes. Sérieux, c'est tellement simple qu'on a l'impression d'abuser.
=== Commandes
Arrivés là nous avons vu que la modélisation applicative s'appuie sur des contrats de type "Duty" entre Upend et Downend, qui sont des interfaces avec des méthodes sans valeur de retour, et ne lançant pas d'exception.
Appeler une méthode sur un objet c'est la façon la plus simple d'obtenir ce qu'on veut. Mais un appel de méthode n'est qu'un état de la pile du fil d'exécution en cours, correspondant à un appel synchrone. Ce dont on a besoin, c'est de transformer les appels de méthodes en objets sérialisables et référençables dans une file d'exécution.
Chiron fournit une classe de base ``Command`` pour représenter une possibilité d'exécution différée. Un objet ``Command`` a une référence sur un contexte d'exécution qui correspond à un ``Tracker`` ou un ``Designator`` (si on est sur le Downend ou l'Upend, respectivement). Un objet ``Command`` a une méthode ``execute(_)`` qui prend en entrée un objet d'un type déterminé. Notre class ``Command`` ressemble à peu près à ça :
<<<
public abstract class Command< ENDPOINT_SPECIFIC, CALLABLE_RECEIVER > {
public final ENDPOINT_SPECIFIC endpointSpecific ;
protected Command( final ENDPOINT_SPECIFIC endpointSpecific ) {
this.endpointSpecific = checkNotNull( endpointSpecific ) ;
}
public abstract void callReceiver( CALLABLE_RECEIVER callableReceiver ) ;
public abstract void encodeBody( PositionalFieldWriter positionalFieldWriter )
throws IOException ;
}
>>>
Les classes concrètes dérivées de ``Command`` déclarent les paramètres de la méthode à appeler sous forme de champs ``final``. C'est à dire qu'un objet ``Command`` est immuable.
La sérialisation repose sur un objet ``PositionalFieldWriter`` dont nous reparlerons, en bref il fournit des méthodes pour écrire des types élémentaires. Pour l'instant on retiendra juste que c'est une version améliorée du ``java.io.DataOutput``.
La désérialisation est définie dans une méthode statique propre à chaque classe concrète dérivée de ``Command``. Elle repose sur un objet ``PositionalFieldReader`` dont nous retiendrons pour l'instant qu'il s'agit juste d'une version améliorée du ``java.io.DataInput``.
Pour chaque interface ``XxxDuty`` on a un ``XxxCommandCrafter`` qui implante cette interface, et qui instancie l'objet ``Command`` approprié. Après pour obtenir un appel de méthode à partir de l'objet ``Command`` il suffit d'appeler ``Command#callReceiver(_)`` en passant l'objet qui possède la méthode à appeler.
La méthode ``Command#encodeBody(_)`` ne doit sérialiser que les champs spécifiques.
=== Mais pourquoi ne pas générer du code pour les objets ``Command`` ?
Ça serait bien d'avoir quelque chose qui génère du code à partir des méthodes des interfaces ``Duty`` pour sérialiser ou désérialiser les objets ``Command``. Mais pour l'instant ce n'est pas fait.
Ajouter une méthode dans une interface "Duty" signifie donc une nouvelle classe dérivée de ``Command``, et tout le tintouin de la sérialisation-désérialisation. Mais dans la pratique ce n'est pas si terrible, car c'est du code trivial et facile à tester.
Évidemment générer du code avec Javassist c'est la classe ; la preuve c'est que Netty le fait. Mais ce n'était pas le plus urgent, et il a fallu quelques ajustements pour trouver ce qu'il fallait mettre exactement dans la classe ``Command`` et ses dérivés. Dans ces cas-là on va plus vite en modifiant le code à la main, plutôt qu'en modifiant le générateur.
C'est marrant, la sérialisation a inspiré beaucoup d'auteurs de socles techniques, mais une fois qu'on manipule seulement des objets immuables, on découvre avec un émerveillement teinté de gêne que finalement, tout cela est bien trivial et qu'on s'est fait du mal pour rien avec ces trucs de sérialisation automatique. Donc on vire, euh, ``java.io.Serializable`` ? Le consultant XStream ? Oui. Chiron y'a une fonction "ressources humaines" cachée dedans.