FlowTracker : Suivi des Données dans les Programmes Java
Obtenez une compréhension instantanée des flux de données dans vos applications Java.
FlowTracker est un agent Java conçu pour surveiller la manière dont un programme lit, modifie et écrit des données. En observant l’exécution d’un programme, il peut révéler les opérations d’entrée/sortie de fichiers et de réseaux, tout en établissant des connexions entre les entrées et les sorties pour identifier l’origine des résultats produits. Cela permet d’interpréter plus facilement la signification des sorties d’un programme Java et de comprendre les raisons de leur génération.
Cette démonstration de concept met en lumière les perspectives que nous pouvons acquérir en analysant le comportement des programmes sous cet angle.
Présentation Démonstrative
Spring PetClinic est une application de démonstration développée pour le framework Spring. Pour illustrer les capacités de FlowTracker, nous l’avons utilisé pour observer PetClinic lors du traitement d’une requête HTTP et de la génération d’une page HTML à partir d’un modèle et de données provenant d’une base de données. Vous pouvez accéder à cette démonstration directement depuis votre navigateur, sans nécessiter d’installation. Ouvrez la démo FlowTracker de PetClinic ou visionnez la vidéo ci-dessous.
petclinic.mp4
Dans cette vidéo, vous pouvez observer la réponse HTTP que FlowTracker a détectée lors de l’envoi de données par PetClinic sur le réseau. En cliquant sur une partie du contenu de la réponse HTTP, vous pouvez voir en bas d’écran d’où provient cette information. Il est également possible de sélectionner une autre origine ou sortie suivie dans l’arborescence à gauche (ou via le bouton en bas à gauche sur mobile).
En explorant cette réponse HTTP, nous naviguons à travers plusieurs niveaux de la pile logicielle :
- Gestion HTTP FlowTracker révèle quel code a généré quelle sortie. En cliquant sur « HTTP/1.1 » ou sur les en-têtes HTTP, vous pouvez voir que cette partie de la réponse a été produite par Apache Coyote (classes dans le package
org.apache.coyote
), vous indiquant précisément l’origine de chaque en-tête. - Modèles Thymeleaf FlowTracker illustre comment les entrées lues par le programme (les modèles HTML) correspondent aux sorties. En cliquant sur un nom de balise HTML, tel que « html » ou « head », vous pouvez identifier le fichier
layout.html
, d’où provient cette partie de la page HTML. Si vous cliquez surlayout.html
, puis sur le bouton coloré+
en bas, toutes les données provenant de ce fichier seront mises en surbrillance avec la même couleur. En faisant défiler vers le bas, vous remarquerez qu’une partie de la réponse provient d’un autre fichier,ownerDetails.html
. Cliquez sur un<
ou>
pour voir que ces caractères ont été générés par la bibliothèque de modèles Thymeleaf. - Base de données Les données affichées sur la page HTML proviennent d’une base de données. En analysant les requêtes effectuées, FlowTracker permet de retracer l’origine des informations affichées, facilitant ainsi la compréhension des interactions entre l’application et la base de données.
Présentation de FlowTracker : Un Outil d’Instrumentation pour Java
FlowTracker est un agent d’instrumentation conçu pour suivre le flux de données au sein des applications Java. En permettant de retracer les valeurs jusqu’à leur origine, cet outil offre une visibilité inédite sur la manière dont les données circulent dans un programme.
Fonctionnalités de FlowTracker
Lorsqu’un utilisateur interagit avec une table de données, par exemple en cliquant sur un élément, FlowTracker ne se contente pas d’afficher la valeur. Il retrace également cette valeur jusqu’au script SQL qui l’a insérée dans la base de données. Dans une démonstration utilisant une base de données en mémoire, FlowTracker a pu suivre les données sans interruption, car tout se déroulait au sein de la JVM. En revanche, avec une base de données MySQL, il est possible de suivre les valeurs jusqu’à la connexion à la base de données, en observant la requête SQL envoyée et les interactions entre le pilote JDBC MySQL et la base de données.
Cette démonstration de la PetClinic de Spring illustre les capacités de FlowTracker, qui n’est pas limité à un cadre ou une bibliothèque spécifique. Une autre démonstration met en lumière comment FlowTracker aide à comprendre le format des fichiers de classe générés et le bytecode associé, en observant le compilateur Java.
Utilisation de FlowTracker
Il est important de noter que FlowTracker est actuellement plus un prototype qu’un produit prêt pour la production. Bien qu’il ait montré son efficacité sur plusieurs programmes d’exemple, son utilisation peut varier en fonction des cas. De plus, il peut introduire une surcharge significative, ralentissant ainsi l’exécution des programmes.
Pour commencer, téléchargez le fichier JAR de FlowTracker depuis les pages de publication de GitHub. Ajoutez l’agent à votre ligne de commande Java en utilisant l’option -javaagent:path/to/flowtracker.jar
. Il est également conseillé de désactiver certaines optimisations de la JVM qui pourraient interférer avec FlowTracker. Par défaut, FlowTracker démarre un serveur web sur le port 8011, accessible à l’adresse http://localhost:8011/.
Fonctionnement Interne de FlowTracker
Résumé
FlowTracker agit comme un agent d’instrumentation qui injecte son code dans les fichiers de classe (bytecode) lors du chargement par la JVM. Ce code établit une correspondance entre les données en mémoire et leur origine, tout en permettant au programme de lire, transmettre et écrire ces données. L’accent est mis sur le suivi des données textuelles et binaires, telles que les chaînes de caractères et les tableaux de caractères ou d’octets, plutôt que sur les données numériques ou structurées.
Cette fonctionnalité est réalisée grâce à plusieurs techniques :
- Remplacement de certains appels aux méthodes JDK par des versions de FlowTracker.
- Injection de code à des points clés du JDK pour suivre les entrées et sorties.
- Analyse du flux de données et instrumentation approfondie au sein des méthodes pour suivre les variables locales et les valeurs sur la pile.
- Ajout de code avant et après les appels de méthode pour suivre les arguments et les valeurs de retour à l’aide de ThreadLocals.
Modèle de Données : Trackers
Le modèle de données de FlowTracker repose sur des classes et concepts fondamentaux :
- Tracker : contient des informations sur le contenu et la source d’un objet suivi :
- contenu : les données qui ont transité, par exemple, tous les octets passés par un
InputStream
ou unOutputStream
. - source : associe des plages de contenu à leurs plages sources dans d’autres trackers. Par exemple, pour les octets d’une
String
, cela pourrait pointer vers la plage du tracker duFileInputStream
d’où laString
a été lue, indiquant ainsi le fichier d’origine et sa position exacte.
- contenu : les données qui ont transité, par exemple, tous les octets passés par un
- TrackerRepository : maintient une grande carte globale associant des objets intéressants à leur tracker.
- TrackerPoint : pointeur vers une position dans un tracker, représentant une valeur primitive unique suivie, par exemple, la source d’un
byte
.
Instrumentation de Base
Pour maintenir les Trackers à jour, notre instrumentation insère des appels à des méthodes hook dans FlowTracker lorsque certaines méthodes spécifiques du JDK sont appelées. Par exemple, pour System.arraycopy
, nous interceptons cet appel du côté de l’appelant : les appels à java.lang.System.arraycopy
sont remplacés par des appels à com.coekie.flowtracker.hook.SystemHook.arraycopy
. Pour cette instrumentation, nous utilisons la bibliothèque de manipulation de bytecode ASM.
Dans SystemHook
, nous appelons la véritable méthode arraycopy
, récupérons les Trackers
des tableaux source et destination depuis le TrackerRepository
, et mettons à jour le Tracker
cible pour qu’il pointe vers sa source.
Par exemple, avec le code suivant :
char[] abc=...; char[] abcbc=new char[5];
System.arraycopy(abc, 0, abcbc, 0, 3);
System.arraycopy(abc, 1, abcbc, 3, 2);
Cela sera réécrit comme suit. Bien que l’instrumentation se fasse sur le bytecode, nous montrons ici un code source équivalent pour plus de clarté :
char[] abc=...; char[] abcbc=new char[5];
SystemHook.arraycopy(abc, 0, abcbc, 0, 3);
SystemHook.arraycopy(abc, 1, abcbc, 3, 2);
Après l’exécution de ce code, le tracker pour abcbc
ressemblera à : {[0-2]: {tracker: abcTracker, sourceIndex: 0, length: 3}, [3-4]: {tracker: abcTracker, sourceIndex: 1, length: 2}}
.
Voici un exemple d’accroche du côté de l’appelant. Cependant, la majorité des appels aux méthodes de hook se font du côté du callee, à l’intérieur des méthodes du JDK. Prenons par exemple FileInputStream.read(byte[])
, qui lit des données à partir d’un fichier et stocke le résultat dans le tableau de byte[]
fourni. Nous ajoutons l’appel à notre méthode hook (FileInputStreamHook.afterReadByteArray
) à la fin de la méthode FileInputStream.read(byte[])
. Pour cela, nous avons développé notre propre micro-framework d’instrumentation, basé sur des annotations et mis en œuvre à l’aide de AdviceAdapter
d’ASM.
De cette manière, nous intégrons des hooks dans plusieurs classes du JDK responsables des entrées et sorties, telles que java.io.FileInputStream
, java.io.FileOutputStream
, ainsi que des classes internes comme sun.nio.ch.FileChannelImpl
, sun.nio.ch.IOUtil
, sun.nio.ch.NioSocketImpl
, et d’autres encore.
Mise en œuvre : SystemHook
, FileInputStreamHook
, et d’autres classes dans le package des hooks.
Analyse des valeurs primitives et du flux de données
Un défi plus complexe consiste à suivre les valeurs primitives. Prenons cet exemple :
byte[] x; byte[] y; // ... byte b=x[1]; // ... y[2]=b;
Lorsque ce code est exécuté, il est nécessaire de mettre à jour le Tracker de y
pour se souvenir que la valeur à l’index 2 provient de la valeur à l’index 1 dans x
. Si x
et y
avaient été des tableaux de String[]
et que b
était un String
au lieu d’un byte
, nous n’aurions pas eu besoin de modifier le code de cette manière, car le TrackerRepository saurait quel est le Tracker de la chaîne et maintiendrait cette association, peu importe comment cet objet chaîne est transmis. Cependant, le TrackerRepository ne peut pas garder une correspondance des valeurs primitives comme les bytes avec les Trackers, car les valeurs primitives n’ont pas d’identité : tout Map
ayant un byte comme clé mélangerait différentes occurrences du même byte. Au lieu de cela, nous stockons l’association de b
à son tracker dans une variable locale dans la méthode elle-même. Le code est réécrit de manière approximative comme suit :
byte[] x; byte[] y; // ... byte b=x[1]; TrackerPoint bTracker=ArrayHook.getElementTracker(x, 1); // ... y[2]=b; ArrayHook.setElementTracker(y, 2, bTracker);
Pour cela, FlowTracker doit comprendre comment les valeurs circulent exactement à travers une méthode. Nous nous appuyons sur le support d’analyse d’ASM pour analyser le code (interprétation symbolique). Cela nous permet de construire un modèle de l’origine des valeurs dans les variables locales et sur la pile à chaque point de la méthode, ainsi que de leur destination.
Cela est mis en œuvre dans :
FlowValue
et ses sous-classes (par exemple,ArrayLoadValue
) qui modélisent l’origine des valeurs et peuvent générer les instructions qui créent les TrackerPoints pointant vers cette source. Un cas particulièrement intéressant estMergedValue
, qui gère les situations où, en raison du flux de contrôle (par exemple, les instructions if, les boucles), une valeur peut provenir de plusieurs endroits possibles.FlowInterpreter
: extension de l’Interpreter
d’ASM, qui interprète les instructions bytecode et crée lesFlowValue
appropriés.Store
et ses sous-classes (par exemple,ArrayStore
) qui représentent les destinations des FlowValues, consommant les TrackerPoints.FlowTransformer
: pilote l’ensemble du processus d’analyse et d’instrumentation. Consultez sa documentation pour une explication plus détaillée de la manière dont tout cela s’articule.
Nous ne suivons pas la source de toutes les valeurs primitives. L’accent est mis sur les valeurs byte
et char
, et dans une moindre mesure sur les int
et long
.
Appels de méthodes
L’analyse du flux de données de la section précédente est limitée à la gestion du flux de valeurs primitives au sein d’une seule méthode. Ces valeurs circulent également vers d’autres méthodes, en tant qu’arguments et valeurs de retour des appels de méthode. Nous modélisons cela dans Invocation
, qui stocke les PointTracker
pour les arguments et les valeurs de retour. L’Invocation
est stockée dans un ThreadLocal
juste avant un appel de méthode, et récupérée au début de l’implémentation de la méthode.
Prenons par exemple ce code qui passe une valeur primitive à une méthode « write » :
void caller() { byte b=...; out.write(b); } ... class MyOutputStream { void write(byte value) { ... // do something with value } }
Pour obtenir le TrackerPoint de b
dans la méthode write
, le code est instrumenté de la manière suivante :
Origine du Code
Il existe principalement deux catégories d’origines de données suivies. La première est l’entrée/sortie (I/O), qui est suivie comme expliqué dans la section sur l’instrumentation de base. La seconde concerne les valeurs provenant directement du code, telles que les constantes primitives et les chaînes de caractères (par exemple, 'a'
, "abc"
). Pour ces dernières, nous créons un traqueur pour chaque classe, connu sous le nom de ClassOriginTracker
, qui contient une représentation textuelle de cette classe ainsi que des constantes qu’elle référence. Lorsque ces constantes sont utilisées, nous orientons les traqueurs vers l’emplacement correspondant dans cette représentation textuelle. Cela revient à considérer que notre représentation textuelle de la classe est l’endroit d’où les valeurs ont été lues. Ainsi, notre modèle pour les constantes ressemble beaucoup à celui que nous utilisons pour l’I/O.
Par exemple, pour le code suivant :
class MaClasse { void maMethode() { char a='x'; ... // faire quelque chose avec a } }
Nous générons un ClassOriginTracker
dont le contenu ressemble à ceci :
class MaClasse
void maMethode():
(ligne 3): x
Le code est ensuite réécrit de la manière suivante :
class MaClasse { void maMethode() { char a='x'; TrackerPoint aTracker=ConstantHook.constantPoint( 1234 /* id pour MaClasse*/, 81 /* décalage de 'x' dans le contenu de ClassOriginTracker */); ... // faire quelque chose avec a et aTracker } }
Pour des raisons de performance, nous utilisons en réalité ConstantDynamic
(JEP 309) afin de garantir que les méthodes constantPoint
ne soient invoquées qu’une seule fois, plutôt qu’à chaque exécution de maMethode
.
Gestion des Littéraux de Chaînes
Pour les littéraux de chaînes, nous créons une nouvelle copie de la chaîne et associons le contenu de celle-ci (le byte[]
dans String.value
) avec le ClassOriginTracker
. Une instruction telle que String s="abc";
est réécrite en String s=StringHook.constantString("abc", 1234, 81);
. Cela rompt une garantie normalement fournie par la JVM, selon laquelle toutes les constantes de chaîne sont internées : toutes les occurrences de la même constante de chaîne devraient référencer la même instance. Bien que la plupart des codes ne s’appuient pas sur l’internement des chaînes, ceux qui le font pourraient rencontrer des problèmes à cause de notre instrumentation. Nous évitons la plupart des problèmes potentiels grâce aux mesures suivantes :
- Nous utilisons
ConstantDynamic
, de sorte que le même littéral de chaîne (à la même ligne de code) exécuté plusieurs fois renvoie toujours la même instance. - Nous réécrivons certaines expressions
stringA==stringB
enObjects.equals(stringA, stringB)
, de sorte que, sous certains angles, elles semblent à nouveau être la même instance. - Nous désactivons le suivi des littéraux de chaînes dans certains packages (comme
java.lang.*
). Cela est configurable (voirbreakStringInterning
dans USAGE.md).
Gestion des Valeurs Non Suivies
Le FlowTracker ne suit pas toutes les valeurs du programme. Cela est en partie dû à des préoccupations de performance, en partie parce que nous n’avons pas encore mis en œuvre tout ce que nous souhaiterions, et en partie parce que cela ne semble tout simplement pas pertinent ou nécessiterait la construction d’un modèle de données plus complexe où les valeurs peuvent provenir de plusieurs sources (par exemple, des valeurs numériques calculées). Lorsque des valeurs non suivies se retrouvent dans des endroits où nous souhaitons commencer à les suivre, nous les traitons de manière similaire aux constantes : nous ajoutons un lien vers le ClassOriginTracker
, indiquant où elles ont été suivies, représenté par ">"
. Par exemple, les longueurs de tableaux ne sont pas suivies, donc si une méthode appelle write(array.length)
, alors dans cette Invocation
, nous passons un PointTracker
qui fait référence à l’endroit dans le code où la méthode write
est appelée.
Analyse des Flux de Données : Approfondissement
Lorsqu’on examine des résultats, en particulier ceux présentés sous un format binaire, il est souvent possible de déduire rapidement leur signification, même si l’origine d’une valeur n’est pas immédiatement évidente. Par exemple, une valeur située juste avant une chaîne suivie pourrait indiquer la longueur de cette chaîne.
Thèmes Supplémentaires à Explorer
Il existe de nombreux autres aspects de l’implémentation qui mériteraient d’être abordés, mais qui n’ont pas été inclus dans cette discussion. La plupart de ces éléments sont documentés dans le code pour ceux qui souhaitent approfondir leurs connaissances :
- Détails sur MergedValue : C’est l’un des aspects les plus complexes de l’analyse des flux de données, impliquant l’instrumentation du code pour suivre les valeurs à travers les branches et les boucles.
- Concatenation de chaînes : Nous avons intégré des points d’accroche pour la concaténation de chaînes via son identification (JEP 280), en ajoutant des hooks aux MethodHandles retournés par StringConcatFactory.
- Recherche du code source : Décompilation avec Vineflower et association du bytecode avec les lignes de code source. Consultez les générateurs de code source tels que SourceCodeGenerator et AsmCodeGenerator.
- Configuration du ClassLoader : Nous avons mis en place un système pour éviter les conflits de dépendances sur le bootclasspath sans utiliser de shading, ce qui compliquerait le débogage. Cela permet également de modifier un agent sans avoir à le reconditionner, favorisant ainsi des cycles de développement rapides.
- Intervention du chargement de classes : Nous avons trouvé des solutions pour contourner les problèmes liés au suivi des invocations de méthodes. Cela inclut des transformations spécifiques pour suspendre les invocations.
- Suivi des valeurs primitives : Nous avons mis en place des structures comme FieldRepository et FieldValue pour gérer le stockage des valeurs primitives dans les champs.
- Ajout de commentaires dans le code instrumenté : Bien que l’ASM/Bytecode ne supporte pas les commentaires, nous avons trouvé des moyens d’intégrer des annotations pour faciliter la compréhension et le débogage.
- Éviter les problèmes de circularité : Lors de l’instrumentation des classes de base du JDK, nous avons développé des stratégies pour gérer les erreurs de circularité et de débordement de pile.
- Interface Front-end : Nous avons construit un serveur web avec Jetty et JAX-RS, et l’interface utilisateur a été développée avec Svelte, offrant une conception esthétique.
- Optimisation de ThreadLocal : Nous avons mis en place une solution complexe dans ContextSupplier, mais cela pourrait ne pas être d’un grand intérêt.