jeudi 1 décembre 2011

Java : Gérer la JVM avec Java Management Extensions (JMX) Technology - Partie 2 - Création d'un client

Avoir un client JMX peut être pratique mais surtout si on a un programme distant. Si tout se passe en local l'intérêt est vraiment moindre. Pour permettre au programme Java d'être à l'écoute de connexions distantes, il faut rajouter les paramètres suivants :
  • -Dcom.sun.management.jmxremote 
  • -Dcom.sun.management.jmxremote.port=PORT
Ces deux paramètres bien que suffisants, demandent un nom d'utilisateur/mot de passe inscrit dans un fichier de paramètres de la JVM.
Pour désactiver la sécurité voici les paramètres à rajouter :
  • -Dcom.sun.management.jmxremote.authenticate=false
  • -Dcom.sun.management.jmxremote.ssl=false
Bien sûr dans un environnement de production, il peut être utile de bien configurer sa JVM pour qu'elle ne soit pas ouverte à tous les vents. La documentation officielle explique ça très bien, pour peu que l'anglais ne soit pas un problème.

Voici un petit exemple d'une ligne de lancement Java avec les propriétés permettant d'autoriser les appels distants.
java -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9021 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -jar mon_programme.jar

Création de l'agent

Pour se connecter et commencer à dialoguer avec un serveur JMX il faut passer par trois étapes.
  • La création d'un objet URL contenant l'URL de connexion
  • L'appel de la factory pour récupérer la connexion.
  • La création de la couche de communication aux MBeans.
Dans notre cas ça donne donc :
import java.io.IOException;
import java.net.MalformedURLException;
import javax.management.MBeanServerConnection;
import javax.management.remote.JMXConnector;
import javax.management.remote.JMXConnectorFactory;
import javax.management.remote.JMXServiceURL; 

public MBeanServerConnection connect(String server,int port) throws MalformedURLException,IOException,Exception {
    String connectionString = "service:jmx:rmi:///jndi/rmi://"+server+":"+port+"/jmxrmi";
    JMXServiceURL url= new JMXServiceURL(connectionString);
    JMXConnectorFactory jmxc= JMXConnectorFactory.connect(url);
    return jmxc.getMBeanServerConnection();
}

Toutes les interactions faites avec le serveur JMX passent par l'objet MBeanServerConnection. La chaîne de connexion décrite ci-dessus est celle par défaut, mais selon la configuration elle peut varier.


Organisation des objets



Les objets n'ont pas de hiérarchie précise, le niveau que l'on peut obtenir à partir de la connexion est ce que JMX appelle les domaines. Le reste des niveaux que l'on retrouve dans JConsole est déduit par la console d'après des règles qui n'ont rien d'obligatoire.
Les objets sont enregistrés avec un nom. Leur nom défini leur hiérarchie virtuelle. Un objet est nommé de la manière suivante : "domain : propriété1=val, propriété2=val ...".
Ainsi pour gérer la mémoire on va travailler avec l'objet ayant pour nom :
"java.lang:type=Memory"
Si on veut plus de détail et travailler avec l' EdenSpace l'objet aura pour nom :
" java.lang:type=MemoryPool,name=PS Eden Space"

Tous ces objets sont ce qu'on appelle des MBeans pour ManagedBeans on peut retrouver leur nom dans JConsole :
Exemple de description d'un objet dans Jconsole
Dans la partie gauche on voit l'arborescence, on voit que dans java.lang, dans la sous-catégorie MemoryPool on a sélectionné PS Eden Space. Maintenant dans la partie droite, on voit les informations, les MBeanInfo, c'est une collection d'objet que l'on peut extraire du MBean. Dans ce tableau, on a l'information primordiale pour retrouver l'objet, c'est la valeur de ObjectName ( la première ligne du tableau) il s'agit de la chaîne donnée plus haut.
Pour récupérer cet objet auprès du serveur et ainsi travailler avec, on peut forger l'objet à la main :

    ObjectName eden_space= new ObjectName("java.lang:type=MemoryPool,name=PS Eden Space");

