Dossier : L'instrumentation de code en .NET par Sébastien Bouchet (sebastien.bouchet@linkvest.com)

Introduction

L’idée d’insérer des services « autour d’un objet » (par exemple avant et après l’appel d’une méthode) n’est pas nouvelle. Bien avant que l’on parle de la programmation orientée aspect (AOP), les développeurs ont cherché des moyens d’enrichir/instrumenter leur code de manière quasi-transparente.

Un mécanisme « pré-AOP » proposé par Java fait appel à la notion de proxy dynamique. A partir d’un objet de base de type A et d’une classe d’instrumentation, il est possible de faire générer un proxy qui instrumente A et expose les mêmes interfaces que A lors de la phase d’exécution. Il est donc possible d’utiliser de manière (quasi-)transparente un proxy en lieu et place d’une instance de A et donc de bénéficier de l’instrumentation qui a été mise en place.

Cet article a pour but de proposer une implémentation des proxies dynamiques « à-la-Java » pour le framework .NET, en se basant sur l’émission dynamique de MSIL (technique souvent appelée Reflection.Emit en référence au namespace System.Reflection.Emit qui fournit la plupart des classes permettant d’émettre du MSIL à l’exécution).

Dans son excellent dossier sur la programmation orientée aspect (AOP), Thomas Gil a d’ailleurs mentionné rapidement la possibilité d’utiliser cette technique pour ajouter des fonctionnalités aspect au framework .NET. Je ne saurais trop vous conseiller de commencer par la lecture de son dossier qui couvre de manière complète les enjeux et problématiques de l’AOP.

Pourquoi des proxies dynamiques ?

Commençons par une rapide mise en perspective : quel comportement cherche-t-on à obtenir ? Pour cela, partons de l’exemple simple suivant : comment mettre en place un système de trace des appels de méthodes dans un applicatif ?

Il existe au moins deux solutions que l’on souhaiterait éviter à tout prix :

Instrumentation mêlée

La première méthode consiste à édicter une norme de développement stipulant que chaque développeur doit penser à ajouter une trace de l’entrée/sortie du flot de contrôle de chaque méthode qu’il développe :

    //[Java]

public void UnExemple(){

        System.out.println("[ENTER] MaClass::UnExemple()");
        //L'implémentation à proprement parler
        System.out.println("[LEAVE] MaClass::UnExemple()");
}


Cette technique est lourde et viole sans vergogne le (bon) principe de séparation des responsabilités, avec les conséquences que l’on sait :

On objectera qu’un pré-processeur bien senti viendrait aisément à bout de ce problème, en altérant le code produit par le programmeur, immédiatement avant la compilation.

C’est tout à fait exact, et c’est l’objet d’outils d’AOP avec tramage à la compilation (compile-time weaving) tel que AspectJ.

Le problème est d’ailleurs tellement bien connu de l’industrie que les têtes pensantes du développement logiciel ont commencé à y chercher une solution il y a bien longtemps. Pour étayer ce propos, on peut mentionner à titre anecdotique une pratique ancestrale : les macros C/C++. Dans le monde C, un moyen d’insérer du code au début / à la fin d’une méthode de manière semi-automatique est de définir des macros BEGIN_FUNC et END_FUNC qui vont servir à générer les déclarations de fonctions.

Définies comme dans l’exemple ci-dessous, ces macros permettent de tracer les entrées/sorties dans une méthode.

#include <stdio.h>

 

#define BEGIN_FUNC(name,params) name(params){ \

        char* __methName = "" #name "";printf("[ENTER]-->%s\n",__methName);

#define END_FUNC() printf("[EXIT]-->%s\n",__methName);}

void BEGIN_FUNC(FaisQuelqueChose,char* p)

        printf("%s\n",p);

END_FUNC()

int main(void) {

    FaisQuelqueChose("Hello");

    return 0;

}


A l’exécution on obtient l’output suivant :

    E:\dynamic proxies>test

[ENTER]-->FaisQuelqueChose

Hello

[EXIT]-->FaisQuelqueChose


Il est inutile d’épiloguer sur les nombreuses faiblesses de cette approche ad hoc (ne voyant pas les { et } en début et fin de méthode, votre éditeur de code favori aura sans doute du mal avec l’indentation ; dans le cas d’un programme C, les déclarations de variables doivent précéder le code proprement dit, etc …)

Décoration manuelle

Une autre approche consiste à travailler au corps le design de notre applicatif. On peut être aidé en cela par le design pattern « décorateur », qui exprime un design dans lequel une classe « Décorateur » enrichit la fonctionnalité d’un objet de type « Décoré » tout en exposant la même interface publique. L’utilisateur peut donc utiliser de manière transparente l’objet décoré ou non.

Voici une représentation simple de ce design pattern sous la forme d’un diagramme de classes :

 

L’utilisation des classes participantes est illustrée par le diagramme de séquence suivant :

Ce qui correspond au code suivant :

//[C#]

//Sans instrumentation :

//IDecore d = new ObjetConcretADecorer() ;

 

IDecore d = new Decorateur(new ObjetConcretADecorer()) ;

d.FaisQuelqueChose();

 

