| Dossier : L'instrumentation de code en .NET par Sébastien Bouchet (sebastien.bouchet@linkvest.com) | ||
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.
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 :
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 :
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 :
[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 …)
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.
La mise en œuvre des proxies dynamiques en Java est très simple :
le modèle objet de base est développé sans contrainte, et donc livré sous la forme de classes et d’interfaces implémentées.
le code d’instrumentation (exemple : trace de tous les appels de méthodes) est livré sous la forme d’une classe implémentant l’interface InvocationHandler. Il s’agit d’un intercepteur générique fonctionnant avec tous les types d’objets.
Pour utiliser un objet « instrumenté », il suffit de l’envelopper dans la classe d’instrumentation, puis de demander à la classe java.lang.reflect.Proxy de créer un proxy pour cet objet d’instrumentation, faisant ainsi croire à l’appelant que celui-ci expose les mêmes interfaces que l’objet de base.
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 :
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 ?
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.
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 :
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 :
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)
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 :
[TraceAttribute]
public class AClass:ContextBoundObject,AnInterface {
...
}
Pour le développeur utilisant cette classe, les services ajoutés sont invisibles. Le code suivant suffit :
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 :
L’instanciation de l’objet proxifié ne diffère en rien d’une instanciation traditionnelle : le développeur utilise l’opérateur new pour créer une instance de l’objet, et new lui retourne un proxy transparent sans qu’il ne se doute de rien. Il s’agit là d’un atout majeur au niveau du développement.
En termes de design, cette approche est assez différente : en effet il est de la responsabilité de la classe à instrumenter de préciser quels services il faut lui ajouter au moyen des attributs de contexte, alors que dans les autres cas il est de la responsabilité du développeur qui utilise la classe de l’envelopper dans les services adéquats.
Enfin, l’inconvénient majeur de cette solution réside dans le fait que les classes à décorer doivent hériter de ContextBoundObject. Les langages objet modernes ont abandonné pour de bonnes raisons l’héritage multiple, et qu’un framework technique impose la classe de base de tous les objets représente donc une contrainte très forte.
.NET Remoting est une fois encore mis en branle, avec des conséquences potentiellement importantes sur les performances.
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
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
Sélection d’un fournisseur de code ou CodeProvider (CSharpCodeProvider, par exemple)
Récupération d’un ICodeGenerator au moyen de la méthode CreateGenerator du fournisseur de code
Génération du code source du proxy dans un fichier temporaire
Récupération d’un ICodeCompiler au moyen de la méthode CreateCompiler du fournisseur de code
Compilation du code généré
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 :
Les implémentations de ICodeCompiler fournies par le CSharpCodeProvider et VBCodeProvider se basent respectivement sur les compilateurs en ligne de commande csc.exe et vbc.exe. Si ces outils font partie du SDK, il est normal de se poser la question suivante : font-ils partie du runtime redistribuable ? La réponse est oui (et ceci pour une bonne raison : ils sont requis par le runtime ASP.NET), il n’y a donc a priori aucun obstacle théorique ou juridique (en tous cas en l’état actuel du framework .NET et de son licensing) pour redistribuer du code utilisant un ICodeCompiler.
Néanmoins, et cet avis n’engage que moi, je serais prudent dans la redistribution de code exploitant un des ces compilateurs. En effet, exploiter CodeDOM conjointement avec la compilation dynamique peut dans certains contextes permettre de se passer de la technologie VSA qui elle est payante. Microsoft pourrait à l’avenir y voir un manque à gagner et changer les termes de la license des compilateurs.
csc et vbc ne sont pas des compilateurs en mémoire. La sauvegarde des sources et de l’assembly compilé sur un disque ou média inscriptible est donc requise.
La compilation de code C# et VB.NET est une étape coûteuse en temps, même si elle n’est requise que lors de la première utilisation d’un type.
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..
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 :
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 :
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.
//"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().
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

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.
Rapprochons le MSIL de la Figure 2 de son source C# théorique :
{
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 !
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 :
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 ?
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 :
lock(i) {
Console.WriteLine(i);
}
Alors que le code suivant est légal :
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 !
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.
è 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.
Pour émettre dynamiquement un type avec System.Reflection.Emit, on suit le processus générique suivant :
Création d’un AssemblyBuilder
Ouverture d’un nouveau ModuleBuilder dans l’AssemblyBuilder pour contenir le type
Déclaration à l’aide d’un TypeBuilder
Ajout des champs
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
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)
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 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
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é, …
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_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) :
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 :
{
//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;
}
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)
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
On le voit, l’utilisation d’une variable locale est requise. En effet, à chaque instruction stelem.ref, les arguments sur la pile (à savoir la référence sur le tableau, l’index de l’élément du tableau, et la valeur à insérer dans le tableau) disparaissent de la pile d’évaluation. On perdrait donc la référence sur le tableau que l’on a créé si on ne l’avait pas sauvegardé dans une variable locale à l’aide de l’instruction stloc.1.
ldarg.1 est un raccourci pour la forme générique ldarg.s 1 que nous utiliserons pour la génération
De même, ldc.i4.1 est un raccourci pour la forme générique ldc.i4.s 1 que nous utiliserons pour la génération
Dans le cas où la valeur à mettre dans le tableau est d’un type par valeur (sous classe de System.ValueType), une opération de boxing est requise (copie du paramètre de la pile vers le tas managé, et récupération dans la pile d’évaluation d’une référence sur l’emplacement du tas contenant la valeur)
La principale difficulté étant vaincue, il ne reste plus qu’à produire le code qui génère le MSIL :
//"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 :
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.
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 :
API d’assez haut niveau, qui évite de se poser trop de questions ayant trait au format du fichier MSIL à produire (en-têtes, références, déclarations, … sont générés par les APIs du namespace System.Reflection.Emit)
La solution proposée n’impose pas aux objets de base d’hériter d’une classe particulière. La seule règle dont le développeur « métier » doit avoir conscience est la suivante : seules les méthodes exprimées dans des interfaces pourront être instrumentées de cette manière.
Permet une compilation en mémoire (sans nécessité d’accès disque ; ici nous avons choisi de sauvegardé les assemblies générés uniquement à des fins de mise au point), sans dépendance aucune au compilateur C# ou VB.NET.
Néanmoins, si les résultats exposés ci-dessus sont séduisants, il ne faut pas perdre de vue que Reflection.Emit est :
Très technique. Il est clair que l’on ne parle pas ici de day-to-day programming ! Une connaissance minimale de MSIL et du fonctionnement du CLR est requise, et il est nettement plus difficile d’acquérir ce savoir que de se former à CodeDOM ou AspectJ. Il sera plus vraisemblable de trouver ce type de code à la base de frameworks open source ou commerciaux. Et pour revenir encore une fois à l’AOP, il n’est pas farfelu d’imaginer que quelqu’un se lancera un jour dans le développement d’un framework AOP avec runtime weaving en utilisant ce type de technique. Avis aux amateurs !
Difficile à déboguer. Le meilleur moyen que j’ai trouvé est de coder le code « cible » (celui que l’on cherche à générer) en C# ou VB, le compiler, puis désassembler son MSIL, qui sert ensuite de référence. On le compare ensuite au MSIL produit par émission jusqu’à ce que les deux soient similaires.
Enfin, le tableau est légèrement assombri par les deux considérations suivantes :
Création : de la même manière qu’avec les proxies dynamiques Java, l’instanciation des objets ne se fait plus par l’opérateur new, mais par la classe factory. Cette technique est donc légèrement invasive, tout comme le sont les solutions à base de CodeDOM ou d’extension de RealProxy.
Performance : l’invocation concrète des méthodes de l’objet de base se fait par introspection. D’autre part, de nombreuses opérations de boxing et d’unboxing sont nécessaires pour tous les paramètres d’un type par valeur : en effet, ils sont passés à Invoke() sous la forme d’un tableau de type object[]. L’impact sur la performance n’est donc pas négligeable en théorie. Toutefois il y a de fortes chances que cette technique soit plus légère que l’héritage de RealProxy. En effet, on économise la création d’une structure IMessage ainsi que la construction de la chaîne des puits.
Auteur : Sébastien Bouchet
Copyright © DotNetGuru - Avril 2003
|
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