A partir de là on peut demander au serveur ses attributs, les opérations que l'on peut exécuter dessus ou s'abonner à des notifications.

L'autre manière de récupérer les objets consiste à interroger directement le serveur en lui demandant ce qu'il possède comme objet.
Les exemples que l'on trouve sur internet cherchent d'abord les domaines avant d'aller chercher les objets. On peut très bien aller chercher tous les objets sans tenir compte du domaine. L'ObjectName est utilisé lors de ces requêtes et les caractères wildcard "*" sont utilisés pour compléter les noms. Ainsi pour chercher tous les éléments enregistrés sur le serveur on peut écrire :


   MBeanServerConnection mbsc = this.connect("serveur",9021);
   ObjectName tous= ObjectName.WILDCARD; // équivalent à new ObjectName("*:*");
   Set<ObjectName> objets = mbsc.queryNames(tous,null);
L'exemple avec d'abord la recherche de domaine :
  MBeanServerConnection mbsc = this.connect("serveur",9021);
   String domains[] = mbsc.getDomains();
   Arrays.sort(domains);
   for(String domain : domains){
     Set<ObjectName> names = new TreeSet(mbsc.queryNames(new ObjectName(domain + ":*"), null));
   }


Maintenant que la façon de retrouver les objets est connue, on peut accéder à toutes les ressources gérées par la machine virtuelle. Elles sont regroupées en trois catégories :
  1. Les notifications
  2. Les attributs
  3. Les méthodes que l'on peut appeler

Les Notifications

Pour les notifications, il suffit de savoir si un objet est un broadcaster . Si c'est bien le cas on peut écouter les notifications qu'il va envoyer. Voici le test pour savoir si un objet est capable d'envoyer des notifications :
private boolean isBroadcaster(ObjectName name,MBeanServerConnection mbsc) {
    try {
        return mbsc.isInstanceOf(name, "javax.management.NotificationBroadcaster");
    } catch (Exception e) {
        System.out.println("Error calling isBroadcaster: " +e.getMessage());
    }
    return false;
}

Ensuite pour s'y abonner, il faut enregistrer un objet NotificationListener sur le serveur et les écoutes sont démarrées. Voici un code très basique pour le NotificationListener :
public class MyListener implements NotificationListener {
   
    @Override
    public void handleNotification(Notification notification, Object handback) {
        System.out.println(notification.getTimeStamp());
        System.out.println(notification.getType());
        System.out.println(notification.getSequenceNumber());
        System.out.println(notification.getSource());
        System.out.println(notification.getMessage());
    }
};
Le code permettant de s'abonner aux notifications:
if(isBroadcaster(objectName,mbsc)){
    mbsc.addNotificationListener(objectName, new MyListener(), null, null); 
}


Les codes sont simplistes et ne révèlent pas toutes les possibilités offertes avec ces fonctions. Par exemple les deux null que l'on voit dans l'appel à addNotificationListener , le premier s'agit un filtre NotificationFilter l'autre de l'objet handback qui sera passé en paramètres de handleNotification. Le filtre est une interface simple qui dit si oui ou non le message doit être filtré.

Les attributs

Plusieurs étapes sont nécessaires pour lister les attributs d'un objet, la première est récupérer les informations auprès du serveur, l'objet qui en résulte est un MBeanInfo pour le récupérer il suffit d'appeler la méthode getMBeanInfo de la connexion au serveur. Depuis cet objet on peut récupérer la liste des constructeurs, la liste des attributs, la liste des notifications envoyées et la liste des opérations. Dans cette section ce qui nous intéresse c'est la liste des attributs, voici le code pour l'obtenir :
try {
    mbi = mbsc.getMBeanInfo(name);
    MBeanAttributeInfo[] attributes = mbi.getAttributes();
} catch (Exception e) {e.printStackTrace();}