Le décorateur agrège une instance de la classe originelle, et fournit l’instrumentation. Il y a donc un début de séparation de la fonctionnalité et de l’instrumentation.

Si le design est satisfaisant (le modèle objet originel – ici la classe ObjetConcretADecorer – n’est pas affecté par les services que l’on veut ajouter), sa mise en oeuvre n’est pas viable à grande échelle : en effet, pour chaque interface à décorer, il faut coder un décorateur ! Si l’on doit instrumenter 100 classes, alors l’ampleur de la tâche sera rédhibitoire.

Heureusement, on sent bien qu’un générateur de code viendrait à bout de ce problème sans grande difficulté. Et une fois encore, c’est tout à fait exact !

Et d’ailleurs, si vous imaginez que ce générateur fonctionne non pas avant la compilation mais à l’exécution, vous commencez à avoir une idée de la fonctionnalité offerte par les proxies dynamiques. Il est donc grand temps de se pencher sur les proxies dynamiques tels qu’ils sont proposés dans la plateforme J2SE.

Proxies dynamiques en Java

La mise en œuvre des proxies dynamiques en Java est très simple :

Prenons l’exemple d’une classe AClass implémentant une interface AnInterface.

public class AClass implements AnInterface {

        ...

}

 

Si l’on veut ajouter une fonctionnalité de trace systématique des appels, on fournit une implémentation de InvocationHandler, par exemple dans une classe TraceMethodCallsHandler.

import java.lang.reflect.InvocationHandler;

import java.lang.reflect.Method;

 

public class TraceMethodCallsHandler implements InvocationHandler {

    Object _inner = null;

    public TraceMethodCallsHandler(Object o)

    {

        _inner = o;

    }

    public Object invoke(Object targ, Method meth, Object[] arguments)

        throws Throwable {

            System.out.println("Before --> " + meth.getName());

            Object ret = meth.invoke(_inner,arguments);

            System.out.println("After --> " + meth.getName());

            return ret;

    }

}

 

Alors on créé un proxy de la manière suivante :

    AClass c = new AClass();

InvocationHandler h = new TraceMethodCallsHandler(c);

AnInterface proxy = (AnInterface)Proxy.newProxyInstance(

    ClassLoader.getSystemClassLoader(),

    new Class[]{AnInterface.class},

    h);

 

On passe à newProxyInstance un tableau de types (Class[]) qui contient la liste des interfaces que l’on veut que le proxy implémente, en l’occurrence AnInterface. Le cast vers AnInterface est donc légal, et le proxy peut alors être utilisé en lieu et place de l’instance c. Les appels aux méthodes se voient alors agrémentés de la fonctionnalité fournie par l’InvocationHandler.

Le diagramme UML des classes participantes est le suivant :

 

Remarquez qu’aucune librairie additionnelle n’est requise, le J2SE SDK vous suffira à compiler et tester le code ci-dessus.

Si vous ne connaissiez pas cette technique, j’espère vous avoir convaincu que son rapport simplicité de mise en œuvre / efficacité est assez séduisant. On dispose là d’une mini-plateforme AOP avec tissage à l’exécution ! Notre sous-classe de InvocationHandler n’est rien d’autre qu’un conseil (advice), et Proxy::newProxyInstance est le tisseur de cette plateforme !

On peut donc légitimement se poser la question suivante : y a-t-il moyen de faire la même chose avec le framework .NET ?

Proxies dynamiques pour le framework .NET

Je vois au moins quatre approches pour créer des proxies dynamiquement avec le framework .NET :

La dernière de ces approches fait l’objet de la partie suivante. Afin de mieux comprendre ce que seront ses forces et faiblesses, évoquons succinctement les trois autres solutions.

Sous-classer RealProxy

Une des façons répandues (à en juger par le nombre d’articles sur le sujet) de réaliser des proxies dynamiques pour le framework .NET est de fournir une sous-classe de RealProxy.

Le développement de l’instrumentation se fait en surchargeant la méthode abstraite Invoke de la classe RealProxy, définie comme suit :

    //[C#]

public override IMessage Invoke(IMessage msg);

 

On trouvera un exemple de RealProxy à l’adresse http://hosting.msugs.ch/DotNetRox/articles/Art06.html ou encore http://www.dotnetguru.org/articles/CustomProxy.htm.

L’utilisation d’un proxy développé de cette manière est simple. Si la sous-classe de RealProxy se nomme TraceProxy :

    //C#

TraceProxy p = new TraceProxy(typeof(AnInterface),new AClass());

AnInterface proxy = (AnInterface).GetTransparentProxy();

proxy.DoSomething();

 

C’est l’infrastructure de .NET Remoting qui entre en jeu pour convertir la pile d’appel en une structure IMessage et réciproquement, convertir le IMessage en un appel de méthode sur l’objet concret au moyen d’un StackBuilderSink. Ce processus n’est bien entendu pas sans impact sur la performance, comme le laisse penser la figure suivante qui explicite la chaîne d’invocation :

Figure 1 : chaîne d'appel (source : MSDNMAG - Mars 2003)

Développer un attribut de contexte

Cette méthode est une variante de la précédente, qui diffère par la façon dont on développe les services à injecter aux appels.

Elle suppose que les objets à « proxifier » héritent de ContextBoundObject :

//C#

public class AClass:ContextBoundObject,AnInterface {

    ...

}


On développe alors un attribut de contexte (hérite de ContextAttribute) implémentant IContributeObjectSink, ce qui lui permet d’insérer un IMessageSink dans la séquence d’appel de la Figure 1.

C’est en implémentant la méthode SyncProcessMessage (resp. AsyncProcessMessage pour les appels asynchrones) de l’interface IMessageSink que l’on peut injecter des services avant ou après l’exécution d’une méthode.

Un canevas d’implémentation rapide et compacte d’un tel attribut est donné ci-dessous :

//C#

public class TraceAttribute : ContextAttribute,IMessageSink,IContributeObjectSink {

        private IMessageSink _nextSink;

        ...

        public IMessage SyncProcessMessage(IMessage msg)

        {

                //... le code avant l'appel

                IMessage ret = _nextSink.SyncProcessMessage(msg);

                //... le code après l'appel

                return ret;

        }

}

 

La classe AClass n’a plus qu’à se réclamer de cet attribut pour que l’injection de services soit opérationnelle :

    //C#

[TraceAttribute]

public class AClass:ContextBoundObject,AnInterface {

        ...

}

 

Pour le développeur utilisant cette classe, les services ajoutés sont invisibles. Le code suivant suffit :

    //C#

AClass c = new AClass();

 

En réalité, l’opérateur new retourne, dans ce cas, un proxy transparent similaire à celui qui a été créé manuellement dans le paragraphe précédent. Le développeur s’en rendra compte en déboguant son code et en inspectant la valeur de c.

Pour plus de détails sur cette technique faisant là encore appel à .NET Remoting, on pourra par exemple télécharger un source VB.NET d’exemple à l’adresse http://hosting.msugs.ch/DotNetRox/articles/dynamic-proxy.zip.

Quelques remarques s’imposent concernant cette approche :

Générer le proxy avec CodeDOM

Puisqu’on parle de génération dynamique de code, il est légitime d’envisager l’utilisation de la technologie CodeDOM, qui fournit tout ce qu’il faut pour générer facilement et de manière fiable du source C# ou VB.NET. Comme l’on dispose du namespace System.Reflection pour inspecter notre modèle d’objet initial, tous les ingrédients sont réunis pour implémenter l’algorithme de génération.

Les étapes de la mise en place sont les suivantes : développer une méthode « factory » similaire à la méthode newProxyInstance de java.lang.reflect.Proxy

  1. Création d’un graphe CodeDOM représentant en mémoire et de manière indépendant du langage de génération le source du proxy

  2. Sélection d’un fournisseur de code ou CodeProvider (CSharpCodeProvider, par exemple)

  3. Récupération d’un ICodeGenerator au moyen de la méthode CreateGenerator du fournisseur de code

  4. Génération du code source du proxy dans un fichier temporaire

  5. Récupération d’un ICodeCompiler au moyen de la méthode CreateCompiler du fournisseur de code

  6. Compilation du code généré

  7. Chargement dynamique de l’assembly généré, et manipulation du type correspondant au proxy La manipulation de CodeDOM est aisée et très bien documentée. Le SDK du framework fournit en outre une multitude d’exemples illustrant cette technologie novatrice et ô combien séduisante !

Il est opportun de formuler les remarques suivantes :

L’approche CodeDOM est donc parfaitement viable d’un point de vue technique, mais elle se prête nettement mieux à une génération statique, comme une étape de post-compilation.

Pour une génération légère et totalement dynamique, l’émission d’un assemblage dynamique au moyen de System.Reflection.Emit est une option supplémentaire décrite ci-après..

Génération avec System.Reflection.Emit

But à atteindre

Fixons nous le but suivant : transposer l’architecture des proxies dynamiques Java dans le monde .NET.

Convenons donc que l’instrumentation sera fournie dans une classe héritant de CallHandler (notre version de InvocationHandler) et que les proxies seront créés par une classe utilitaire ObjectFactory (notre « java.lang.reflect.Proxy »).

Une classe implémentant CallHandler fournira donc son instrumentation en implémentant la méthode :

    public override object Invoke(string pStrMethodName,object[] parms)

 

Que générer ?

Le meilleur moyen d’appréhender le code à générer est de raisonner sur un exemple.

Si on considère les interfaces et classes de base suivantes :

public interface IMyBizFace

{

        int DoSomething();

}

public class SomeClass : IMyBizFace

{

        public int DoSomething()

        {

                Console.WriteLine("In DoingSomething() ...");

                return -347;

        }

}

 

Et un CallHandler basique qui va logguer les appels :

public class TraceDecorator : CallHandler

{

        object _inner;

        public TraceDecorator(object obj)

        {

                _inner = obj;

        }

        public override object Invoke(string pStrMethodName,object[] parms)

        {

                Console.WriteLine("Proxyfying call ...");

                object ret = InvokeMember(_inner,pStrMethodName,parms);

                Console.WriteLine("End of proxyfied call ...");

                return ret;

        }

}

 

La classe CallHandler étant définie comme suit :

public abstract class CallHandler

{

        protected object InvokeMember(object target,string methodName,object[] parms)

        {