Avec ce code on récupère un tableau d'objets MBeanAttributeInfo qui contiennent les informations nécessaires pour déterminer quel type de données se cache derrière les attributs appelés. Les données sont de type OpenType et sont séparées en deux grandes catégories: les données simples et les données composites. Les données simples sont toutes de type SimpleType, les données composite peuvent-être de type CompositeType ou ArrayType ou encore TabularType SimpleType représente un type d'objet proche des types primitifs, (Integer,BigDecimal,String,Void,Date...) la liste n'est pas exaustive. ArrayType est un tableau a n dimensions ( récupérables par la méthode getDimension() ) contenant tous les types autorisés par OpenType ( voir la constante ALLOWED_CLASSNAMES_LIST ) comme les tableaux ils ne peuvent contenir qu'un de ces types à la fois. Le type CompositeType est une sorte de table de hashage d'OpenType et peut contenir tous les types d'OpenType, avec ce type, on peut avoir accès à un nombre illimité de structure et de sous-structure. Les TabularType sont la représentation OpenType des Map Java. La donnée memoryUsageAfter de l'attribut lastGCinfo du MBean GarbageCollector est de ce type. Ces types sont des descripteurs, ce ne sont pas eux qui transportent les données. L'équivalent données, on la même convention de nommage en replaçant Type par Data. Pour récupérer la liste des attributs on peut boucler sur les MBeanAttributeInfo[], un objet Descriptor donne le type d'openType dont il s'agit. Le code qui suit n'affiche qu'un sous niveau d'attributs ( dans le cas d'un ArrayType on considère une seule dimension ) :
for (MBeanAttributeInfo info : attributes) {
    Descriptor d = info.getDescriptor();
    Object val = d.getFieldValue("openType");
    String nom = info.getName();
    if (val instanceof CompositeType) {
        CompositeType ct = (CompositeType)val;
        for (String key : type.keySet()) {
            System.out.println(nom + "/" + key); //"On affiche la clé de la ressource trouvée"
 }
    }else if (val instanceof ArrayType){
        @SuppressWarnings("unchecked")
 ArrayType<OpenType>?<[]> t = (ArrayType<OpenType>?<[]>) val;
 OpenType ot = t.getElementOpenType();
        // Ici on obtient une récursion, ArrayType pouvant contenir n'importe quel OpenType,
        //le mieux à faire et de repasser dans le if/else if/.. dans lequel on se trouve.
    }else if (val == null) { //Correction d'un problème d'incompatiblité Java 1.5
        String type = info.getType();
        if(type != null && type.contains("CompositeData")){ // Exemple avec CompositeData, le problème ne se pose pas avec les types simple
            try {
         CompositeData cd = (CompositeData) mbsc.getAttribute(objectName, nom);
                //On pourrait appliquer cette méthode pour toutes les versions de Java mais elle est très coûteuse en temps.
  if(cd != null){
      CompositeType ct = cd.getCompositeType();
      //Maintenant qu'on a le CompositeType on le traite comme au dessus
                    for (String key : type.keySet()) {
                        System.out.println(nom + "/" + key); //"On affiche la clé de la ressource trouvée"
             }
  }
     } catch (Exception e) {
        e.printStackTrace();
        } 
    }else{
        System.out.println(nom)
    }
}

Ce code d'exemple liste tous les attributs d'un objet inscrit dans le serveur JMX de la JVM sans s'occuper de son type, il ne fait qu'afficher le nom des ressources.
Pour le client qui récupérera les données, le principe est le même on intervient sur la connexion au serveur on lui demande un attribut précis et lui renvoie un OpenData contenant la réponse. Voici un code d'exemple qui écrit dans la sortie standard la réponse et qui décompose les types composites :
   public void printVal(MBeanServerConnexion mbsc,ObjetName objet,String attributs){
        String[] attribs = attribut.split("/");
        Objet val = mbsc.getAttribute(objet,attribs[0]);
        if(val instanceof CompositeData){
            if(attributes.length >1){
                String value = ""+((CompositeData) val).get(attribs[1]);
                System.out.println(attribs[0]+"/"+attribs[1]+" : "+value);
            }
        }else if(attr instanceof CompositeData[]){
            CompositeData[] datas = (CompositeData[])val;
            String value="";
            boolean first =true;
            for(CompositeData da : datas){
                if(attributes.length >1){
                    if(first)first=false;
                    else value+=",";
                    value += ((CompositeData) da).get(attributes[1]);
                }
            }
            System.out.println(attribs[0]+"/"+attribs[1]+" : "+value);
        }else if(attr instanceof Object[]){
            Object[] datas = (Object[])val;
            String value="";
            boolean first =true;
            for(Object d: datas){
                if(first)first=false;
                else value+=",";
                value += d;
            }
            System.out.println(attribs[0]+" : "+value);
        }else{
            System.out.println(attribs[0]+" : "+val);
        }
    }

Ce code d'exemple, outre le fait que de nombreuses parties pourraient faire partie d'autres fonctions pour factoriser montre comment on récupère un attribut d'un objet et comment le découper lorsqu'il s'agit d'un CompositeData. Il faut appeler la valeur sous la clé, si cette valeur est un CompositeData, on peut redescendre d'un niveau et ainsi de suite. Dans l'absolu il n'y a pas de limite dans les niveaux, cet exemple n'est pas exhaustif dans la mesure où le principe est d'écrire dans la sortie standard et non de faire un graphique ou un quelconque autre usage.
Il est bon de savoir que des objets sont dédiés aux fonctions de la JMX. Ces objets sont récupérables en utilisant les Proxy. Voici l'accès par proxy comme indiqué dans la documentation Oracle :
   MBeanServerConnection mbsc;

   // Connect to a running JVM (or itself) and get MBeanServerConnection
   // that has the JVM MBeans registered in it
   ...

   // Get a MBean proxy for RuntimeMXBean interface
   RuntimeMXBean proxy = 
       ManagementFactory.newPlatformMXBeanProxy(mbsc,
                                                ManagementFactory.RUNTIME_MXBEAN_NAME,
                                                RuntimeMXBean.class);
   // Get standard attribute "VmVendor" 
   String vendor = proxy.getVmVendor();

Les Opérations

Les opérations dans JMX sont invoquées un peu de la même manière que ce que l'on ferait en Java classique avec l'introspection de l'API Reflection. En effet il faut appeler le nom de la méthode et donner sa signature. La différence c'est que tout se fait un seul appel, les paramètres aussi sont passés lors de l'appel. Pour récupérer la liste des Opérations disponibles, une méthode dans MBeanInfo permet de le faire, il s'agit de la méthode, getOperations() , comme indiqué dans l'exemple suivant :
try {
    MBeanInfo mbi = mbsc.getMBeanInfo(name);
    MBeanOperationInfo[] operations = mbi.getOperations();
} catch (Exception e) {e.printStackTrace();}

L'objet MBeanOperationInfo renseigne sur le champ d'action de la méthode, sa valeur de retour et sa signature. Commençons par le champ d'action, il s'agit d'un entier comparable a plusieurs constantes de la classe indiquant si la méthode est en lecture, ecriture, les deux ou si c'est impossible à déterminer :
    MBeanOperationInfo[] operations = mbi.getOperations();
    for ( MBeanOperationInfo operation : operations ) {
        int impact = operation.getImpact()
        if(impact == MBeanOperationInfo.INFO) {
            System.out.println(operation.getName() + " est en lecture seule ");
        }else if (impact == MBeanOperationInfo.ACTION){
            System.out.println(operation.getName() + " va engendrer une modification ");
        }else if (impact == MBeanOperationInfo.ACTION_INFO){
            System.out.println(operation.getName() + " va engendrer une modification et renvoie une information ");
        }else if (impact == MBeanOperationInfo.UNKNOWN){
            System.out.println(operation.getName() + " va faire quelque chose mais quoi? ");
        }
        // Comme il s'agit d'entier on peut tester si la méthode retournera une information peu importe si elle est en écriture en utilsant un masque.
        if(impact &  MBeanOperationInfo.INFO ==  MBeanOperationInfo.INFO){
           System.out.println(operation.getName() + " Va renvoyer une information ");
        }
    }

La valeur de retour de la méthode peut être obtenue de différentes façon, selon qu'il s'agisse un MXBean ou d'un MBean. La méthode getReturnType() est celle qui va renvoyer une valeur quoiqu'il arrive mais la valeur retournée n'est pas de type OpenType. En effet elle retourne le nom de la classe de retour.
    MBeanOperationInfo[] operations = mbi.getOperations();
    for ( MBeanOperationInfo operation : operations ) {
        System.out.println(operation.getName() + " : "+operation.getReturnType());
        //peut retourner une information comme celle-ci findMappings : [Ljava.lang.String;
        //C'est à dire que la valeur de retour sera un tableau ( le [L ) de chaine de caractères
    }
L'autre manière pour récupérer le type de retour est d'utiliser le descripteur. Comme dans le cas des attributs, les descripteurs possèdent deux champs importants. Le champ openType et le champ originalType, dans notre cas on va traiter le champ openType qui fonctionne comme pour les attributs et donc on peut traiter les données de la même manière.
    MBeanOperationInfo[] operations = mbi.getOperations();
    for ( MBeanOperationInfo operation : operations ) {
        Descriptor desc = operation.getDescriptor();
        Object val = desc.getField("openType");
        if(val ==null){// ça n'est pas un openType.
            //Utilisation de operation.getReturnType();
        }else{
            OpenType<?> type = (OpenType<?>) val;
            //Traitement standard.
            //Si rien n'est renvoyé ( void ) il s'agit d'un SimpleType de void ( SimpleType<Void> ) 
        }
    }
Pour la signature le de méthode, le même procédé est applicable, on peut récupérer la liste des paramètres avec leur type simple ou leur OpenType lorsqu'il existe.
    MBeanOperationInfo operation;// Récupéré des autres méthodes.
    MBeanParameterInfo[] params = operation.getSignature();
    System.out.print(operation.getName() +":");
    for(MBeanParameterInfo param : params){
        System.out.print(param.getName()+" - "+param.getType()+ " - "+param.getDescriptor().getField("openType") + ",");
    }
    System.out.println();
Maintenant que l'on a la méthode, son type de retour et surtout sa signature on peut se mettre à invoquer des méthodes. Il suffit pour cela d'appeler la méthode invoke de l'objet faisant le pont en le client et le serveur ( le MBeanServerConnection ). Pour cela il faut appeler la méthode d'un objet en utilisant le nom de l'objet, le nom de la méthode, en lui passant des paramètres et sa signature. Un exemple simple, une méthode qui ne prend aucun paramètre et qui ne renvoie rien, la méthode gc() de l'objet Memory
     MBeanServerConnection mbsc = ...;
     mbsc.invoke(new ObjectName("java.lang:name=Memory"),"gc",new Object[]{},new String[]{});

Une petite explication s'impose, les paramètres sont passés en tant que tableau d'objet, ici vide vu qu'il n'y aucun paramètre. Par contre la signature est en mode texte, ainsi bien que les OpenType soient gérés, il semble obligatoire d'utiliser la signature en mode simple ( avec le retour de param.getType() lorsque l'on tente de connaître la signature de la méthode ). Ainsi si l'on veut invoquer une méthode prenant en paramètre un tableau de chaîne de caractère et un long il faudra créer un table de String contenant les signatures de ces deux types.
     String[] signature = new String[]{"[Ljava.lang.String","long"};
     Object[] params = new Object{ signature, 32L}; // Par commodité je passe la signature car c'est un tableau de chaine de caractère.
     Object retour =  mbsc.invoke(new ObjectName("mondomaine:name=MonObjet"),"mamethode",params,signature));
Vous avez maintenant toutes les cartes en main pour créer un agent JMX. La prochaine partie parlera du côté serveur avec la possibilité de créer ses propres objets et de les appeler à la manière de RMI mais en beaucoup plus souple ( bien qu'il s'agisse bien souvent de RMI pour la couche transport). Elle dévoilera un petit exemple de bout en bout de mon cru. Le sujet est en cours d'élaboration, la patience est de mise !