                return target.GetType().InvokeMember(

                        methodName,

                        System.Reflection.BindingFlags.InvokeMethod,

                        null,

                        target,parms);

        }

        public abstract object Invoke(string methodName,object[] parms);

}

 

Alors le code nécessaire pour utiliser ce framework aura l’allure suivante :

//Objet que l'on veut proxifier

SomeClass source = new SomeClass();

 

//On augmente cet objet à l'aide d'un

//CallHandler qui ajoute de la fonctionnalité autour

//des méthodes de l'objet

CallHandler sourceDecoree = new TraceDecorator(source);

 

Ici on aimerait pouvoir utiliser le handler « sourceDecoree » en lieu et place de l’objet initial. Oui mais voilà, sourceDecoree est de type CallHandler qui n’expose pas les mêmes interfaces que l’objet initial. Toute tentative du type :

    ((IMyBizFace)sourceDecoree).DoSomething();

 

est donc vouée à l’échec (une InvalidCastException).

Il faut introduire une étape supplémentaire, la création d’un proxy exposant les mêmes interfaces que l’objet de base, autour du handler.

    //Puis on construit un proxy autour du handler pour faire

//"comme si" il implémentait les mêmes interfaces que SomeClass

IMyBizFace proxy = (IMyBizFace)ObjectFactory.NewInstance(typeof(SomeClass),sourceDecoree);

 

//Ensuite on peut travailler avec le proxy comme on travaillerait

//avec l'objet de base

proxy.DoSomething();

 

A quoi pourrait bien ressembler le proxy ? Si l’on imagine son code source, il aurait l’allure suivante :

public class SomeClassProxy : IMyBizFace

{

        ProxLib.CallHandler _handler;

        public SomeClassProxy(ProxLib.CallHandler pHandler)

        {

                _handler = pHandler;

        }

        public int DoSomething()

        {

            return (int)_handler.Invoke("DoSomething",new object[]{});

        }

Listing 1 : source virtuel du proxy à générer

Pour la petite histoire, si nous avions sélectionné CodeDOM comme moyen de générer un proxy dynamique, il s’agirait pour nous de générer automatiquement le code ci-dessus, puis de le compiler et de charger l’assembly généré dynamiquement.

Voici donc ce qu’il nous faut réaliser : coder la méthode NewInstance() de ObjectFactory pour qu’elle génère du MSIL équivalent à celui qui proviendrait de la compilation du source ci-dessus.

A quoi peut ressembler ce MSIL ? Pour le voir, nous n’avons qu’à compiler le bout de code « virtuel » ci-dessus et le désassembler à l’aide de l’outil ILDASM.

Le MSIL provenant de la décompilation de la méthode DoSomething() est le suivant :

Figure 2 : dump MSIL de la méthode DoSomething() du proxy à générer

A la lecture de ce dump cryptique, on peut légitimement prendre peur : va-t-il falloir entrer dans les détails de ce langage ? La réponse est – heureusement – non : le namespace System.Reflection.Emit nous fournit une API d’assez haut niveau pour générer simplement la plupart des éléments syntaxiques apparaissant ci-dessus (déclarations de classes, de méthodes, …). Vous l’avez compris, il est grand temps de se pencher sur les possibilités des classes de ce namespace !

Oui mais avouez qu’il serait dommage d’émettre du MSIL sans comprendre ce qui se passe réellement ! Je vous propose donc de décortiquer un peu le source MSIL de la méthode DoSomething().

Ce qu’il faut savoir sur le CLR

Dans un sens, MSIL est beaucoup plus simple à appréhender que l’assembleur x86 « classique » dans la mesure où le Common Language Runtime n’utilise pas de registres. Tout passe par la pile d’évaluation qui sert à contenir tour à tour les arguments à passer à une instruction et le résultat de l’exécution d’une instruction.

Le schéma générique de fonctionnement est donc extrêmement simple : pour additionner deux entiers, il suffit de les pousser sur la pile, puis d’appeler l’instruction d’addition, ce qui a pour effet d’enlever les deux entiers de la pile et d’y stocker le résultat.

Exemple : ajouter 3 et 4

Remarques sur les instructions

D’une manière générale, les instructions :

Ainsi ldc.i4.0 permet de pousser sur la pile un entier (sur 32 bits : i4) dont la valeur est 0, ce qui est équivalent à l’instruction ldc.i4.s 0

ldarg permet de charger un argument de la méthode sur la pile. Attention, on pourrait croire que ldarg.0 charge le premier paramètre de la méthode, en réalité dans le cas d’une méthode non statique le premier argument est l’adresse de l’objet courant, c’est-à-dire this. ldarg.0 permet donc dans ce cas de charger la référence this sur la pile.

DoSomething() passée au crible

Rapprochons le MSIL de la Figure 2 de son source C# théorique :

    public int DoSomething()

{

        return (int)_handler.Invoke("DoSomething",new object[]{});

}

 

Pour implémenter cette méthode en MSIL, il faut pouvoir invoquer la méthode Invoke de _handler. On dispose pour cela de l’instruction callvirt qui permet d’invoquer une méthode manière polymorphique. callvirt s’attend à disposer, sur la pile d’évaluation, des informations suivantes :

Avant d’appeler callvirt, il nous faut donc peupler la pile d’évaluation de la bonne manière ; on récupère facilement une référence sur _handler au moyen de l’instruction ldfld (« Load Field »), appliquée à la référence this que l’on récupère par un ldarg.0.

Charger la chaîne « DoSomething » se fait de manière très simple au moyen de ldstr.

Note : Dans la suite, nous représenterons graphiquement la pile comme si System.String était un type par valeur, ce qui n’est pas le cas, et ceci pour des raisons de simplification des schémas.

Reste à créer un tableau d’objets. MSIL fournit pour cela l’instruction newarr, qui prend comme paramètre sur la pile d’évaluation le nombre d’éléments du tableau à créer (ici 0).

Toutes ces étapes de remplissage de la pile qui précèdent l’instruction callvirt qui est le cœur de notre méthode sont représentées ci-dessous. La pile y est représentée en orange:

Figure 3 : évolution de la pile d'évaluation lors de l'exécution de DoSomething()

Une fois Invoke() appelée, callvirt a laissé sur la pile d’évaluation une référence sur le résultat. Si le résultat était d’un type par référence, tout irait bien, il suffirait de retourner la référence à l’aide de l’instruction ret. Toutefois, dans notre exemple, comme DoSomething() retourne un int, Invoke() a retourné une référence sur un Int32 situé sur le tas (boxed). Il nous faut donc transférer cette valeur entière du tas vers la pile, ce qui est effectué par la combinaison unbox/ldind.i4.

è Quelques explications s’imposent, notamment un bref rappel du mécanisme de boxing/unboxing !

Boxing et unboxing

Le CLR alloue en ligne l’espace nécessaire pour contenir les types par valeur. Concrètement, cela signifie que le bout de code suivant :

    int i = 123;

 

initialise un entier sur la pile.

Dans le cas d’un champ dont le type est par valeur, la cartographie du tas managé s’explique par le fait que par défaut, l’allocation est faite en ligne, comme l’illustre la figure ci-dessous :

Figure 4 : allocation des champs d'un type par valeur

Mais alors, comment se fait-il alors que l’on puisse compiler et exécuter le code suivant ?

    int i = 123;

Hashtable h = new Hashtable();

h.Add(i, « 123-eme element »);

 

La méthode Add de la classe Hashtable attend en paramètres deux références vers un object. Object étant la classe de base des types par référence (résidant sur le tas managé), on se demande donc comment le runtime peut bien s’en sortir avec une valeur stockée sur la pile au moment d’invoquer Add !

Ce tour de magie se nomme boxing : le CLR convertit l’entier i en une représentation équivalente sur le tas. Concrètement, cela signifie qu’il alloue de la place sur le tas pour une « boîte » (box) suffisamment grande pour contenir un entier, puis copie la valeur de la pile vers le tas. Il passe alors à Add une référence vers la forme emboîtée (boxed form) de i. Ce processus est illustré par la figure suivante :

Figure 5 : principe du boxing (source : doc du .NET Framework SDK )

Dans le cas d’un champ d’une classe, la cartographie du tas peut être modifée en déclarant le champ comme étant de type object :

Figure 6 : allocation d'un champ sous sa forme "mise en boîte"

Remarque

La boîte a la place nécessaire pour contenir le type par valeur, mais également l’en-tête associé aux types par référence, en-tête constituée des overhead fields. Cet en-tête contient notamment un SyncBlockIndex nécessaire pour sérialiser les accès à cet objet. Pour une valeur dans sa forme unboxed, il n’y a pas de SyncBlockIndex ; c’est ce qui explique que le code suivant :

    int i = 133;

lock(i) {

        Console.WriteLine(i);

}


      produit l’erreur de compilation suivante : 'int' is not a reference type as required by the lock statement

Alors que le code suivant est légal :

    int i = 133;

object o = i; //boxing, la forme boxed de l’entier dispose des overhead fields

lock(o) {

        Console.WriteLine(o);

}


Pour un exposé passionnant portant sur ces overhead fields, se référer à l’article de Jeffrey Richter situé à l’adresse http://www.msdnaa.net/Resources/display.aspx?ResID=1794

Mais qui dit « mise en boîte » implique sortie de la boîte ! Cette opération se nomme unboxing. Elle consiste à convertir un valeur de sa forme « en boîte » vers sa forme habituelle, c'est-à-dire une valeur sur la pile. Dans l’exemple suivant, j est bien situé sur la pile, et la déclaration int j = o est bien plus coûteuse qu’il n’y paraît puisque le CLR recopie la valeur contenue dans la référence o vers la pile. Attention à ce genre de piège dans des boucles !

    int i = 133;

object o = i; //boxing

int j = o; //unboxing


Au niveau le plus bas (MSIL), l’unboxing se déroule en deux phases, d’où la combinaison unbox/ldind.i4 que l’on trouve dans le source de DoSomething() :

Nous voici munis du savoir nécessaire pour comprendre les dernières lignes du MSIL de notre méthode DoSomething(), qui transfèrent l’entier retourné par Invoke du tas vers la pile avant de le retourner :

Figure 7 : transfert de la valeur de retour de Invoke de la pile vers le tas

Enfin, l’instruction ret termine la méthode et retourne la valeur située sur la pile.

Remarques

è A ce stade, nous avons une idée à peu près claire du MSIL que nous devons générer. Il est temps de se pencher sur les outils fournis par la FCL : le namespace System.Reflection.Emit.

Sytem.Reflection.Emit

Grands principes

Pour émettre dynamiquement un type avec System.Reflection.Emit, on suit le processus générique suivant :

  1. Création d’un AssemblyBuilder

  2. Ouverture d’un nouveau ModuleBuilder dans l’AssemblyBuilder pour contenir le type

  3. Déclaration à l’aide d’un TypeBuilder

  4. Ajout des champs

  5. Ajout du ou des constructeurs à l’aide de ConstructorBuilder, émission du code à l’aide du ILGenerator de chaque ConstructorBuilder et de l’énumération OpCodes

  6. Ajout des différentes méthodes au moyen de MethodBuilders, émission du code à l’aide du ILGenerator de chaque MethodBuilder et de l’énumération OpCodes

Les principales classes et interfaces que nous allons utiliser sont les suivantes :

AppDomain Le domaine dans lequel on va émettre l’assemblage dynamique
AssemblyBuilder Permet de configurer et construire un assembly dynamiquement
ModuleBuilder Permet de configurer et construire un module pour contenir le code que l’on va générer
TypeBuilder Déclare et configure un nouveau type (interface, classe, …) de manière dynamique
FieldBuilder Déclare un nouveau membre
ConstructorBuilder Déclare un nouveau constructeur
MethodBuilder Déclare et configure une méthode
ILGenerator Emet du MSIL
Enumération OpCodes Contient tous les opcodes supportés par MSIL

         

Quel est le résultat de ce processus ? Un AssemblyBuilder complet et un TypeBuilder complet. A partir de l’AssemblyBuilder, il est possible de sauvegarder l’assembly généré sur le disque. On obtient donc une dll ou un exe que l’on peut désassembler pour corriger d’éventuels bugs.

A partir du TypeBuilder, il est possible, grâce à la méthode CreateType, de créer un System.Type que l’on peut directement utiliser dans le domaine courant pour créer des objets.

Dans la pratique, on obtient le canevas suivant :

    private static Type EmitProxy(Type t)

{

        //Le nom du proxy est consrtuit à partir du nom du type

        assemblyName.Name = "__DynaProx." + t.Name;

        //on ouvre un assembly builder dans le domaine courant

        //On le configure en RunAndSave pour pouvoir sauvegarder

        //l'assembly généré et le désassembler

        AssemblyBuilder assembly = AppDomain.CurrentDomain.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.RunAndSave,(string)@"c:\temp");

 

        //Le module qui va contenir le proxy

        ModuleBuilder module = assembly.DefineDynamicModule("__DynaProx.Module","dumpme.dll");

       

        //la définition du proxy

        //C'est une sous-classe publique d'object

        //qui implémente les mêmes interfaces que le type à proxifier

        TypeBuilder proxyClass =

                module.DefineType("__Proxy__" + t.Name,

                TypeAttributes.Public | TypeAttributes.Class,

                typeof(object),

                t.GetInterfaces());

 

        // ... le reste (méthodes, ...) ...

        //Et enfin on instancie le type de manière à pouvoir créer une

        //instance du proxy

        proxyClass.CreateType();

}

 

L’option AssemblyBuilderAccess.RunAndSave passée au constructeur de AssemblyBuilder permet d’utiliser dynamiquement l’assembly généré tout en conservant la possibilité de le sauver sur le disque (méthode Save() de la classe AssemblyBuilder)

Algorithme de génération

Il ne nous reste plus qu’à configurer le TypeBuilder pour qu’il implémente la fonctionnalité voulue. L’algorithme de génération se déduit facilement du listing 1 :

    Créer un champ privé _handler de type CallHandler

Créer un constructeur acceptant un CallHandler en paramètre

Emettre le corps du constructeur :

        Initialiser _handler avec ce paramètre

 

Pour chaque interface implémentée

        Pour chaque méthode de l’interface

                Ajouter une méthode de même signature

                Emettre le corps de la méthode :

                        Créer un tableau object[] o contenant les paramètres

                        Appeler _handler ::Invoke avec en paramètres le nom de la méthode et o

                        Retourner la valeur de retour

        Fin pour

Fin pour

 

Structure et constructeur

L’ajout d’un champ ne nécessite aucune connaissance de MSIL ; la méthode DefineField() de la classe TypeBuilder se charge d’émettre le microcode. Elle prend en paramètres le nom du champ, son type et une combinaison de FieldAttributes permettant de spécifier si le champ est statique, constant, public, protégé, …

    FieldBuilder handlerField =

        proxyClass.DefineField("_handler",

        typeof(ProxLib.CallHandler),

        FieldAttributes.Private);

 

La déclaration du constructeur n’est pas plus complexe, mais il nous reste à émettre le corps du constructeur. Pour cela, nous avons – enfin ! – besoin d’émettre du MSIL. Nous devons d’abord récupérer un ILGenerator pour le constructeur au moyen de la méthode GetILGenerator() du ConstructorBuilder.

A nouveau se pose la question de savoir ce qu’il faut générer ; si l’on décortique le micro-fonctionnement du constructeur, en se référant au proxy C# que l’on a pris soin de désassembler, on tombe sur le MSIL suivant :

    IL_0000: ldarg.0

IL_0001: call          instance void [mscorlib]System.Object::.ctor()

IL_0006: ldarg.0

IL_0007: ldarg.1

IL_0008: stfld         class [ProxLib]ProxLib.CallHandler TargetLib.SomeClassProxy::_handler

IL_000d: ret

 

Le constructeur effectue donc les opérations suivantes (rappel : ldarg.0 charge « this » sur la pile, ldarg.1 charge le premier paramètre sur la pile) :

  1. appel du constructeur de la classe mère (ici Object ::.ctor())
  2. affectation du champ _handler avec le paramètre passé
  3. sortie

Ce qui se traduit, en terme d’évolution de la pile d’évaluation, par le schéma ci-dessous :

Si l’on rassemble tous les morceaux, le code source permettant de générer totalement le constructeur est le suivant :

    private static FieldBuilder CreateStructureAndConstructor(TypeBuilder proxyClass)

{

        //l'attribut de type CallHandler

        FieldBuilder handlerField =

                proxyClass.DefineField("_handler",

                typeof(ProxLib.CallHandler),

                FieldAttributes.Private);

 

        //le constructeur et son argument de type CallHandler

        ConstructorBuilder constructor = proxyClass.DefineConstructor(

                MethodAttributes.Public,

                CallingConventions.Standard,

                new Type[]{ typeof(ProxLib.CallHandler) });

 

        //Et enfin le coeur : le constructeur appelle le constructeur

        //de la super classe et affecte la valeur du champ à partir

        //de ce qui a été passé en paramètre

        ILGenerator constructorIL = constructor.GetILGenerator();

        constructorIL.Emit(OpCodes.Ldarg_0);

        ConstructorInfo super = typeof(Object).GetConstructor(new Type[0]);

        constructorIL.Emit(OpCodes.Call, super);

        constructorIL.Emit(OpCodes.Ldarg_0);

        constructorIL.Emit(OpCodes.Ldarg_1);

        constructorIL.Emit(OpCodes.Stfld, handlerField);

        constructorIL.Emit(OpCodes.Ret);

        return handlerField;

}

 

Implémentation des interfaces

Reste maintenant à générer le corps de chaque méthode des différentes interfaces qu’implémente notre proxy. Nous nous sommes fait, au moyen de la Figure 3, une idée du MSIL à générer. Toutefois, l’exemple était assez simpliste car DoSomething() n’acceptait aucun paramètre.

L’allure générale du générateur est la suivante :

private static void ImplementInterface(

                                    TypeBuilder proxyClass,

                                    Type interf,

                                    FieldBuilder _handlerField)

{

        MethodInfo[] meths = interf.GetMethods();

        foreach (MethodInfo method in meths)

        {

                Type [] paramtypes = new Type[method.GetParameters().Length];

                int i = 0;

                foreach(ParameterInfo prm in method.GetParameters())

                {

                        paramtypes[i++] = prm.ParameterType;

                }

                MethodBuilder mbuilder =

                        proxyClass.DefineMethod(

                                method.Name,

                                MethodAttributes.Virtual | MethodAttributes.Public,

                                method.ReturnType,

                                paramtypes);

                …

        }

}

 

C’est l’objet MethodBuilder qui nous permet de définir la méthode. Grâce à son ILGenerator, obtenu par un appel à GetILGenerator(), nous allons pouvoir émettre le corps de la méthode.

Un des points critiques est de générer le code nécessaire pour initialiser correctement le tableau. Le MSIL cible a l’allure suivante (des commentaires ont été ajoutés pour faciliter la lecture)

    //Créer le tableau

IL_000c: newarr     [mscorlib]System.Object

//stocker une référence sur le tableau dans la variable locale #1

IL_0011: stloc.1

 

//Charger une référence sur le tableau sur la pile

IL_0012: ldloc.1

//Charger l’entier 0 sur la pile

IL_0013: ldc.i4.0

//Charger le premier paramètre sur la pile

IL_0014: ldarg.1

//tableau[0] = argument n°1

IL_0015: stelem.ref

 

//Charger une référence sur le tableau sur la pile

IL_0016: ldloc.1

//Charger l’entier 1 sur la pile

IL_0017: ldc.i4.1

//Charger le second paramètre sur la pile

IL_0018: ldarg.2

//tableau[1] = argument n°2

IL_0019: stelem.ref

 

//Et ainsi de suite pour chaque paramètre


Remarques

La principale difficulté étant vaincue, il ne reste plus qu’à produire le code qui génère le MSIL :

    //2. Maintenant, il faut émettre le code nécessaire pour invoker

//"Invoke". Pour cela on doit générer du MSIL

ILGenerator gen = mbuilder.GetILGenerator();

gen.DeclareLocal(method.ReturnType);

gen.DeclareLocal(typeof(object[]));

//3. Il s'agit d'abord de loader le field Handler

//on utilise pour cela l'argument 0 de la méthode qui

//n'est autre que this !

gen.Emit(OpCodes.Ldarg_0);

gen.Emit(OpCodes.Ldfld,_handlerField);

 

//4. Le premier argument de Invoke est le nom de la méthode

gen.Emit(OpCodes.Ldstr,method.Name);

//5. Ensuite on créé le tableau object[] qui contient les paramètres

//il faut connaître la longeueur du tableau, à savoir

//method.GetParameters().Length, que l'on pushe donc

//sur la pile

gen.Emit(OpCodes.Ldc_I4_S,method.GetParameters().Length);

gen.Emit(OpCodes.Newarr,typeof(System.Object));

 

gen.Emit(OpCodes.Stloc_1);

gen.Emit(OpCodes.Ldloc_1);

 

//6. le morceau plus touchy : générer le code d'initialisation

//du tableau, typiquement quelque chose du style

//new object[]{toto,tata,tonton,titi}

for(int j = 0;j < method.GetParameters().Length;j++)

{

        gen.Emit(OpCodes.Ldc_I4_S,j);

        gen.Emit(OpCodes.Ldarg_S,j+1); //eh oui, attention ldarg.0 = this !

 

        //Si le type de l'argument est un type par valeur, il

        //faut le boxer car on le met dans un object[] dont les

        //éléments sont nécessairement sur le heap

        if(method.GetParameters()[j].ParameterType.IsSubclassOf(typeof(System.ValueType)))

                gen.Emit(OpCodes.Box,method.GetParameters()[j].ParameterType);

 

        gen.Emit(OpCodes.Stelem_Ref);

        gen.Emit(OpCodes.Ldloc_1);

}

 

//7. Maintenant il ne reste plus qu'à effectuer l'appel concret

//avec un callvirt

gen.EmitCall(OpCodes.Callvirt,typeof(ProxLib.CallHandler).GetMethod("Invoke"),null);

 

//8. Puis le problème épineux du unboxing

//En effet on veur générer quelque chose du genre

// return (int)Invoke("toto",new object[]{})

//sachant que invoke retourne une référence à un object sur le heap

//managé. Bref, dans le cas où on caste en un type par valeur

//il faut unboxer l'objet retourné

 

if(method.ReturnType != null)

{

        if(method.ReturnType.Equals(typeof(void)))

        {

                gen.Emit(OpCodes.Pop);

        }

        else if(method.ReturnType.IsSubclassOf(typeof(System.ValueType)))

        {

                gen.Emit(OpCodes.Unbox,method.ReturnType);

                gen.Emit(OpCodes.Ldobj,method.ReturnType);

        }

}

 

//9. Ca y est on est enfin rendus, il n'y a plus

//qu'à retourner !

gen.Emit(OpCodes.Ret);


Et enfin testons le tout sur un exemple :

    IMyBizFace proxy = (IMyBizFace)ObjectFactory.NewInstance(typeof(SomeClass),new

TraceDecorator(new SomeClass()));

proxy.DoSomething();

 

//Plus complexe : on essaie sur un type built-in : Hashtable

Hashtable h = new Hashtable();

object proxhash = ObjectFactory.NewInstance(typeof(Hashtable),new TraceDecorator(h)); ((IDictionary)proxhash).Add("Boo","Baa");

 

//Youpi ! ça a marché !

 

L’intégralité du code source, sous la forme d’une solution Visual Studio 2002, est disponible ici.

Conclusion

En premier lieu, reconnaissons que l’implémentation proposée est très incomplète : un assembly est généré par type à proxifier, on regénère le proxy à chaque instanciation, …

On constate tout de même que Reflection.Emit est une technologie extrêmement puissante :

Néanmoins, si les résultats exposés ci-dessus sont séduisants, il ne faut pas perdre de vue que Reflection.Emit est :

Enfin, le tableau est légèrement assombri par les deux considérations suivantes :

 

Auteur : Sébastien Bouchet

Copyright © DotNetGuru  - Avril 2003


Qui est Sébastien Bouchet ?

De culture J2EE, Sébastien Bouchet est architecte expert .NET chez Linkvest.

Quelques mots sur Linkvest

Linkvest est un intégrateur de systèmes basé à Lausanne et disposant de bureaux à Genève et Paris. Linkvest est depuis ses origines un pionnier des technologies distribuées (CORBA, .NET, J2EE) et fournit avec succès des services d'intégration d'application d'entreprise aux banques, industries et telecoms depuis 1984. Linkvest est l'un des leaders en Suisse dans la mise en oeuvre de solutions .NET innovantes.

 

Téléchargez le code source de l'article

Solution VS.NET : DynaProx.zip

Ressources

http://www.dotnetguru.org/articles/dossiers/aop/aop.php

ILDASM is your new best friend : http://msdn.microsoft.com/msdnmag/issues/01/05/bugslayer/default.aspx

Dynamic Proxies in .NET

    http://hosting.msugs.ch/DotNetRox/articles/Art06.html

    http://msdn.microsoft.com/msdnmag/issues/03/03/ContextsinNET/default.aspx

    http://java.sun.com/j2se/1.3/docs/guide/reflection/proxy.html

Jeffrey Richter Applied .NET Framework Programming, Microsoft Press : http://www.msdnaa.net/Resources/display.aspx?ResID=1794