[JAVA] [Français] Tutoriel Byte Buddy

https://bytebuddy.net/#/tutorial traduction google du tutoriel bytebuddy

Why runtime code generation?

Le langage Java est livré avec un système de type relativement strict. En Java, toutes les variables et tous les objets doivent être d'un type particulier et vous obtiendrez toujours une erreur si vous essayez d'attribuer un type incompatible. Ces erreurs sont généralement émises par le compilateur Java, ou au moins par le runtime Java, lorsque vous transtypez un type de manière incorrecte. Un tel typage rigoureux est souvent souhaitable, par exemple lors de l'écriture d'applications métier. Les domaines d'entreprise peuvent généralement être décrits de manière explicite, toute entrée de domaine représentant son propre type. Cela vous permet d'utiliser Java pour créer des applications très lisibles et robustes où des erreurs sont détectées à proximité de la source. En particulier, le système de types de Java est responsable de la popularité de Java dans la programmation d'entreprise.

Cependant, en imposant ce système de type strict, Java impose des restrictions qui limitent la portée du langage dans d'autres domaines. Par exemple, lors de l'écriture d'une bibliothèque générique qui doit être utilisée par d'autres applications Java, nous ne connaissons pas ces types lorsque notre bibliothèque est compilée, donc votre application Impossible de référencer le type défini dans. La bibliothèque de classes Java est fournie avec une API de réflexion pour appeler des méthodes ou accéder à des champs dans le code inconnu de l'utilisateur. Vous pouvez utiliser l'API de réflexion pour introspecter des types inconnus, des méthodes d'appel et des champs d'accès. Malheureusement, l'utilisation de l'API de réflexion présente deux inconvénients majeurs.

C'est là que la génération de code d'exécution peut nous aider. Cela vous permet d'émuler certaines fonctionnalités qui ne sont normalement accessibles que lors de la programmation dans un langage dynamique, sans interrompre la vérification de type statique de Java. De cette façon, vous pouvez maximiser le meilleur des deux mondes et améliorer encore les performances d'exécution. Pour mieux comprendre ce problème, examinons l'exemple d'implémentation de la bibliothèque de sécurité au niveau de la méthode mentionnée ci-dessus.

Writing a security library

Les applications métier peuvent se développer et il peut être difficile d'avoir une vue d'ensemble de la pile d'appels au sein de l'application. Cela peut être un problème si vous avez des méthodes importantes dans votre application qui ne doivent être appelées que sous certaines conditions. Imaginez une application métier qui implémente une fonction de réinitialisation qui vous permet de tout supprimer de la base de données de votre application.

class Service {
  void deleteEverything() {
    // delete everything ...
  }
}

De telles réinitialisations ne doivent, bien entendu, être effectuées que par l'administrateur et ne doivent jamais être effectuées par l'utilisateur moyen de notre application. En analysant notre code source, bien sûr, nous pouvons être sûrs que cela ne se produit pas. Mais nous pouvons nous attendre à ce que notre application se développe et évolue à l'avenir. Par conséquent, nous devons implémenter un modèle de sécurité plus strict dans lequel les appels de méthode sont protégés par des contrôles explicites sur l'utilisateur actuel de l'application. En général, utilisez une infrastructure de sécurité pour empêcher cette méthode d'être appelée par une personne autre que l'administrateur.

À cette fin, supposons que vous utilisez un cadre de sécurité avec une API publique, comme ceci:

@Retention(RetentionPolicy.RUNTIME)
@interface Secured {
  String user();
}
 
class UserHolder {
  static String user;
}
 
interface Framework {
  <T> T secure(Class<T> type);
}

Ce framework vous oblige à utiliser l'annotation Secured pour marquer les méthodes qui ne sont accessibles qu'à des utilisateurs spécifiques. ʻUserHolderest utilisé pour définir globalement quel utilisateur est actuellement connecté à l'application. L'interfaceFrameworkpermet la création d'instances protégées en appelant le constructeur par défaut d'un type donné. Bien sûr, ce cadre est très simple, mais en principe, c'est ainsi qu'un cadre de sécurité, tel que le populaire Spring Security, fonctionne. Une caractéristique de ce cadre de sécurité est qu'il conserve le type d'utilisateur. En contractant notre interfaceFramework, nous nous engageons à renvoyer une instance de tout type T` que l'utilisateur reçoit. Cela permet à l'utilisateur d'interagir avec son propre type comme si le cadre de sécurité n'existait pas. Dans un environnement de test, les utilisateurs peuvent même créer des instances non protégées de leur type et utiliser ces instances à la place d'instances protégées. Vous conviendrez que c'est vraiment utile! De tels frameworks sont connus pour interagir avec les POJO (objets Java ordinaires). C'est un terme inventé pour décrire un cadre non intrusif qui n'impose pas son propre type à l'utilisateur.

Pour l'instant, nous savons que le type passé à Framework est uniquement T = Service, et que la méthode deleteEverything est annotée avec@Secured ("ADMIN"). Imaginer. De cette façon, vous pouvez facilement implémenter une version protégée de ce type particulier en la sous-classant simplement.

class SecuredService extends Service {
  @Override
  void deleteEverything() {
    if(UserHolder.user.equals("ADMIN")) {
      super.deleteEverything();
    } else {
      throw new IllegalStateException("Not authorized");
    }
  }
}

Vous pouvez utiliser cette classe supplémentaire pour implémenter le framework comme suit:

class HardcodedFrameworkImpl implements Framework {
  @Override
  public <T> T secure(Class<T> type) {
    if(type == Service.class) {
      return (T) new SecuredService();
    } else {
      throw new IllegalArgumentException("Unknown: " + type);
    }
  }
}

Bien entendu, cette implémentation n'est pas très utile. La signature de la méthode sécurisée suggérait que cette méthode pouvait fournir n'importe quel type de sécurité, mais en réalité elle lève une exception si quelque chose d'autre qu'un service connu se produit. Cela nécessitera également que notre bibliothèque de sécurité connaisse ce type de service particulier lorsque la bibliothèque est compilée. De toute évidence, ce n'est pas une solution viable pour mettre en œuvre le cadre. Alors, comment pouvons-nous résoudre ce problème? Ceci est un didacticiel de bibliothèque de génération de code, vous pouvez donc deviner la réponse. Si nécessaire, créez une sous-classe au moment de l'exécution lorsque la classe Service devient visible pour le cadre de sécurité en appelant la méthode secure. Lors de la génération de code, vous pouvez prendre un type donné, le sous-classer au moment de l'exécution et remplacer la méthode que vous souhaitez protéger. Dans ce cas, nous remplaçons toutes les méthodes annotées avec @ Secured et lisons l'utilisateur requis à partir de la propriété ʻuser` de l'annotation. De nombreux frameworks Java populaires sont implémentés de la même manière.

General information

Utilisez la génération de code avec soin avant de tout apprendre sur la génération de code et Byte Buddy. Les types Java sont assez spéciaux pour les JVM et ne sont souvent pas récupérés. Par conséquent, n'abusez pas de la génération de code et n'utilisez le code généré que si le code généré est la seule solution pour résoudre le problème. Cependant, si vous devez étendre un type inconnu, comme dans l'exemple précédent, la génération de code est probablement la seule option. Les cadres pour la sécurité, la gestion des transactions, le mappage relationnel objet ou la simulation sont des utilisateurs typiques des bibliothèques de génération de code.

Bien entendu, Byte Buddy n'est pas la première bibliothèque à générer du code sur une JVM. Cependant, Byte Buddy pense connaître certaines astuces qui ne sont pas applicables dans d'autres frameworks. L'objectif général de Byte Buddy est de travailler de manière déclarative en se concentrant à la fois sur son langage spécifique à son domaine et sur l'utilisation d'annotations. Nous savons que d'autres bibliothèques de génération de code pour JVM ne fonctionneront pas de cette façon. Néanmoins, vous voudrez peut-être examiner d'autres cadres de génération de code pour savoir lequel fonctionne le mieux pour vous. En particulier, les bibliothèques suivantes sont répandues dans le domaine Java.

Évaluez le cadre par vous-même, mais nous pensons que Byte Buddy offre les fonctionnalités et les commodités que vous trouveriez autrement en vain. Byte Buddy est livré avec un langage expressif spécifique au domaine qui vous permet de créer des classes d'exécution hautement personnalisées en écrivant du code Java simple ou en utilisant une saisie forte dans votre propre code. Faire. Dans le même temps, Byte Buddy est hautement personnalisable et ne limite pas les fonctionnalités qui sortent de la boîte. Si vous le souhaitez, vous pouvez également définir un code d'octet personnalisé pour la méthode implémentée. Mais vous pouvez faire beaucoup sans creuser dans le framework sans savoir ce qu'est le byte code et comment il fonctionne. Par exemple, avez-vous vu Hello World? Un exemple: Byte Buddy est aussi simple à utiliser.

Bien sûr, les API confortables ne sont pas les seuls éléments à prendre en compte lors du choix d'une bibliothèque de génération de code. Pour de nombreuses applications, les caractéristiques d'exécution du code généré sont susceptibles de déterminer le meilleur choix. En outre, le temps d'exécution pour créer une classe dynamique peut être un problème au-delà du temps d'exécution du code généré lui-même. Nous prétendons être les plus rapides Il est aussi simple que difficile de fournir une métrique valide pour la vitesse de la bibliothèque. Néanmoins, je voudrais fournir une telle métrique comme une orientation de base. Cependant, gardez à l'esprit que ces résultats ne se traduisent pas nécessairement par des cas d'utilisation spécifiques où des mesures individuelles doivent être mises en œuvre.

Avant de parler de métriques, examinons les données brutes. Le tableau suivant indique le temps d'exécution moyen en nanosecondes pour les opérations dont les écarts types sont entre accolades.

baseline Byte Buddy cglib Javassist Java proxy
trivial class creation 0.003 (0.001) 142.772 (1.390) 515.174 (26.753) 193.733 (4.430) 70.712 (0.645)
interface implementation 0.004 (0.001) 1'126.364 (10.328) 960.527 (11.788) 1'070.766 (59.865) 1'060.766 (12.231)
stub method invocation 0.002 (0.001) 0.002 (0.001) 0.003 (0.001) 0.011 (0.001) 0.008 (0.001)
class extension 0.004 (0.001) 885.983 (7.901) 5'408.329 (52.437) 1'632.730 (52.737) 683.478 (6.735)
super method invocation 0.004 (0.001) 0.004 (0.001) 0.004 (0.001) 0.021 (0.001) 0.025 (0.001) -

Comme les compilateurs statiques, les bibliothèques de génération de code font face à un compromis entre la génération rapide de code et la génération rapide de code. Lors du choix entre ces objectifs contradictoires, Byte Buddy se concentre principalement sur la génération de code avec un temps d'exécution minimal. La création et la manipulation de types ne sont généralement pas une procédure courante dans aucun programme et n'affectent pas de manière significative les applications de longue durée. En particulier, le chargement des classes et des classes d'instrumentation sont les étapes les plus chronophages et les plus inévitables lors de l'exécution d'un tel code.

Le premier benchmark du tableau ci-dessus mesure le temps d'exécution de la bibliothèque pour sous-classer ʻObject` sans implémenter ou remplacer la méthode. Cela donne l'impression de la surcharge générale d'une bibliothèque dans la génération de code. Dans ce benchmark, le proxy Java fonctionne mieux que les autres bibliothèques, avec des optimisations qui ne sont possibles qu'en supposant que l'interface est toujours étendue. Byte Buddy vérifie également les types génériques et les classes d'annotations et déclenche des exécutions supplémentaires. Cette surcharge de performance se retrouve également dans d'autres benchmarks pour la création de classes. Benchmark (2a) montre le temps d'exécution mesuré pour créer (et charger) une classe qui implémente une interface unique avec 18 méthodes, et (2b) montre les méthodes générées pour cette classe. Indique l'heure d'exécution. De même, (3a) montre un benchmark pour étendre une classe avec les mêmes 18 méthodes implémentées. Byte Buddy propose deux benchmarks. Cela est dû aux optimisations possibles pour les intercepteurs qui exécutent toujours des superméthodes. Au détriment du temps pendant la création de classe, le temps d'exécution d'une classe créée avec Byte Buddy atteint généralement la ligne de base. Autrement dit, l'instrumentation n'entraîne aucune surcharge. Gardez à l'esprit que Byte Buddy est meilleur que les autres bibliothèques de génération de code lors de la création de classes lorsque le traitement des métadonnées est désactivé. Cependant, étant donné que le temps d'exécution de la génération de code est très court par rapport au temps d'exécution total du programme, un tel opt-out n'est pas disponible car il offre peu de performances au détriment de la complication du code de la bibliothèque.

Enfin, notre métrique mesure les performances du code Java précédemment optimisé par le compilateur Just-in-Time de la JVM (http://en.wikipedia.org/wiki/Just-in-time_compilation). Veuillez noter en particulier. Si le code ne s'exécute qu'occasionnellement, les performances seront pires que les métriques ci-dessus ne le suggèrent. Cependant, dans ce cas, les performances du code sont moins importantes. Le code de cette métrique est distribué avec Byte Buddy afin que vous puissiez exécuter ces métriques sur votre propre ordinateur et ajuster les nombres ci-dessus en fonction de la puissance de traitement de votre machine. Pour cette raison, considérez les nombres ci-dessus comme une mesure relative comparant différentes bibliothèques, plutôt qu'une interprétation absolue. Au fur et à mesure que vous développez votre Byte Buddy, vous devez surveiller ces mesures pour éviter une dégradation des performances lorsque vous ajoutez de nouvelles fonctionnalités.

Dans le prochain didacticiel, nous vous expliquerons les fonctionnalités de Byte Buddy. Commencez par ses fonctionnalités plus générales que la plupart des utilisateurs sont les plus susceptibles d'utiliser. Après cela, nous explorerons de plus en plus de sujets avancés et donnerons une brève introduction au bytecode Java et aux formats de fichiers de classe. Et ne vous découragez pas si vous passez rapidement au contenu qui suit. Vous pouvez faire presque tout sans avoir à comprendre les détails de JVM en utilisant l'API standard de Byte Buddy. Lisez la suite pour en savoir plus sur l'API standard.

Creating a class

Les types créés par Byte Buddy sont émis par une instance de la classe ByteBuddy. Appelez simplement new ByteBuddy () pour créer une nouvelle instance et vous êtes prêt à partir. J'espère que vous utilisez un environnement de développement où vous obtenez des suggestions sur les méthodes que vous pouvez appeler sur un objet donné. De cette façon, vous pouvez utiliser l'EDI pour guider le processus tout en évitant de rechercher manuellement l'API de classe dans le javadoc de Byte Buddy. Comme mentionné précédemment, Byte Buddy fournit un langage spécifique à un domaine visant à le rendre aussi lisible que possible par l'homme. Par conséquent, les astuces IDE vous permettront presque toujours d'aller dans la bonne direction. Mais cela suffit, créons la première classe lors de l'exécution d'un programme Java.

DynamicType.Unloaded<?> dynamicType = new ByteBuddy()
  .subclass(Object.class)
  .make();

De toute évidence, l'exemple de code ci-dessus crée une nouvelle classe qui étend le type ʻObject. Ce type créé dynamiquement est équivalent à une classe Java qui étend simplement ʻObject sans implémenter explicitement une méthode, un champ ou un constructeur. Vous avez peut-être remarqué que vous n'avez même pas nommé les types générés dynamiquement, ce qui est généralement nécessaire lors de la définition des classes Java. Bien sûr, vous pouvez facilement nommer votre type explicitement:

DynamicType.Unloaded<?> dynamicType = new ByteBuddy()
  .subclass(Object.class)
  .name("example.Type")
  .make();

Mais que se passe-t-il sans nom explicite? Byte Buddy vit sur Beyond Customs (http://en.wikipedia.org/wiki/Convention_over_configuration) et fournit des paramètres par défaut pratiques. Pour les noms de type, le paramètre Byte Buddy par défaut fournit une NamingStrategy qui crée aléatoirement un nom de classe basé sur le nom de la superclasse de type dynamique. De plus, le nom est défini comme étant dans le même package que la superclasse, de sorte que les méthodes privées de package de superclasse directe sont toujours reconnues comme des types dynamiques. Par exemple, si vous sous-classez le type ʻexample.Foo, le nom généré sera quelque chose comme ʻexample.Foo $$ ByteBuddy $$ 1376491271. Ici, la séquence numérique est aléatoire. Il y a une exception à cette règle lors du sous-classement d'un type d'un package java.lang qui a un type tel que ʻObject. Le modèle de sécurité Java n'autorise pas les types personnalisés dans cet espace de noms. Par conséquent, le schéma de dénomination par défaut préfixe ces noms de type avec net.bytebuddy.renamed`.

Ce comportement par défaut peut ne pas vous convenir. De plus, grâce aux conventions relatives aux principes de configuration, vous pouvez modifier le comportement par défaut à tout moment selon vos besoins. C'est là que la classe ByteBuddy a été introduite. Créez les paramètres par défaut en créant une nouvelle instance de ByteBuddy (). Vous pouvez le personnaliser selon vos besoins individuels en appelant des méthodes avec ce paramètre. Essayons ça:

DynamicType.Unloaded<?> dynamicType = new ByteBuddy()
  .with(new NamingStrategy.AbstractBase() {
    @Override
    public String subclass(TypeDescription superClass) {
        return "i.love.ByteBuddy." + superClass.getSimpleName();
    }
  })
  .subclass(Object.class)
  .make();

Dans l'exemple de code ci-dessus, nous avons créé un nouveau paramètre dans lequel la stratégie de dénomination de type est différente du paramètre par défaut. La classe anonyme est implémentée pour concaténer simplement la chaîne «i.love.ByteBuddy» avec le nom simple de la classe de base. Par conséquent, lors du sous-classement du type ʻObject, le type dynamique est nommé ʻi.love.ByteBuddy.Object. Soyez prudent lorsque vous créez votre propre schéma de dénomination. Les machines virtuelles Java utilisent des noms pour distinguer les types, nous voulons donc éviter les conflits de noms. Si vous avez besoin de personnaliser le comportement de nommage, vous pouvez utiliser le NamingStrategy.SuffixingRandom intégré à Byte Buddy pour le personnaliser afin d'inclure des préfixes plus significatifs pour votre application que les valeurs par défaut.

Domain specific language and immutability

Après avoir vu le langage spécifique au domaine de Byte Buddy en action, nous devons jeter un coup d'œil sur la façon dont ce langage est implémenté. Un détail que vous devez savoir sur l'implémentation est que le langage est construit autour d'objets immuables (http://en.wikipedia.org/wiki/Immutable_object). En fait, presque toutes les classes qui existent dans l'espace de noms Byte Buddy sont immuables, mais dans certains cas, il n'était pas possible d'immuable le type. Nous vous recommandons de suivre ce principe lors de l'implémentation de fonctionnalités personnalisées dans Byte Buddy.

En ce qui concerne l'immuabilité mentionnée ci-dessus, soyez prudent lors de la configuration d'une instance ByteBuddy, par exemple. Par exemple, vous pourriez commettre l'erreur suivante:

ByteBuddy byteBuddy = new ByteBuddy();
byteBuddy.withNamingStrategy(new NamingStrategy.SuffixingRandom("suffix"));
DynamicType.Unloaded<?> dynamicType = byteBuddy.subclass(Object.class).make();

Les types dynamiques doivent être générés en utilisant la stratégie de dénomination personnalisée (probablement) définie new NamingStrategy.SuffixingRandom (" suffix "). L'appel de la méthode withNamingStrategy au lieu de modifier l'instance stockée dans la variable byteBuddy renvoie une instance ByteBuddy personnalisée, mais celle-ci est perdue. Par conséquent, les types dynamiques sont créés en utilisant la configuration par défaut créée en premier.

Redefining and rebasing existing classes

Jusqu'à présent, nous vous avons montré comment utiliser Byte Buddy pour créer des sous-classes de classes existantes. Cependant, vous pouvez également utiliser la même API pour étendre une classe existante. Ces améliorations sont disponibles en deux versions différentes.

type redefinition

Lors de la redéfinition d'une classe, Byte Buddy vous permet de modifier une classe existante en ajoutant des champs et des méthodes ou en remplaçant une implémentation de méthode existante. Cependant, les implémentations de méthode existantes seront perdues si elles sont remplacées par une autre implémentation. Par exemple, si vous redéfinissez le type suivant

class Foo {
  String bar() { return "bar"; }
}

Puisque la méthode bar retourne" qux ", les informations que cette méthode a renvoyées à l'origine` "bar" ʻest complètement perdues.

type rebasing

Lorsque vous rebasez une classe, Byte Buddy conserve toutes les implémentations de méthode de la classe rebasée. Au lieu de rejeter la méthode remplacée comme lors d'une redéfinition de type, Byte Buddy copie toutes ces implémentations de méthode dans une méthode privée renommée avec une signature compatible. .. De cette manière, l'implémentation n'est pas perdue et la méthode rebasée peut continuer à appeler le code d'origine en appelant ces méthodes renommées. Ainsi, la classe ci-dessus «Foo» peut être rebasée comme suit:

class Foo {
  String bar() { return "foo" + bar$original(); }
  private String bar$original() { return "bar"; }
}

Les informations que la méthode bar a renvoyées à l'origine" bar " sont stockées dans une autre méthode et restent accessibles. Lors du rebasage d'une classe, Byte Buddy gère toutes les définitions de méthode, par exemple lors de la définition de sous-classes. Autrement dit, lorsque vous essayez d'appeler l'implémentation superméthode d'une méthode rebase, la méthode rebase est appelée. Mais au lieu de cela, il aplatit finalement cette superclasse virtuelle au type rebasé montré ci-dessus.

Le rebasage, la redéfinition ou le sous-classement est effectué à l'aide de la même API définie par l'interface DynamicType.Builder. De cette manière, par exemple, vous pouvez définir une classe en tant que sous-classe et modifier ultérieurement cette définition pour représenter la classe rebasée à la place. Cela peut être réalisé en changeant simplement un mot dans la langue spécifique au domaine de Byte Buddy. Cette méthode applique l'une des approches possibles

new ByteBuddy().subclass(Foo.class)
new ByteBuddy().redefine(Foo.class)
new ByteBuddy().rebase(Foo.class)

Le reste du processus de définition décrit dans le reste de ce didacticiel est transparent. Les définitions de sous-classes sont un concept familier pour les développeurs Java, donc toutes les descriptions et exemples suivants de langages spécifiques au domaine de Byte Buddy sont affichés en créant des sous-classes. Cependant, gardez à l'esprit que toutes les classes peuvent être définies de la même manière en redéfinissant ou en rebasant.

Loading a class

Jusqu'à présent, je n'ai défini et créé que des types dynamiques, mais je ne les ai pas utilisés. Les types créés par Byte Buddy sont représentés par une instance de DynamicType.Unloaded. Comme leur nom l'indique, ces types ne sont pas chargés dans les machines virtuelles Java. Au lieu de cela, les classes créées par Byte Buddy sont représentées au format binaire dans le format de fichier de classe Java. Ainsi, ce que vous voulez faire avec le type généré dépend de vous. Par exemple, vous pouvez exécuter Byte Buddy à partir d'un script de génération qui génère uniquement les classes que vous souhaitez étendre avant de déployer votre application Java. Pour cela, la classe DynamicType.Unloaded vous permet d'extraire un tableau d'octets qui représente un type dynamique. Pour plus de commodité, ce type a également une méthode saveIn (File) qui vous permet d'enregistrer la classe dans un dossier spécifique. Vous pouvez également injecter la classe dans un fichier jar existant avec ʻinject (File) `.

L'accès direct au format binaire de la classe est facile, mais malheureusement le chargement de type est plus compliqué. En Java, toutes les classes sont chargées en utilisant ClassLoader. Un exemple d'un tel chargeur de classe est le chargeur de classe bootstrap responsable du chargement des classes fournies dans la bibliothèque de classes Java. Le chargeur de classe système, quant à lui, est responsable du chargement de la classe dans le chemin de classe de votre application Java. De toute évidence, aucun de ces chargeurs de classes existants n'est conscient des classes dynamiques que nous avons créées. Pour surmonter cela, nous devons trouver d'autres possibilités de chargement de classes générées par l'exécution. Byte Buddy propose des solutions dans une variété d'approches dès la sortie de la boîte.

Malheureusement, l'approche ci-dessus présente les deux inconvénients.

Après avoir créé DynamicType.Unloaded, ce type peut être chargé en utilisant ClassLoadingStrategy. Si aucune stratégie de ce type n'est fournie, Byte Buddy déduira une telle stratégie en fonction du chargeur de classe fourni, sinon uniquement pour les chargeurs de classe bootstrap qui ne peuvent pas injecter de types en utilisant la réflexion par défaut. Créez un nouveau chargeur de classe pour. Byte Buddy propose plusieurs stratégies de chargement de classe prêtes à l'emploi. Chaque stratégie suit l'un des concepts ci-dessus. Ces stratégies sont définies dans ClassLoadingStrategy.Default. La stratégie WRAPPER crée un nouveau wrapping ClassLoader. Ici, la stratégie CHILD_FIRST crée un chargeur de classe similaire avec l'enfant ayant la première sémantique. Les stratégies «WRAPPER» et «CHILD_FIRST» sont également disponibles dans la version dite manifeste, où la forme binaire du type est conservée après le chargement de la classe. Ces versions alternatives vous permettent d'accéder à la représentation binaire des classes du chargeur de classe via la méthode ClassLoader :: getResourceAsStream. Cependant, gardez à l'esprit que pour ce faire, ces chargeurs de classes doivent conserver une référence à la représentation binaire complète des classes qui consomment de l'espace sur le tas JVM. Par conséquent, si vous prévoyez d'accéder réellement au format binaire, utilisez uniquement la version du manifeste. La stratégie ʻINJECTIONfonctionne par réflexion et n'est évidemment pas disponible dans la version du manifeste car elle ne change pas la sémantique de la méthodeClassLoader :: getResourceAsStream`.

Voyons en fait le chargement d'une telle classe.

Class<?> type = new ByteBuddy()
  .subclass(Object.class)
  .make()
  .load(getClass().getClassLoader(), ClassLoadingStrategy.Default.WRAPPER)
  .getLoaded();

Dans l'exemple ci-dessus, nous avons créé et chargé la classe. Comme mentionné précédemment, nous avons utilisé la stratégie WRAPPER pour charger la classe appropriée dans la plupart des cas. Enfin, la méthode getLoaded renvoie une instance de la classe Java qui représente la classe dynamique actuellement chargée.

Lors du chargement d'une classe, une stratégie de chargement de classe prédéfinie est exécutée en appliquant le ProtectionDomain du contexte d'exécution courant. Alternativement, toutes les stratégies par défaut fournissent une spécification de domaine de protection explicite en appelant la méthode withProtectionDomain. La définition d'un domaine de protection explicite est importante lors de l'utilisation de Security Manager ou lorsque vous travaillez avec des classes définies dans des fichiers JAR signés.

Reloading a class

La section précédente a décrit comment Byte Buddy peut être utilisé pour redéfinir ou rebaser des classes existantes. Cependant, il n'est pas possible de garantir qu'une classe particulière n'a pas encore été chargée pendant l'exécution du programme Java. (De plus, Byte Buddy ne prend actuellement que les classes de chargement comme arguments. Dans les versions futures, il fonctionnera de la même manière que le déchargement des classes en utilisant les API existantes) même après leur chargement. Cette fonctionnalité est rendue accessible par ClassReloadingStrategy de Byte Buddy. Redéfinissons la classe Foo pour démontrer cette stratégie.

class Foo {
  String m() { return "foo"; }
}
 
class Bar {
  String m() { return "bar"; }
}

Vous pouvez facilement redéfinir la classe «Foo» en «Bar» en utilisant Byte Buddy. Avec HotSwap, cette redéfinition s'applique également aux instances existantes.

ByteBuddyAgent.install();
Foo foo = new Foo();
new ByteBuddy()
  .redefine(Bar.class)
  .name(Foo.class.getName())
  .make()
  .load(Foo.class.getClassLoader(), ClassReloadingStrategy.fromInstalledAgent());
assertThat(foo.m(), is("bar"));

HotSwap n'est accessible qu'à l'aide de ce que l'on appelle l'agent Java. Ces agents peuvent être installés en les spécifiant à l'aide du paramètre -javaagent lors du démarrage d'une machine virtuelle Java. L'argument de paramètre est le fichier jar de l'agent Byte Buddy que vous pouvez télécharger à partir de la page Byte Buddy Bintray. Cependant, si l'application Java est exécutée à partir d'une installation JDK d'une machine virtuelle Java, Byte Buddy peut toujours charger l'agent Java après avoir lancé l'application avec ByteBuddyAgent.installOnOpenJDK (). C'est une méthode très pratique, car la redéfinition de classe est principalement utilisée pour implémenter des outils et des tests. À partir de Java 9, il est également possible d'installer l'agent au moment de l'exécution sans installer le JDK.

Une chose qui peut sembler contre-intuitive dans l'exemple ci-dessus est le fait que Byte Buddy est chargé de redéfinir le type Bar, où le type Foo est finalement redéfini. Les machines virtuelles Java identifient les types par nom et chargeur de classe. Par conséquent, en renommant Bar en Foo et en appliquant cette définition, nous redéfinirons éventuellement le type renommé de Bar. Bien sûr, il est également possible de redéfinir Foo directement sans renommer différents types.

Cependant, l'utilisation de la fonction HotSwap de Java présente un inconvénient majeur. L'implémentation actuelle de HotSwap nécessite que la classe redéfinie applique le même schéma de classe avant et après la redéfinition de classe. Autrement dit, vous ne pouvez pas ajouter de méthodes ou de champs lors du rechargement d'une classe. Nous avons déjà vu que Byte Buddy définit une copie de la méthode originale de la classe rebase afin que la classe rebase ne fonctionne pas avec ClassReloadingStrategy. De plus, la redéfinition de classe ne fonctionne pas pour les classes qui ont des méthodes d'initialisation de classe explicites (blocs statiques dans la classe). En effet, cette méthode d'initialisation doit également être copiée dans la méthode supplémentaire. Cependant, il est prévu d'étendre HotSwap à l'avenir, et Byte Buddy est prêt à utiliser cette fonctionnalité dès qu'elle fonctionnera. En attendant, le support HotSwap de Byte Buddy peut être utilisé pour les cas d'angle que vous trouvez utiles. Sinon, le déplacement et la redéfinition de classe peuvent être une fonctionnalité utile, par exemple, lors de l'extension d'une classe existante à partir d'un script de construction.

Working with unloaded classes

Avec cette prise de conscience des limites de la fonctionnalité HotSwap de Java, on pourrait penser que la seule application significative des instructions de rebase et de redéfinition est au moment de la construction. En appliquant des opérations au moment de la construction, vous pouvez affirmer que la classe traitée ne sera pas chargée avant le chargement de la première classe. C'est simplement parce que ce chargement de classe est effectué sur une autre instance de la JVM. Byte Buddy fonctionne de la même manière pour les classes qui n'ont pas encore été chargées. À cette fin, Byte Buddy fait abstraction de l'API de réflexion de Java afin qu'une instance Class soit représentée en interne, par exemple, par une instance de TypeDescription. En fait, Byte Buddy ne sait gérer que les classes fournies par l'adaptateur qui implémente l'interface TypeDescription. Le gros avantage de cette abstraction est que les informations sur la classe n'ont pas à être fournies par ClassLoader, elles peuvent être fournies par n'importe quelle autre source.

Byte Buddy fournit un moyen standard d'obtenir la TypeDescription d'une classe en utilisant TypePool. Bien entendu, une implémentation par défaut d'un tel pool est également fournie. Cette implémentation TypePool.Default analyse la forme binaire de la classe et la représente comme la TypeDescription requise. Similaire à ClassLoader, il gère également le cache des classes exprimables. Ceci est également personnalisable. Il obtient aussi généralement la forme binaire de la classe de ClassLoader, mais ne lui dit pas de charger cette classe.

Les machines virtuelles Java ne chargent les classes qu'à la première utilisation. En conséquence, vous pouvez redéfinir une classe en toute sécurité, par exemple:

package foo;
class Bar { }

Exécutez-le au démarrage du programme avant d'exécuter tout autre code.

class MyApplication {
  public static void main(String[] args) {
    TypePool typePool = TypePool.Default.ofClassPath();
    new ByteBuddy()
      .redefine(typePool.describe("foo.Bar").resolve(), // do not use 'Bar.class'
                ClassFileLocator.ForClassLoader.ofClassPath())
      .defineField("qux", String.class) // we learn more about defining fields later
      .make()
      .load(ClassLoader.getSystemClassLoader());
    assertThat(Bar.class.getDeclaredField("qux"), notNullValue());
  }
}

Vous pouvez empêcher le chargement de la classe intégrée JVM en chargeant explicitement la classe redéfinie avant qu'elle ne soit utilisée pour la première fois dans une instruction d'assertion. De cette façon, la définition redéfinie de foo.Bar est chargée et utilisée tout au long de l'exécution de l'application. Cependant, lorsque vous utilisez TypePool pour fournir une description, vous ne référencez pas la classe dans le littéral de classe. Si vous utilisiez un littéral de classe pour foo.Bar, la tentative de redéfinition serait invalide car la JVM chargeait cette classe avant que les modifications ne soient apportées pour la redéfinir. De plus, lorsque vous traitez avec des classes déchargées, vous devez spécifier ClassFileLocator où vous pouvez trouver les fichiers de classe pour la classe. L'exemple ci-dessus crée simplement un localisateur de fichiers de classe qui scanne le chemin de classe pour ces fichiers dans une application en cours d'exécution.

Creating Java agents

Au fur et à mesure que les applications se développent et deviennent plus modulaires, l'application de telles transformations à des points de programme spécifiques est, bien sûr, une contrainte lourde à mettre en œuvre. Et il existe une meilleure façon d'appliquer une telle redéfinition de classe à la demande. Utilisez l'agent Java (https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html) pour diriger directement les activités de chargement de classe qui ont lieu dans votre application Java Il est possible d'intercepter. L'agent Java est implémenté sous la forme d'un simple fichier jar avec les points d'entrée spécifiés dans le fichier manifeste de ce fichier jar, comme décrit sous Ressources liées. Avec Byte Buddy, l'implémentation d'un tel agent est facile avec ʻAgentBuilder. En supposant que vous ayez précédemment défini une simple annotation nommée ToString, implémentez la méthode toString pour toutes les classes annotées en implémentant simplement la méthode premain` de l'agent comme suit: Est facile.

class ToStringAgent {
  public static void premain(String arguments, Instrumentation instrumentation) {
    new AgentBuilder.Default()
        .type(isAnnotatedWith(ToString.class))
        .transform(new AgentBuilder.Transformer() {
      @Override
      public DynamicType.Builder transform(DynamicType.Builder builder,
                                              TypeDescription typeDescription,
                                              ClassLoader classloader) {
        return builder.method(named("toString"))
                      .intercept(FixedValue.value("transformed"));
      }
    }).installOn(instrumentation);
  }
}

Suite à l'application de ʻAgentBuilder.Transformerci-dessus, toutes les méthodestoStringde la classe annotée retournent maintenant transformées.DynamicType.Builder` de Byte Buddy sera discuté dans une prochaine section, mais ne vous inquiétez pas pour cette classe pour le moment. Le code ci-dessus est, bien sûr, une application triviale et dénuée de sens. Une bonne utilisation de ce concept en fait un outil puissant pour implémenter facilement une programmation orientée aspect.

Il est également possible d'instrumenter les classes chargées par le chargeur de classe bootstrap lors de l'utilisation de l'agent. Cependant, cela nécessite une certaine préparation. Tout d'abord, le chargeur de classe bootstrap est représenté par une valeur null, il n'est donc pas possible de charger une classe dans ce chargeur de classe en utilisant la réflexion. Cependant, cela peut être nécessaire pour charger la classe d'assistance dans le chargeur de classe de classe de mesure pour prendre en charge l'implémentation de classe. Pour charger des classes dans le chargeur de classe bootstrap, Byte Buddy peut créer des fichiers jar et ajouter ces fichiers au chemin de chargement du chargeur de classe bootstrap. Pour rendre cela possible, ces classes doivent être enregistrées sur le disque. Les dossiers de ces classes peuvent être spécifiés en utilisant la commande ʻenableBootstrapInjection, qui récupère également une instance de l'interface ʻInstrumentation pour ajouter la classe. Toutes les classes d'utilisateurs utilisées par la classe d'instrumentation doivent également être placées dans un chemin de recherche bootstrap possible à l'aide de l'interface d'instrumentation.

Loading classes in Android applications

Android utilise un format de fichier de classe différent, en utilisant un fichier dex qui n'est pas dans la disposition du format de fichier de classe Java. De plus, le runtime ART, qui hérite de la machine virtuelle Dalvik, compile les applications Android en code machine natif avant son installation sur un appareil Android. Par conséquent, Byte Buddy ne peut plus redéfinir ou déplacer des classes à moins que l'application ne soit explicitement déployée avec sa source Java, car il n'y a aucune représentation de code intermédiaire à interpréter. Cependant, Byte Buddy peut définir de nouvelles classes en utilisant DexClassLoader et le compilateur dex intégré. À cette fin, Byte Buddy fournit un module byte-buddy-android qui inclut ʻAndroidClassLoadingStrategy` qui vous permet de charger des classes créées dynamiquement depuis votre application Android. Pour que cela fonctionne, vous avez besoin d'un dossier pour écrire des fichiers temporaires et des fichiers de classe compilés. Ce dossier est interdit par Android Security Manager et ne doit pas être partagé entre différentes applications.

Working with generic types

Byte Buddy gère les types génériques tels que définis par le langage de programmation Java. Les types génériques ne sont pas pris en compte par le runtime Java, qui ne gère que l'effacement des types génériques. Cependant, les types génériques sont toujours intégrés dans n'importe quel fichier de classe Java et exposés par l'API Java Reflection. Par conséquent, il est judicieux d'inclure des informations génériques dans la classe générée, car les informations de type générique peuvent affecter le comportement d'autres bibliothèques et frameworks. L'intégration d'informations de type générique est également importante lorsque la classe est persistante et traitée comme une bibliothèque par le compilateur Java.

Lors du sous-classement d'une classe, de l'implémentation d'une interface ou de la déclaration d'un champ ou d'une méthode, Byte Buddy accepte Java Type au lieu de la Classe effacée. Les types génériques peuvent également être définis explicitement en utilisant TypeDescription.Generic.Builder. Une différence importante entre les types génériques Java pour l'élimination de type réside dans les implications contextuelles des variables de type. Une variable de type avec un nom particulier défini par un type ne représente pas nécessairement le même type si un autre type déclare la même variable de type avec le même nom. Par conséquent, Byte Buddy lie tous les types génériques qui représentent des variables de type dans le contexte du type ou de la méthode généré lorsque l'instance Type est transmise à la bibliothèque.

Byte Buddy insère de manière transparente les méthodes de pont (https://docs.oracle.com/javase/tutorial/java/generics/bridgeMethods.html) lorsque le type est créé. La méthode bridge est résolue par la propriété de l'instance ByteBuddy, MethodGraph.Compiler. Le compilateur de graphe de méthode par défaut se comporte comme un compilateur Java et gère les informations de type générique dans les fichiers de classe. Pour les langages autres que Java, un compilateur de graphes différentiels peut être un bon choix.

Fields and methods

La plupart des types créés dans la section précédente ne définissent pas de champs ou de méthodes. Cependant, en sous-classant ʻObject, la classe créée hérite des méthodes définies par sa superclasse. Vérifiez ce trivia Java et appelez la méthode toString` sur l'instance de type dynamique. Vous pouvez obtenir une instance en appelant le constructeur de la classe que vous avez créée de manière réfléchie.

String toString = new ByteBuddy()
  .subclass(Object.class)
  .name("example.Type")
  .make()
  .load(getClass().getClassLoader())
  .getLoaded()
  .newInstance() // Java reflection API
  .toString();

L'implémentation de la méthode ʻObject # toStringretourne une concaténation du nom de classe pleinement qualifié de l'instance et la représentation hexadécimale du code de hachage de l'instance. Et en fait, appeler la méthodetoString sur l'instance créée renvoie quelque chose comme ʻexample.Type @ 340d1fa5.

Bien sûr, nous ne le faisons pas ici. La principale motivation pour créer des classes dynamiques est la possibilité de définir une nouvelle logique. Commençons par un simple pour montrer comment cela est fait. Remplace la méthode toString et renvoie Hello World!. Au lieu de la valeur par défaut précédente

String toString = new ByteBuddy()
  .subclass(Object.class)
  .name("example.Type")
  .method(named("toString")).intercept(FixedValue.value("Hello World!"))
  .make()
  .load(getClass().getClassLoader())
  .getLoaded()
  .newInstance()
  .toString();

La ligne que j'ai ajoutée au code contient deux instructions dans la langue spécifique au domaine de Byte Buddy. La première instruction est une méthode qui vous permet de sélectionner autant de méthodes que vous souhaitez remplacer. Cette sélection est appliquée en passant ʻElementMatcher, qui agit comme un prédicat pour déterminer s'il faut remplacer chaque méthode remplaçable. Byte Buddy est fourni avec un certain nombre de correspondances de méthode prédéfinies collectées dans la classe ʻElementMatchers. En général, vous devez importer statiquement cette classe pour rendre le code résultant plus naturel à lire. Une telle importation statique a également été envisagée dans l'exemple ci-dessus en utilisant un apparieur de méthode nommé qui sélectionne la méthode par le nom exact. Les matchers de méthode prédéfinis sont configurables. De cette manière, les choix de méthodes peuvent être expliqués plus en détail comme suit:

named("toString").and(returns(String.class)).and(takesArguments(0))

Cette dernière méthode correspond uniquement à cette méthode particulière car elle décrit la méthode toString avec une signature Java complète. Cependant, dans le contexte donné, nous savons qu'il n'y a pas d'autre méthode nommée toString avec une signature différente de sorte que notre correspondance de méthode d'origine est suffisante.

Après avoir sélectionné la méthode toString, la seconde interception d'instruction détermine l'implémentation qui remplace toutes les méthodes de la sélection spécifiée. Pour savoir comment implémenter une méthode, cette instruction nécessite un seul argument de type implémentation. L'exemple ci-dessus utilise l'implémentation FixedValue fournie avec Byte Buddy. Comme le nom de cette classe l'indique, l'implémentation implémente toujours une méthode qui renvoie une valeur spécifique. Un peu plus loin dans cette section, nous discuterons de l'implémentation de FixedValue en détail. Examinons maintenant de plus près la sélection des méthodes.

Jusqu'à présent, nous n'avons intercepté qu'une seule méthode. Dans une application réelle, les choses peuvent devenir plus compliquées et vous voudrez peut-être appliquer différentes règles pour remplacer différentes méthodes. Regardons un exemple d'un tel scénario.

class Foo {
  public String bar() { return null; }
  public String foo() { return null; }
  public String foo(Object o) { return null; }
}
 
Foo dynamicFoo = new ByteBuddy()
  .subclass(Foo.class)
  .method(isDeclaredBy(Foo.class)).intercept(FixedValue.value("One!"))
  .method(named("foo")).intercept(FixedValue.value("Two!"))
  .method(named("foo").and(takesArguments(1))).intercept(FixedValue.value("Three!"))
  .make()
  .load(getClass().getClassLoader())
  .getLoaded()
  .newInstance();

Dans l'exemple ci-dessus, nous avons défini trois règles différentes pour remplacer les méthodes. En examinant le code, nous pouvons voir que la première règle implique la méthode définie par Foo, c'est-à-dire les trois méthodes de la classe exemple. La deuxième règle correspond aux deux méthodes nommées «foo», qui est un sous-ensemble de la sélection précédente. Et la dernière règle ne correspond qu'à la méthode foo (Object). Il s'agit d'une réduction supplémentaire de l'ancien choix. Mais si ce choix est dupliqué, comment Byte Buddy détermine-t-il quelle règle s'applique à quelle méthode?

Byte Buddy organise les règles de remplacement des méthodes dans un format de pile. Autrement dit, chaque fois que vous enregistrez une nouvelle règle pour remplacer une méthode, elle sera poussée vers le haut de cette pile et sera toujours appliquée en premier jusqu'à ce qu'une nouvelle règle soit ajoutée. Dans l'exemple ci-dessus, cela signifie:

Pour cette organisation, vous devez toujours enregistrer un matcher de méthode plus spécifique en dernier. Sinon, l'outil de correspondance de méthode moins spécifique enregistré ultérieurement peut ne pas appliquer la règle définie précédemment. Notez que vous pouvez définir la propriété ʻignoreMethoddans le paramètreByteBuddy`. Une méthode qui correspond bien à ce matcher de méthode ne sera jamais écrasée. Par défaut, Byte Buddy ne remplace pas les méthodes synthétiques.

Dans certains scénarios, vous souhaiterez peut-être définir une méthode de supertype ou une nouvelle méthode qui ne remplace pas l'interface. Ceci est également possible avec Byte Buddy. Pour cela, vous pouvez appeler defineMethod partout où vous pouvez définir une signature. Une fois que vous avez défini la méthode, il vous sera demandé de fournir une implémentation, similaire à la méthode identifiée par le method matcher. Les correspondants de méthode enregistrés après avoir défini une méthode peuvent avoir la priorité sur cette implémentation en raison des principes d'empilement décrits précédemment.

defineField permet à Byte Buddy de définir certains types de champs. En Java, les champs ne sont jamais surchargés, ils sont juste ombrés (http://en.wikipedia.org/wiki/Variable_shadowing). Par conséquent, la correspondance de champ, etc. ne peut pas être utilisée.

Grâce à cette connaissance du choix des méthodes, vous êtes prêt à apprendre comment vous pouvez mettre en œuvre ces méthodes. Pour cela, jetons un œil à l'implémentation prédéfinie de ʻImplementation` fournie avec Byte Buddy. La définition d'une implémentation personnalisée est décrite dans sa propre section, mais est uniquement destinée aux utilisateurs qui ont besoin d'implémenter une méthode très personnalisée.

A closer look at fixed values

L'implémentation de FixedValue est déjà en cours d'exécution. Comme son nom l'indique, la méthode implémentée par FixedValue renvoie simplement l'objet fourni. Les classes peuvent stocker ces objets de deux manières différentes.

Lorsque vous implémentez une méthode avec FixedValue # value (Object), Byte Buddy analyse le type du paramètre et le définit pour être stocké dans un pool de classes dynamiques si possible. Sinon, stockez la valeur dans un champ statique. Cependant, si la valeur est stockée dans le pool de classes, l'instance renvoyée par la méthode sélectionnée peut être d'un ID d'objet différent. Par conséquent, vous pouvez utiliser FixedValue # reference (Object) pour indiquer à Byte Buddy de toujours stocker les objets dans des champs statiques. Cette dernière méthode est surchargée afin que vous puissiez spécifier le nom du champ comme deuxième argument. Sinon, le nom du champ est automatiquement dérivé du code de hachage de l'objet. L'exception à ce comportement est la valeur «null». La valeur «null» n'est jamais stockée dans le champ, mais est simplement représentée par son expression littérale.

Vous vous interrogez peut-être sur la sécurité des types dans ce contexte. Évidemment, vous pouvez définir une méthode qui renvoie une valeur non valide.

new ByteBuddy()
  .subclass(Foo.class)
  .method(isDeclaredBy(Foo.class)).intercept(FixedValue.value(0))
  .make();

Il est difficile d'empêcher cette implémentation invalide par le compilateur dans le système de type Java. Au lieu de cela, Byte Buddy lève ʻIllegalArgumentException lorsque le type est créé, permettant une affectation incorrecte d'entiers aux méthodes qui retournent String`. Byte Buddy fera tout ce qui est en son pouvoir pour s'assurer que tous les types créés sont des types Java légitimes et lancera des exceptions et échouera rapidement lors de la création de types illégaux.

Le comportement d'allocation Byte Buddy est personnalisable. Encore une fois, Byte Buddy ne fournit que des valeurs par défaut légitimes qui imitent le comportement d'affectation du compilateur Java. En conséquence, Byte Buddy vous permet d'assigner des types à n'importe lequel de ses supertypes et considère également le boxing des valeurs primitives ou le déballage de leurs représentations wrapper. Cependant, gardez à l'esprit que Byte Buddy ne prend actuellement pas totalement en charge les types génériques et ne considère que l'élimination de type. Par conséquent, Byte Buddy peut provoquer une pollution du tas (http://en.wikipedia.org/wiki/Heap_pollution). Au lieu d'utiliser un assignateur prédéfini, vous pouvez toujours implémenter votre propre assignateur qui permet la conversion de type qui n'est pas implicitement incluse dans le langage de programmation Java. Vous découvrirez ces implémentations personnalisées dans la dernière section de ce didacticiel. Pour l'instant, je mentionne que vous pouvez définir un tel assignateur personnalisé en appelant withAssigner avec n'importe quelle implémentation FixedValue.

Delegating a method call

Dans de nombreux scénarios, il est bien sûr insuffisant de renvoyer une valeur fixe de la méthode. Pour être plus flexible, Byte Buddy fournit une implémentation MethodDelegation. Cela vous donne une liberté maximale pour répondre aux appels de méthode. La délégation de méthode définit une méthode d'un type créé dynamiquement afin de transférer l'appel vers une autre méthode qui peut être en dehors du type dynamique. De cette manière, la logique des classes dynamiques peut être représentée à l'aide de Java brut, mais la génération de code fournit uniquement une liaison à d'autres méthodes. Avant de plonger dans les détails, jetons un œil à un exemple d'utilisation de MethodDelegation.

class Source {
  public String hello(String name) { return null; }
}
 
class Target {
  public static String hello(String name) {
    return "Hello " + name + "!";
  }
}
 
String helloWorld = new ByteBuddy()
  .subclass(Source.class)
  .method(named("hello")).intercept(MethodDelegation.to(Target.class))
  .make()
  .load(getClass().getClassLoader())
  .getLoaded()
  .newInstance()
  .hello("World");

Cet exemple délègue un appel à la méthode Source # hello (String) à la méthode Target afin que la méthode renvoie Hello World! ʻAu lieu de null. À cette fin, l'implémentation MethodDelegation identifie les méthodes appelables de type Targetet identifie la meilleure correspondance entre ces méthodes. Dans l'exemple ci-dessus, le typeTarget définit une seule méthode statique, ce qui est utile car les paramètres de la méthode, le type de retour et le nom sont les mêmes que ceux de Source # name (String)`. ..

En pratique, décider de la méthode à déléguer est probablement plus compliqué. Alors, comment Byte Buddy décide-t-il comment le faire quand il y a un vrai choix? Pour ce faire, supposons que la classe Target soit définie comme:

class Target {
  public static String intercept(String name) { return "Hello " + name + "!"; }
  public static String intercept(int i) { return Integer.toString(i); }
  public static String intercept(Object o) { return o.toString(); }
}

Comme vous l'avez peut-être remarqué, toutes les méthodes ci-dessus sont appelées interceptions. Byte Buddy n'exige pas que la méthode cible ait le même nom que la méthode source. Nous étudierons bientôt ce problème en détail. Plus important encore, si vous modifiez la définition de Target et exécutez l'exemple précédent, vous verrez que la méthode named (String) est liée à ʻintercept (String) . Mais pourquoi? De toute évidence, la méthode ʻintercept (int) ne peut pas accepter l'argument String de la méthode source, donc il n'y a aucune chance de correspondance. Cependant, ce n'est pas le cas pour la méthode ʻintercept (Object) pouvant être liée. Pour résoudre cette ambiguïté, Byte Buddy imite à nouveau le compilateur Java en choisissant la méthode de liaison avec le type de paramètre le plus spécifique. Rappelez-vous comment le compilateur Java choisit les liaisons pour les méthodes surchargées. Puisque String est plus spécifique que ʻObject, la classe ʻintercept (String)` est finalement sélectionnée parmi trois choix.

Avec les informations à ce jour, vous pourriez penser que l'algorithme de liaison de méthode est une propriété assez rigide. Mais nous n'avons pas encore raconté toute l'histoire. Jusqu'à présent, nous n'avons observé qu'un autre exemple de convention pour définir des principes qui peuvent être modifiés si les valeurs par défaut ne répondent pas aux exigences réelles. En pratique, l'implémentation de MethodDelegation fonctionne avec des annotations qui déterminent à quelle valeur l'annotation d'un paramètre doit être affectée. Cependant, si aucune annotation n'est trouvée, Byte Buddy traite les paramètres comme s'ils étaient annotés avec @ Argument. Cette dernière annotation amène Byte Buddy à affecter le «n» argument de la méthode source à la cible annotée. Si aucune annotation n'est explicitement ajoutée, la valeur de «n» est définie sur l'index du paramètre annoté. Selon cette règle, Byte Buddy le traite comme suit:

void foo(Object o1, Object o2)

Comme si tous les paramètres étaient annotés comme suit:

void foo(@Argument(0) Object o1, @Argument(1) Object o2)

En conséquence, les premier et deuxième arguments de la méthode instrumentée sont attribués à l'intercepteur. Si la méthode interceptée ne déclare pas au moins deux paramètres, ou si le type de paramètre annoté n'est pas affecté à partir du type de paramètre de la méthode instrumentée, la méthode d'intercepteur en question est rejetée.

En plus de l'annotation @ Argument, il existe d'autres annotations prédéfinies que vous pouvez utiliser avec MethodDelegation.

En plus d'utiliser des annotations prédéfinies, Byte Buddy vous permet de définir vos propres annotations en enregistrant un ou plusieurs ParameterBinders. Vous découvrirez ces personnalisations dans la dernière section de ce tutoriel.

En plus des quatre annotations décrites jusqu'à présent, il existe deux autres annotations prédéfinies qui permettent d'accéder à la super-implémentation des méthodes dynamiques. De cette façon, les types dynamiques peuvent ajouter des aspects à leurs classes, comme les appels de méthode de journalisation. Vous pouvez également utiliser l'annotation @ SuperCall pour effectuer un appel de super-implémentation à une méthode en dehors de la classe dynamique, comme illustré dans l'exemple suivant.

class MemoryDatabase {
  public List<String> load(String info) {
    return Arrays.asList(info + ": foo", info + ": bar");
  }
}
 
class LoggerInterceptor {
  public static List<String> log(@SuperCall Callable<List<String>> zuper)
      throws Exception {
    System.out.println("Calling database");
    try {
      return zuper.call();
    } finally {
      System.out.println("Returned from database");
    }
  }
}
 
MemoryDatabase loggingDatabase = new ByteBuddy()
  .subclass(MemoryDatabase.class)
  .method(named("load")).intercept(MethodDelegation.to(LoggerInterceptor.class))
  .make()
  .load(getClass().getClassLoader())
  .getLoaded()
  .newInstance();

À partir de l'exemple ci-dessus, la super méthode est appelée en injectant une instance de Callable dans LoggerInterceptor qui appelle la première implémentation non prioritaire deMemoryDatabase # load (String)à partir de sa méthode d'appel. Est clair. Cette classe d'assistance est appelée ʻAuxiliaryTypedans la terminologie Byte Buddy. Les types auxiliaires sont créés à la demande par Byte Buddy et sont accessibles directement depuis l'interfaceDynamicTypeaprès la création de la classe. En raison de ces types auxiliaires, la création manuelle d'un type dynamique peut créer plusieurs types supplémentaires qui aident à implémenter la classe d'origine. Enfin, notez que l'annotation@ SuperCall peut également être utilisée avec le type Runnable`, qui supprime la valeur de retour de la méthode d'origine.

Vous vous demandez peut-être encore comment ce type auxiliaire peut appeler d'autres types de superméthodes qui sont normalement interdites en Java. En y regardant de plus près, ce comportement est très courant et est similaire au code compilé qui est généré lorsque l'extrait de code source Java suivant est compilé.

class LoggingMemoryDatabase extends MemoryDatabase {
 
  private class LoadMethodSuperCall implements Callable {
 
    private final String info;
    private LoadMethodSuperCall(String info) {
      this.info = info;
    }
 
    @Override
    public Object call() throws Exception {
      return LoggingMemoryDatabase.super.load(info);
    }
  }
 
  @Override
  public List<String> load(String info) {
    return LoggerInterceptor.log(new LoadMethodSuperCall(info));
  }
}

Cependant, vous devrez peut-être appeler la superméthode avec des arguments différents de ceux affectés dans l'appel d'origine à la méthode. Ceci est également possible avec Byte Buddy en utilisant l'annotation @ Super. Cette annotation déclenche la création d'un autre ʻAuxiliaryType` qui étend la superclasse de type dynamique ou l'interface en question. Comme précédemment, le type auxiliaire remplace toutes les méthodes pour appeler la super-implémentation dynamique. De cette manière, vous pouvez implémenter l'exemple d'intercepteur de journalisation de l'exemple précédent pour modifier l'appel réel.

class ChangingLoggerInterceptor {
  public static List<String> log(String info, @Super MemoryDatabase zuper) {
    System.out.println("Calling database");
    try {
      return zuper.load(info + " (logged access)");
    } finally {
      System.out.println("Returned from database");
    }
  }
}

L'instance affectée au paramètre annoté avec «@ Super» a un ID différent de l'instance réelle du type dynamique. Par conséquent, les champs d'instance accessibles par le paramètre ne reflètent pas les champs de l'instance réelle. De plus, les méthodes non surfaçables des instances auxiliaires conservent leur implémentation d'origine, ce qui peut entraîner un comportement absurde lorsqu'elles sont appelées, plutôt que de déléguer leurs appels. Enfin, si un paramètre annoté avec «@ Super» ne représente pas un supertype du type dynamique associé, alors la méthode n'est pas considérée comme une cible de liaison pour cette méthode.

L'annotation @ Super permet l'utilisation de types arbitraires, vous devrez peut-être fournir des informations sur la façon dont ce type peut être configuré. Par défaut, Byte Buddy essaie d'utiliser le constructeur par défaut de la classe. Cela fonctionne toujours pour les interfaces qui étendent implicitement le type ʻObject. Cependant, lors de l'extension d'une superclasse dynamique, cette classe peut ne pas fournir de constructeur par défaut. Dans de tels cas, ou si vous devez utiliser un constructeur spécifique pour créer de tels types auxiliaires, utilisez l'annotation «@ Super» pour définir le type de paramètre comme propriété «constructorParameters» de l'annotation. Ce faisant, vous pouvez identifier différents constructeurs. Ce constructeur est appelé en affectant la valeur par défaut correspondante à chaque paramètre. Vous pouvez également utiliser la stratégie Super.Instantiation.UNSAFE` pour créer des classes qui utilisent les classes internes de Java pour créer des types auxiliaires sans appeler le constructeur. Cependant, cette méthode n'est pas toujours portable pour les JVM non-Oracle et peut ne pas être disponible dans les futures versions de JVM. À ce jour, les classes internes utilisées dans cette méthode d'instanciation dangereuse se trouvent dans presque toutes les implémentations de JVM.

De plus, vous avez peut-être déjà remarqué que le LoggerInterceptor ci-dessus déclare une exception vérifiée. D'un autre côté, la méthode source instrumentée qui appelle cette méthode ne déclare pas Checked ʻException`. Le compilateur Java refuse généralement de compiler de tels appels. Cependant, contrairement au compilateur, le runtime Java ne traite pas les exceptions vérifiées différemment des exceptions non vérifiées et autorise cet appel. Pour cette raison, nous avons décidé d'ignorer les exceptions vérifiées et de leur donner une flexibilité totale dans leur utilisation. Cependant, sachez que lever des exceptions vérifiées non déclarées à partir de méthodes créées dynamiquement peut dérouter les utilisateurs de votre application.

Il y a encore une chose à noter à propos du modèle de délégation de méthode. Le typage statique est idéal pour implémenter des méthodes, mais les types stricts peuvent limiter la réutilisation du code. Pour comprendre pourquoi, considérez l'exemple suivant.

class Loop {
  public String loop(String value) { return value; }
  public int loop(int value) { return value; }
}

Les méthodes de la classe ci-dessus décrivent deux signatures similaires avec des types incompatibles, il n'est donc généralement pas possible d'instrumenter les deux méthodes en utilisant une seule méthode d'intercepteur. Au lieu de cela, vous devez fournir deux méthodes cibles différentes avec des signatures différentes juste pour satisfaire la vérification du type statique. Pour surmonter cette limitation, Byte Buddy vous permet d'annoter des méthodes et des paramètres de méthode avec @ RuntimeType.

class Interceptor {
  @RuntimeType
  public static Object intercept(@RuntimeType Object value) {
    System.out.println("Invoked method with: " + value);
    return value;
  }
}

Vous pouvez désormais fournir une seule méthode d'interception pour les deux méthodes source à l'aide de la méthode cible ci-dessus. Byte Buddy vous permet également de boxer et de déballer des valeurs primitives. Cependant, l'utilisation de @ RunType se fait au détriment de l'abandon de la sécurité de type, donc un mélange de types incompatibles peut entraîner une ClassCastException.

En tant qu'équivalent de @ SuperCall, Byte Buddy est livré avec une annotation @ DefaultCall qui vous permet d'appeler la méthode par défaut au lieu d'appeler la super méthode de la méthode. Une méthode avec cette annotation de paramètre est considérée comme une liaison uniquement si la méthode interceptée est déclarée comme méthode par défaut par une interface directement implémentée par le type instrumenté. De même, l'annotation @ SuperCall empêche la liaison de méthode si la méthode instrumentée ne définit pas une super-méthode non abstraite. Cependant, si vous souhaitez appeler la méthode par défaut sur un type particulier, vous pouvez spécifier la propriété targetType de @ DefaultCall sur une interface particulière. Dans cette spécification, Byte Buddy insère une instance de proxy qui appelle la méthode par défaut pour le type d'interface spécifié si elle existe. Sinon, les méthodes cibles avec des annotations de paramètres ne sont pas considérées comme des délégués. De toute évidence, les appels de méthode par défaut ne sont disponibles que pour les classes définies dans Java 8 et les versions de fichier de classe ultérieures. De même, en plus de l'annotation @ Super, il existe une annotation @ Default qui injecte un proxy pour appeler explicitement une méthode par défaut particulière.

Nous avons déjà mentionné que les annotations personnalisées peuvent être définies et enregistrées dans n'importe quelle MethodDelegation. Byte Buddy est prêt à être utilisé, mais est livré avec une note qui doit encore être explicitement installée et enregistrée. Vous pouvez utiliser l'annotation «@ Pipe» pour transférer un appel de méthode intercepté vers une autre instance. L'annotation @ Pipe n'est pas pré-enregistrée avec MethodDelegation car la bibliothèque de classes Java n'est pas fournie avec un type d'interface approprié avant Java 8 qui définit le type de fonction. Par conséquent, vous devez spécifier explicitement le type en utilisant une seule méthode non statique qui prend ʻObject comme argument et renvoie un autre ʻObject comme résultat. Vous pouvez utiliser des types génériques tant que le type de méthode est lié par le type ʻObject. Bien sûr, si vous utilisez Java 8, le type Function` est une option exécutable. Lorsque vous appelez une méthode avec un argument de paramètre, Byte Buddy convertit le paramètre en type déclaratif de la méthode et appelle la méthode d'interception avec les mêmes arguments que l'appel de méthode d'origine. Avant de regarder l'exemple, définissons un type personnalisé qui peut être utilisé dans Java 5 et supérieur.

interface Forwarder<T, S> {
  T to(S target);
}

Vous pouvez utiliser ce type pour implémenter une nouvelle solution qui enregistre l'accès à la «MemoryDatabase» ci-dessus en transférant les appels de méthode vers une instance existante.

class ForwardingLoggerInterceptor {
 
  private final MemoryDatabase memoryDatabase; // constructor omitted
 
  public List<String> log(@Pipe Forwarder<List<String>, MemoryDatabase> pipe) {
    System.out.println("Calling database");
    try {
      return pipe.to(memoryDatabase);
    } finally {
      System.out.println("Returned from database");
    }
  }
}
 
MemoryDatabase loggingDatabase = new ByteBuddy()
  .subclass(MemoryDatabase.class)
  .method(named("load")).intercept(MethodDelegation.withDefaultConfiguration()
    .withBinders(Pipe.Binder.install(Forwarder.class)))
    .to(new ForwardingLoggerInterceptor(new MemoryDatabase()))
  .make()
  .load(getClass().getClassLoader())
  .getLoaded()
  .newInstance();

Dans l'exemple ci-dessus, l'appel ne sera transféré qu'à une autre instance créée localement. Cependant, l'avantage par rapport au sous-classement d'un type et à l'interception d'une méthode est que vous pouvez étendre une instance existante de cette manière. En outre, vous enregistrez généralement les intercepteurs au niveau de l'instance au lieu d'enregistrer les intercepteurs statiques au niveau de la classe.

Jusqu'à présent, nous avons vu beaucoup d'implémentations de MethodDelegation. Mais avant de continuer, examinons de plus près comment Byte Buddy choisit la méthode cible. Nous avons déjà vu comment Byte Buddy résout la méthode la plus spécifique en comparant les types de paramètres, mais il y en a d'autres. Une fois que Byte Buddy a identifié une méthode candidate appropriée pour la liaison à une méthode source particulière, délègue la solution à la chaîne de ʻAmbiguityResolvers`. Encore une fois, vous êtes libre de mettre en œuvre votre propre solution d'ambiguïté qui peut compléter ou remplacer les valeurs par défaut de Byte Buddy. Sans ces changements, la chaîne de résolution des ambiguïtés tente d'identifier des méthodes cibles uniques en appliquant les règles suivantes dans le même ordre que:

Vous pouvez attribuer des priorités explicites aux méthodes en les annotant avec @ BindingPriority. Si une méthode a une priorité plus élevée qu'une autre, la méthode de priorité plus élevée a toujours la priorité sur la méthode de priorité inférieure. De plus, les méthodes annotées par @ IgnoreForBinding ne sont pas considérées comme des méthodes cibles. Si les méthodes source et cible ont le même nom, cette méthode cible est prioritaire sur les autres méthodes cible avec des noms différents. Si deux méthodes utilisent @ Argument pour lier les mêmes paramètres de la méthode source, la méthode avec le type de paramètre le plus spécifique est considérée. À cet égard, peu importe que les annotations soient fournies explicitement ou implicitement en n'annotant pas les paramètres. L'algorithme de résolution fonctionne de manière similaire à l'algorithme du compilateur Java pour résoudre les appels aux méthodes surchargées. Si les deux types sont également identifiés, la méthode qui lie plus d'arguments est considérée comme la cible. Si vous avez besoin d'assigner des arguments à un paramètre sans tenir compte du type de paramètre pendant cette étape de résolution, vous pouvez le faire en définissant l'attribut bindingMechanic de l'annotation sur BindingMechanic.ANONYMOUS. En outre, les paramètres non cibles doivent être uniques pour chaque valeur d'index de chaque méthode cible pour que l'algorithme de résolution fonctionne. Si la méthode cible a plus de paramètres que les autres méthodes cible, la première a priorité sur la seconde. Jusqu'à présent, nous n'avons délégué que les appels de méthode aux méthodes statiques en nommant une classe spécifique, telle que MethodDelegation.to (Target.class). Cependant, il peut également être délégué à une méthode d'instance ou à un constructeur.

Vous pouvez déléguer un appel de méthode à n'importe quelle méthode d'instance de la classe Target en appelant MethodDelegation.to (new Target ()). Cela inclut les méthodes définies n'importe où dans la hiérarchie des classes de l'instance, y compris les méthodes définies dans la classe ʻObject. Vous voudrez peut-être limiter la gamme de méthodes candidates que vous pouvez faire en appelant filter (ElementMatcher) on MethodDelegation pour appliquer un filtre à la délégation de méthode. Le type ʻElementMatcher est le même que celui utilisé précédemment pour sélectionner les méthodes source dans le langage spécifique au domaine de Byte Buddy. Les instances soumises à la délégation de méthode sont stockées dans des champs statiques. Comme la définition de valeur fixe, cela nécessite la définition TypeInitializer. Au lieu de stocker la délégation dans un champ statique, vous pouvez également définir l'utilisation de n'importe quel champ avec MethodDelegation.toField (String). L'argument spécifie le nom de champ de la destination de transfert pour toutes les délégations de méthode. Assurez-vous d'attribuer une valeur à ce champ avant d'appeler une méthode sur une instance d'une telle classe dynamique. Sinon, la délégation de méthode sera NullPointerException. Vous pouvez utiliser la délégation de méthode pour créer des instances d'un type particulier. L'appel d'une méthode interceptée à l'aide de MethodDelegation.toConstructor (Class) retourne une nouvelle instance du type de cible spécifié. Comme vous venez de l'apprendre, MethodDelegation examine les annotations pour régler sa logique de liaison. Ces annotations sont spécifiques à Byte Buddy, mais cela ne signifie pas que la classe annotée dépendra de Byte Buddy en aucune façon. Au lieu de cela, le runtime Java ignore simplement les types d'annotations qui ne se trouvent pas sur le chemin de classe lorsque la classe est chargée. Cela signifie que Byte Buddy n'est plus nécessaire après la création de la classe dynamique. Cela signifie que vous pouvez charger une classe dynamique et le type qui délègue ses appels de méthode dans un autre processus JVM, même si vous n'avez pas Byte Buddy dans votre chemin de classe.

Il y a des annotations prédéfinies que vous pouvez utiliser avec MethodDelegation, mais je vais les expliquer brièvement. Si vous souhaitez en savoir plus sur ces annotations, vous pouvez trouver plus d'informations dans la documentation de votre code. Ces notes sont les suivantes:

Calling a super method

Comme son nom l'indique, l'implémentation SuperMethodCall peut être utilisée pour appeler une super-implémentation d'une méthode. À première vue, un seul appel à une super-implémentation n'est pas très utile car il duplique simplement la logique existante plutôt que de modifier l'implémentation. Cependant, vous pouvez modifier les annotations de la méthode et leurs paramètres en remplaçant la méthode. Ceci est expliqué dans la section suivante. Cependant, une autre raison d'appeler une super méthode en Java est de définir un constructeur qui doit toujours appeler un autre constructeur de supertype ou de son propre type.

Jusqu'à présent, nous avons simplement supposé que les constructeurs de types dynamiques sont toujours similaires à leurs constructeurs de supertypes directs. A titre d'exemple, nous pouvons appeler

new ByteBuddy()
  .subclass(Object.class)
  .make()

Créez une sous-classe de ʻObject avec un seul constructeur par défaut défini pour appeler simplement le constructeur par défaut de son super-constructeur direct ʻObject, cependant, ce comportement n'est pas spécifié par Byte Buddy. Au lieu de cela, le code ci-dessus est un raccourci à appeler.

new ByteBuddy()
  .subclass(Object.class, ConstructorStrategy.Default.IMITATE_SUPER_TYPE)
  .make()

ConstructorStrategy crée un ensemble de constructeurs prédéfinis pour n'importe quelle classe. En plus de la stratégie ci-dessus, qui copie chaque constructeur visible d'une superclasse directe de type dynamique, il existe trois autres stratégies prédéfinies. Une exception est levée si aucun constructeur de ce type n'existe et s'il n'y en a qu'un qui imite le constructeur public de supertype.

Dans le format de fichier de classe Java, les constructeurs ne sont généralement pas différents des méthodes. Par conséquent, Byte Buddy peut les gérer tels quels. Cependant, le constructeur doit contenir un appel codé en dur à un autre constructeur afin qu'il puisse être accepté par le runtime Java. En conséquence, la plupart des implémentations prédéfinies autres que SuperMethodCall ne peuvent pas créer une classe Java valide lorsqu'elles sont appliquées à un constructeur.

Cependant, vous pouvez définir votre propre constructeur en implémentant un ConstructorStrategy personnalisé en utilisant une implémentation personnalisée, ou en définissant des constructeurs individuels dans le langage spécifique au domaine de Byte Buddy en utilisant la méthode defineConstructor. Je vais. De plus, nous prévoyons d'ajouter de nouvelles fonctionnalités à Byte Buddy pour définir des constructeurs plus complexes tels quels.

Pour le rebasage de classe et la redéfinition de classe, le constructeur, bien sûr, garde la spécification ConstructorStrategy obsolète. Au lieu de cela, afin de copier l'implémentation de ces constructeurs (et méthodes) préservés, vous devez spécifier ClassFileLocator, qui vous permet de rechercher le fichier de classe d'origine qui contient ces définitions de constructeur. Byte Buddy fait de son mieux pour identifier par lui-même l'emplacement du fichier de classe d'origine. Par exemple, en interrogeant le «ClassLoader» correspondant ou en recherchant le chemin de classe de l'application. Les recherches, cependant, peuvent ne pas réussir lors de l'utilisation de chargeurs de classe habituels. Vous pouvez ensuite fournir un ClassFileLocator personnalisé.

Calling a default method

Dans la version 8, le langage de programmation Java a introduit des méthodes par défaut pour les interfaces. En Java, les appels de méthode par défaut sont représentés dans une syntaxe similaire aux appels de superméthodes. La seule différence est que l'appel de méthode par défaut spécifie l'interface qui définit la méthode. Cela est nécessaire car l'appel de méthode par défaut peut être ambigu si les deux interfaces définissent une méthode avec la même signature. Par conséquent, l'implémentation DefaultMethodCall de Byte Buddy reçoit une liste d'interfaces prioritaires. Lors de l'interception d'une méthode, DefaultMethodCall appelle la méthode par défaut sur l'interface mentionnée en premier. À titre d'exemple, supposons que vous souhaitiez implémenter les deux interfaces suivantes:

interface First {
  default String qux() { return "FOO"; }
}
 
interface Second {
  default String qux() { return "BAR"; }
}

Si vous créez une classe qui implémente les deux interfaces et implémente la méthode qux pour appeler la méthode par défaut, cet appel appellera à la fois la méthode par défaut définie dans l'interface First ou Second. Je peux l'exprimer. Cependant, en surchargeant l'interface «First» par «DefaultMethodCall», Byte Buddy reconnaît qu'il doit appeler les méthodes de cette dernière interface au lieu de l'interface alternative.

new ByteBuddy(ClassFileVersion.JAVA_V8)
  .subclass(Object.class)
  .implement(First.class)
  .implement(Second.class)
  .method(named("qux")).intercept(DefaultMethodCall.prioritize(First.class))
  .make()

Les classes Java définies dans les versions de fichiers de classe antérieures à Java 8 ne prennent pas en charge les méthodes par défaut. En outre, il convient de noter que Byte Buddy impose des exigences plus faibles sur la capacité d'appel des méthodes par défaut par rapport au langage de programmation Java. Byte Buddy n'a besoin que d'une interface pour les méthodes par défaut implémentées par les classes les plus spécifiques de la hiérarchie de types. À part le langage de programmation Java, cette interface n'a pas à être l'interface la plus spécifique implémentée par la superclasse. Enfin, si vous ne vous attendez pas à une définition de méthode par défaut ambiguë, vous pouvez toujours utiliser DefaultMethodCall.unambiguousOnly () pour recevoir une implémentation qui lève une exception à la découverte d'un appel de méthode par défaut ambigu. Ce même comportement est priorisé si l'appel de méthode par défaut est ambigu entre les interfaces non prioritaires et qu'aucune interface priorisée qui définit une méthode avec une signature compatible n'est trouvée DefaultMethodCall Il est également affiché sous la forme .

Calling a specific method

Dans certains cas, l'implémentation ci-dessus n'est pas suffisante pour implémenter un comportement plus personnalisé. Par exemple, vous souhaiterez peut-être implémenter une classe personnalisée qui a un comportement explicite. Par exemple, vous pouvez implémenter la classe Java suivante avec un constructeur qui n'a pas de super constructeur avec les mêmes arguments.

public class SampleClass {
  public SampleClass(int unusedValue) {
    super();
  }
}

L'implémentation précédente de SuperMethodCall ne pouvait pas implémenter cette classe car la classe ʻObject ne définit pas de constructeur avec ʻint comme paramètre. Au lieu de cela, vous pouvez appeler explicitement le super constructeur ʻObject`.

new ByteBuddy()
  .subclass(Object.class, ConstructorStrategy.Default.NO_CONSTRUCTORS)
  .defineConstructor(Arrays.<Class<?>>asList(int.class), Visibility.PUBLIC)
  .intercept(MethodCall.invoke(Object.class.getDeclaredConstructor()))
  .make()

Dans le code ci-dessus, j'ai créé une simple sous-classe de ʻObject qui définit un seul constructeur qui prend un seul paramètre ʻint inutilisé. Ce dernier constructeur est implémenté par un appel de méthode explicite au super constructeur ʻObject`.

L'implémentation MethodCall peut également être utilisée lors du passage d'arguments. Ces arguments sont explicitement passés en tant que valeurs, en tant que valeurs pour les champs d'instance qui doivent être définis manuellement ou en tant que valeurs de paramètres spécifiées. L'implémentation permet également à la méthode d'être appelée sur une instance autre que l'instance instrumentée. De plus, vous pouvez créer de nouvelles instances à partir de méthodes interceptées. La documentation de la classe MethodCall fournit des informations détaillées sur ces fonctionnalités.

Accessing fields

Vous pouvez utiliser FieldAccessor pour implémenter des méthodes qui lisent et écrivent des valeurs de champ. Pour être compatible avec cette implémentation, la méthode doit effectuer l'une des opérations suivantes:

Créer une telle implémentation est simple: il suffit d'appeler FieldAccessor.ofBeanProperty (). Cependant, si vous ne souhaitez pas dériver le nom du champ à partir du nom de la méthode, vous pouvez spécifier explicitement le nom du champ en utilisant FieldAccessor.ofField (String). Lors de l'utilisation de cette méthode, le seul argument définit le nom du champ auquel accéder. Si vous le souhaitez, vous pouvez l'utiliser pour définir un nouveau champ même si un tel champ n'existe pas déjà. Lors de l'accès à un champ existant, vous pouvez spécifier le type dans lequel le champ est défini en appelant la méthode ʻin`. En Java, il est légal de définir des champs dans certaines classes de la hiérarchie. Dans ce processus, les champs d'une classe sont masqués par les définitions de champ de ses sous-classes. Sans emplacement explicite pour la classe d'un tel champ, Byte Buddy commencera par la classe la plus spécifique et suivra la hiérarchie des classes pour accéder au premier champ rencontré.

Jetons un coup d'œil à un exemple d'application de FieldAccessor. Dans cet exemple, supposons que vous receviez ʻUserType que vous souhaitez sous-classer au moment de l'exécution. Pour cela, enregistrez un intercepteur pour chaque instance représentée par l'interface. De cette manière, nous pouvons fournir différentes implémentations en fonction de nos besoins réels. Cette dernière implémentation peut être échangée en appelant une méthode de l'interface ʻInterceptionAccessor sur l'instance correspondante. Pour créer une instance de ce type dynamique, je ne veux pas utiliser de réflexion supplémentaire, mais j'appelle une méthode de ʻInstanceCreator` qui agit comme une fabrique d'objets. Les types suivants sont similaires à ce paramètre:

class UserType {
  public String doSomething() { return null; }
}
 
interface Interceptor {
  String doSomethingElse();
}
 
interface InterceptionAccessor {
  Interceptor getInterceptor();
  void setInterceptor(Interceptor interceptor);
}
 
interface InstanceCreator {
  Object makeInstance();
}

Vous avez déjà appris à intercepter les méthodes d'une classe en utilisant MethodDelegation. Vous pouvez utiliser la dernière implémentation pour définir la délégation à un champ d'instance et nommer cet intercepteur de champ. De plus, il implémente l'interface ʻInterceptionAccessor` et intercepte toutes les méthodes de l'interface pour implémenter l'accesseur pour ce champ. En définissant l'accesseur de propriété «Bean», vous pouvez implémenter le «getter» de «getInterceptor» et le «setter» de «setInterceptor».

Class<? extends UserType> dynamicUserType = new ByteBuddy()
  .subclass(UserType.class)
    .method(not(isDeclaredBy(Object.class)))
    .intercept(MethodDelegation.toField("interceptor"))
  .defineField("interceptor", Interceptor.class, Visibility.PRIVATE)
  .implement(InterceptionAccessor.class).intercept(FieldAccessor.ofBeanProperty())
  .make()
  .load(getClass().getClassLoader())
  .getLoaded();

Le nouveau dynamicUserType vous permet d'implémenter l'interface InstanceCreator pour devenir une usine pour ce type dynamique. Encore une fois, j'utilise la délégation de méthode connue pour appeler le constructeur par défaut de type dynamique.

InstanceCreator factory = new ByteBuddy()
  .subclass(InstanceCreator.class)
    .method(not(isDeclaredBy(Object.class)))
    .intercept(MethodDelegation.construct(dynamicUserType))
  .make()
  .load(dynamicUserType.getClassLoader())
  .getLoaded().newInstance();

Notez que vous devez utiliser le chargeur de classe dynamicUserType pour charger l'usine. Sinon, ce type n'apparaîtra pas en usine lors du chargement.

Vous pouvez utiliser ces deux types dynamiques pour enfin créer une nouvelle instance de ʻUserTypeétendu dynamiquement et définir un intercepteur personnalisé pour cette instance. Terminons cet exemple en appliquantHelloWorldInterceptor` à l'instance que nous venons de créer. Notez que l'interface des accesseurs de champ et l'usine ont permis de faire cela sans utiliser de réflexions.

class HelloWorldInterceptor implements Interceptor {
  @Override
  public String doSomethingElse() {
    return "Hello World!";
  }
}
 
UserType userType = (UserType) factory.makeInstance();
((InterceptionAccessor) userType).setInterceptor(new HelloWorldInterceptor());

Miscellaneous

En plus des implémentations décrites jusqu'à présent, Byte Buddy comprend plusieurs autres implémentations.

Annotations

Vous avez appris comment Byte Buddy s'appuie sur les annotations pour fournir certaines de ses fonctionnalités. Et Byte Buddy n'est pas la seule application Java avec une API basée sur les annotations. Pour intégrer des types créés dynamiquement à de telles applications, Byte Buddy permet aux types créés et à leurs membres de définir des annotations. Avant de plonger dans les détails sur la façon d'attribuer des annotations à des types créés dynamiquement, examinons un exemple d'annotation d'une classe d'exécution.

@Retention(RetentionPolicy.RUNTIME)
@interface RuntimeDefinition { }
 
class RuntimeDefinitionImpl implements RuntimeDefinition {
  @Override
  public Class<? extends Annotation> annotationType() {
    return RuntimeDefinition.class;
  }
}
 
new ByteBuddy()
  .subclass(Object.class)
  .annotateType(new RuntimeDefinitionImpl())
  .make();

Les annotations sont représentées en interne comme des types d'interface, comme suggéré par le mot-clé Java @ interface. En conséquence, les annotations peuvent être implémentées par des classes Java comme une interface classique. La seule différence par rapport à l'implémentation de l'interface est la méthode d'annotations implicite ʻannotationType` qui détermine le type d'annotation que la classe représente. Cette dernière méthode retourne généralement un littéral de classe de type annotation implémenté. Les autres propriétés d'annotation sont implémentées comme s'il s'agissait de méthodes d'interface. Cependant, gardez à l'esprit que la mise en œuvre de la méthode d'annotation nécessite de répéter la valeur par défaut de l'annotation.

Il est particulièrement important de définir des annotations de classe créées dynamiquement si une classe doit agir en tant que proxy de sous-classe pour une autre classe. Les proxy de sous-classes sont souvent utilisés pour implémenter des préoccupations transversales lorsque les sous-classes doivent imiter la classe d'origine de manière aussi transparente que possible. Cependant, tant que ce comportement est explicitement requis en définissant l'annotation sur «@ Inherited», les annotations de la classe ne seront pas conservées dans ses sous-classes. Vous pouvez facilement créer un proxy de sous-classe qui contient les annotations de classe de base en utilisant Byte Buddy pour appeler les méthodes d'attribut du langage spécifique au domaine de Byte Buddy. Cette méthode attend «TypeAttributeAppender» comme argument. L'appendeur d'attribut de type offre un moyen flexible de définir des annotations pour les classes créées dynamiquement en fonction de leur classe de base. Par exemple, si vous transmettez TypeAttributeAppender.ForSuperType, les annotations de classe seront copiées dans la sous-classe créée dynamiquement. Les annotations et les ajouts d'attributs de type sont légaux et vous ne pouvez pas définir de types d'annotation plus d'une fois pour une classe.

Les annotations de méthode et de champ sont définies de la même manière que les annotations de type qui viennent d'être décrites. Les annotations de méthode peuvent être définies comme la déclaration finale dans le langage spécifique au domaine de Byte Buddy pour implémenter la méthode. De même, les champs peuvent être annotés après la définition. Regardons à nouveau l'exemple.

new ByteBuddy()
  .subclass(Object.class)
    .annotateType(new RuntimeDefinitionImpl())
  .method(named("toString"))
    .intercept(SuperMethodCall.INSTANCE)
    .annotateMethod(new RuntimeDefinitionImpl())
  .defineField("foo", Object.class)
    .annotateField(new RuntimeDefinitionImpl())

L'exemple de code ci-dessus remplace la méthode toString et annote la méthode remplacée avec RuntimeDefinition. De plus, le type créé définit un champ «foo» avec la même annotation, et définit également cette dernière annotation sur le type créé lui-même.

Par défaut, la configuration ByteBuddy ne prédéfinit pas les annotations pour les types ou membres de type créés dynamiquement. Cependant, ce comportement peut être modifié en spécifiant la valeur par défaut «TypeAttributeAppender», «MethodAttributeAppender» ou «FieldAttributeAppender». Notez qu'un tel ajout par défaut n'est pas légal et remplace la valeur précédente.

Lors de la définition d'une classe, il peut être souhaitable de ne pas charger le type d'annotation ou son type de propriété. Pour cela, vous pouvez utiliser ʻAnnotationDescription.Builder`, qui fournit une interface fluide pour définir des annotations sans déclencher le chargement de classe, mais au détriment de la sécurité de type. Cependant, toutes les propriétés d'annotation sont évaluées au moment de l'exécution.

Par défaut, Byte Buddy inclut toutes les propriétés de l'annotation dans le fichier de classe, y compris les propriétés par défaut spécifiées implicitement par les valeurs par défaut. Cependant, ce comportement peut être personnalisé en fournissant un ʻAnteationFilter à l'instance ByteBuddy`.

Type annotations

Byte Buddy publie et écrit les annotations de type introduites dans le cadre de Java 8. Les annotations de type sont accessibles sous forme d'annotations déclarées par l'instance TypeDescription.Generic. Si vous devez ajouter une annotation de type à un champ générique ou à un type de méthode, vous pouvez utiliser TypeDescription.Generic.Builder pour générer un type d'annotation.

Attribute appenders

Les fichiers de classe Java peuvent contenir des informations personnalisées arbitraires en tant que soi-disant attributs. Ces attributs peuvent être inclus en utilisant Byte Buddy en utilisant * AttributeAppender pour un type, un champ ou une méthode. Cependant, les ajouts d'attributs peuvent également être utilisés pour définir des méthodes en fonction des informations fournies par le type, le champ ou la méthode intercepté. Par exemple, lors du remplacement d'une méthode de sous-classe, vous pouvez copier toutes les annotations de la méthode interceptée.

class AnnotatedMethod {
  @SomeAnnotation
  void bar() { }
}

new ByteBuddy()
  .subclass(AnnotatedMethod.class)
  .method(named("bar"))
  .intercept(StubMethod.INSTANCE)
  .attribute(MethodAttributeAppender.ForInstrumentedMethod.INSTANCE)

Le code ci-dessus remplace la méthode bar de la classe ʻAnnotatedMethod`, mais copie toutes les annotations (y compris les annotations de paramètre ou de type) de la méthode surchargée.

Les mêmes règles peuvent ne pas s'appliquer lorsqu'une classe est redéfinie ou rebasée. Par défaut, ByteBuddy est configuré pour conserver les annotations de méthode rebasées ou redéfinies même si la méthode est interceptée comme décrit ci-dessus. Cependant, ce comportement peut être modifié pour que Byte Buddy rejette les annotations existantes en définissant la stratégie ʻAnnotationRetention sur DISABLED`.

Custom method implementations

La section précédente décrit l'API Byte Buddy standard. Aucune des fonctionnalités décrites jusqu'à présent ne nécessite une connaissance ou une représentation explicite du code d'octet Java. Cependant, si vous avez besoin de créer du code d'octet personnalisé, accédez à l'API de ASM, une bibliothèque de code d'octet de bas niveau avec Byte Buddy. Il peut être créé en y accédant directement. Cependant, différentes versions d'ASM ne sont pas compatibles avec d'autres versions, vous devrez donc reconditionner Byte Buddy dans votre espace de noms lorsque vous publiez votre code. Sinon, l'application peut introduire des incompatibilités pour d'autres utilisations de Byte Buddy lorsque différentes dépendances anticipent différentes versions de Byte Buddy basées sur différentes versions d'ASM. Vous pouvez trouver plus d'informations sur le maintien de votre dépendance à Byte Buddy sur la page d'accueil (https://bytebuddy.net/#dependency).

La bibliothèque ASM est fournie avec une bonne documentation sur le code d'octet Java et l'utilisation de la bibliothèque (http://download.forge.objectweb.org/asm/asm4-guide.pdf). Par conséquent, veuillez vous référer à ce document au cas où vous voudriez en savoir plus sur le bytecode Java et l'API ASM. Jetons plutôt un bref coup d'œil au modèle d'exécution JVM et à l'adaptation par Byte Buddy de l'API ASM.

Chaque fichier de classe Java est composé de plusieurs segments. Les segments principaux peuvent être classés grossièrement comme suit.

Heureusement, la bibliothèque ASM prend la responsabilité d'établir un pool de constantes approprié lors de la création d'une classe. Cela laisse le seul élément non trivial dans l'implémentation de la méthode représenté par un tableau d'instructions d'exécution, chacune codée comme un seul octet. Ces instructions sont traitées par la [machine de pile] virtuelle (http://en.wikipedia.org/wiki/Stack_machine) lorsque la méthode est appelée. À titre d'exemple simple, considérons une méthode qui calcule et renvoie la somme de deux entiers primitifs «10» et «50». Le code d'octet Java pour cette méthode ressemble à ceci:

LDC     10  // stack contains 10
LDC     50  // stack contains 10, 50
IADD        // stack contains 60
IRETURN     // stack is empty

Les mnémoniques du tableau de bytecode Java ci-dessus (http://en.wikipedia.org/wiki/Java_bytecode_instruction_listings) commencent par pousser les deux nombres sur la pile en utilisant l'instruction LDC. Notez comment cet ordre d'exécution diffère de l'ordre dans lequel les ajouts sont représentés dans le code source Java écrit sous forme de notation d'introduction «10 + 50». Valeur de niveau supérieur actuellement trouvée sur la pile Cet ajout est représenté par ʻIADD et consomme deux valeurs de pile de niveau supérieur qui devraient toutes deux être des entiers primitifs. Dans le processus, il ajoute ces deux valeurs et pousse le résultat vers le haut de la pile. Enfin, l'instruction ʻIRETURN consomme ce calcul et le renvoie à partir de la méthode, laissant une pile vide.

Nous avons déjà mentionné que toutes les valeurs primitives référencées dans une méthode sont stockées dans le pool de constantes de la classe. Ceci s'applique également aux nombres «50» et «10» référencés dans la méthode ci-dessus. Chaque valeur du pool de constantes reçoit un index de 2 octets. Supposons que les nombres «10» et «50» soient stockés dans les index «1» et «2». La méthode ci-dessus est exprimée comme suit, avec la valeur d'octet du mnémonique ci-dessus, qui est «0x12» pour «LDC», «0x60» pour «IADD» et «0xAC» pour «IRETURN». Instruction d'octet brut:

12 00 01
12 00 02
60
AC

Pour les classes compilées, cette séquence d'octets exacte se trouve dans le fichier de classe. Cependant, cette description n'est pas encore suffisante pour définir complètement la mise en œuvre du procédé. Pour réduire le temps d'exécution des applications Java, chaque méthode doit informer la machine virtuelle Java de la taille requise pour la pile d'exécution. Pour la méthode ci-dessus, qui vient sans branche, nous avons déjà vu que la pile a jusqu'à deux valeurs, donc c'est assez facile à déterminer. Cependant, de manière plus complexe, fournir ces informations peut facilement être une tâche complexe. Pour aggraver les choses, les valeurs de pile peuvent être de tailles différentes. Les valeurs «long» et «double» consomment deux emplacements, tandis que les autres valeurs en consomment un. Comme si cela ne suffisait pas, les machines virtuelles Java ont également besoin d'informations sur la taille de toutes les variables locales dans le corps de la méthode. Toutes ces variables dans une méthode sont stockées dans un tableau qui contient également tous les paramètres de méthode et des références «this» pour les méthodes non statiques. Là encore, les valeurs «long» et «double» consomment deux emplacements.

De toute évidence, le suivi de toutes ces informations fait de Byte Buddy une abstraction simplifiée car l'assemblage manuel du code d'octet Java est fastidieux et sujet aux erreurs. Dans Byte Buddy, les instructions de pile sont incluses dans l'implémentation de l'interface StackManipulation. Toutes les implémentations d'opérations de pile combinent une instruction pour modifier une pile donnée avec des informations sur l'impact de cette instruction sur la taille. Vous pouvez facilement combiner n'importe quel nombre de ces instructions en une instruction commune. Pour démontrer cela, implémentons d'abord la StackManipulation de l'instruction ʻIADD`.

enum IntegerSum implements StackManipulation {
 
  INSTANCE; // singleton
 
  @Override
  public boolean isValid() {
    return true;
  }
 
  @Override
  public Size apply(MethodVisitor methodVisitor,
                    Implementation.Context implementationContext) {
    methodVisitor.visitInsn(Opcodes.IADD);
    return new Size(-1, 0);
  }
}

De la méthode ʻapply ci-dessus, nous pouvons voir que cette opération de pile exécute l'instruction ʻIADD en appelant la méthode associée dans le visiteur de la méthode ASM. De plus, cette méthode signifie que l'instruction réduit la taille actuelle de la pile d'un emplacement. Le deuxième argument de l'instance Size créée est 0. Cela signifie que cette instruction ne nécessite pas de taille de pile minimale spécifique pour calculer les résultats intermédiaires. De plus, toute manipulation de pile peut être décrite comme invalide. Ce comportement peut être utilisé pour des opérations de pile plus complexes, telles que les affectations d'objets qui peuvent rompre les contraintes de type. Plus loin dans cette section, nous verrons des exemples d'opérations de pile non valides. Enfin, notez que l'opération de pile est décrite sous le nom d'énumération Singleton (http://en.wikipedia.org/wiki/Singleton_pattern#The_Enum_way). L'utilisation de ces descriptions d'opérations de pile immuables et fonctionnelles s'est avérée être une bonne pratique pour l'implémentation interne de Byte Buddy. Nous vous recommandons de suivre la même approche.

Vous pouvez implémenter la méthode en combinant l'IntegerSum ci-dessus avec les opérations de pile IntegerConstant et MethodReturn prédéfinies. Dans Byte Buddy, l'implémentation de la méthode est incluse dans ByteCodeAppender. Il est mis en œuvre comme suit:

enum SumMethod implements ByteCodeAppender {
 
  INSTANCE; // singleton
 
  @Override
  public Size apply(MethodVisitor methodVisitor,
                    Implementation.Context implementationContext,
                    MethodDescription instrumentedMethod) {
    if (!instrumentedMethod.getReturnType().asErasure().represents(int.class)) {
      throw new IllegalArgumentException(instrumentedMethod + " must return int");
    }
    StackManipulation.Size operandStackSize = new StackManipulation.Compound(
      IntegerConstant.forValue(10),
      IntegerConstant.forValue(50),
      IntegerSum.INSTANCE,
      MethodReturn.INTEGER
    ).apply(methodVisitor, implementationContext);
    return new Size(operandStackSize.getMaximalSize(),
                    instrumentedMethod.getStackSize());
  }
}

Encore une fois, le «ByteCodeAppender» personnalisé est implémenté comme une énumération singleton.

Avant d'implémenter la méthode souhaitée, vérifiez d'abord que la méthode instrumentée renvoie réellement un entier primitif. Sinon, la classe créée sera rejetée par le validateur JVM. Il charge ensuite les deux nombres «10» et «50» dans la pile d'exécution, applique la somme de ces valeurs et renvoie le résultat du calcul. L'emballage de toutes ces instructions dans une opération de pile composite garantit que vous obtenez la taille de pile globale nécessaire pour effectuer cette série d'opérations de pile. Enfin, il renvoie l'exigence de taille globale pour cette méthode. Le premier argument du ByteCodeAppender.Size renvoyé reflète la taille requise pour que la pile d'exécution mentionnée ci-dessus soit incluse dans StackManipulation.Size. De plus, le deuxième argument reflète la taille requise pour le tableau de variables locales. Ceci est similaire à cette référence car nous n'avons pas simplement défini ici la taille requise pour les paramètres de méthode et les variables locales.

L'implémentation de cette méthode d'agrégation est prête à fournir une implémentation personnalisée de cette méthode qui peut être fournie pour le langage spécifique au domaine de Byte Buddy.

enum SumImplementation implements Implementation {
 
  INSTANCE; // singleton
 
  @Override
  public InstrumentedType prepare(InstrumentedType instrumentedType) {
    return instrumentedType;
  }
 
  @Override
  public ByteCodeAppender appender(Target implementationTarget) {
    return SumMethod.INSTANCE;
  }
}

Chaque implémentation est interrogée en deux étapes. Tout d'abord, l'implémentation a la possibilité de modifier la classe créée en ajoutant des champs ou des méthodes supplémentaires à la méthode prepare. De plus, cette préparation permet à l'implémentation d'enregistrer le TypeInitializer appris dans la section précédente. Si aucune préparation de ce type n'est requise, il suffit de renvoyer le ʻInstrumentedType inchangé fourni comme argument. Les implémentations ne doivent généralement pas renvoyer des instances individuelles d'un type instrumenté, mais doivent appeler une méthode d'appender de type instrumenté avec tous les préfixes. Une fois que l'implémentation est prête à créer une classe particulière, la méthode ʻappender est appelée pour obtenir le ByteCodeAppender. Cet appender est ensuite également interrogé pour la méthode sélectionnée pour l'interception par l'implémentation spécifiée, et la méthode enregistrée lors de l'appel à la méthode prepare par l'implémentation.

Notez que Byte Buddy appelle les méthodes prepare et ʻappender de chaque implémentation une seule fois pendant le processus de création de classe. Ceci est garanti quel que soit le nombre de fois où l'implémentation est enregistrée pour être utilisée lors de la création de la classe. De cette manière, l'implémentation peut éviter de vérifier que le champ ou la méthode est déjà défini. Dans le processus, Byte Buddy compare les instances ʻImplementations avec les méthodes hashCode et ʻequals`. En général, toutes les classes utilisées par Byte Buddy devraient fournir une implémentation significative de ces méthodes. Le fait que des énumérations soient attachées à de telles implémentations par définition est une autre bonne raison pour leur utilisation.

Voyons maintenant comment fonctionne SumImplementation.

abstract class SumExample {
  public abstract int calculate();
}
 
new ByteBuddy()
  .subclass(SumExample.class)
    .method(named("calculate"))
    .intercept(SumImplementation.INSTANCE)
  .make()

Toutes nos félicitations. Nous avons étendu Byte Buddy pour implémenter une méthode personnalisée qui calcule et renvoie la somme de «10» et «50». Bien entendu, cet exemple de mise en œuvre n'est pas très pratique. Cependant, vous pouvez facilement implémenter des implémentations plus complexes en plus de cette infrastructure. Après tout, si vous pensez avoir créé quelque chose d'utile, pensez à Contribuer à votre implémentation (https://bytebuddy.net/develop). Je suis dans l'attente de votre réponse.

Avant de passer à la personnalisation des autres composants de Byte Buddy, nous devons expliquer brièvement l'utilisation des instructions de saut et le soi-disant problème de cadre de pile Java. À partir de Java 6, les instructions de saut utilisées pour implémenter, par exemple, les instructions «if» ou «while» nécessitent des informations supplémentaires pour accélérer le processus de validation JVM. Cette information supplémentaire est appelée un cadre de carte de pile. Le cadre de mappage de pile contient des informations sur toutes les valeurs trouvées dans la pile d'exécution de n'importe quelle cible de l'instruction de saut. Fournir ces informations peut permettre aux vérificateurs JVM d'économiser du travail, mais pour l'instant, cela dépend de nous. Pour des instructions de saut plus complexes, fournir le cadre de carte de pile correct est une tâche assez difficile, et de nombreux cadres de génération de code ont toujours des problèmes considérables pour créer le cadre de carte de pile correct. Alors, comment gérez-vous ce problème? En fait, nous ne le sommes tout simplement pas. La philosophie de Byte Buddy est que la génération de code ne doit être utilisée que comme un adhésif entre la hiérarchie des types inconnus au moment de la compilation et le code personnalisé qui doit être injecté dans ces types. Par conséquent, le code réel généré doit rester aussi restreint que possible. Dans la mesure du possible, les instructions conditionnelles doivent être implémentées et compilées dans le langage JVM de votre choix, puis liées à une méthode particulière avec une implémentation minimale. Un bon effet secondaire de cette approche est que les utilisateurs de Byte Buddy peuvent travailler avec du code Java standard ou utiliser des outils familiers tels que des débogueurs et des navigateurs de code IDE. Cela n'est pas possible avec du code généré qui n'a pas de représentation de code source. Cependant, si vous devez utiliser des instructions de saut pour créer des codes d'octet, assurez-vous d'utiliser ASM pour ajouter les cadres de mappage de pile appropriés, car Byte Buddy ne les inclura pas automatiquement.

Creating a custom assigner

Dans la section précédente, nous avons expliqué que l'implémentation intégrée de Byte Buddy repose sur ʻAssigner pour attribuer des valeurs aux variables. Dans ce processus, ʻAssigner peut appliquer la conversion d'une valeur en une autre en émettant la StackManipulation appropriée. Ce faisant, l'assistant intégré de Byte Buddy fournit, par exemple, un encadrement automatique des valeurs primitives et de leurs types de wrapper. Dans le cas le plus courant, la valeur peut être affectée directement à la variable. Cependant, dans certains cas, ce qui peut être exprimé en renvoyant une «StackManipulation» non valide de l'assignateur ne peut pas du tout être affecté. Une implémentation régulière des affectations invalides est fournie par la classe ʻIllegalStackManipulation` de Byte Buddy.

Pour illustrer comment utiliser un assigneur personnalisé, implémentez un assigneur qui affecte des valeurs uniquement aux variables de type chaîne en appelant la méthode toString sur toute valeur qu'il reçoit.

enum ToStringAssigner implements Assigner {
 
  INSTANCE; // singleton
 
  @Override
  public StackManipulation assign(TypeDescription.Generic source,
                                  TypeDescription.Generic target,
                                  Assigner.Typing typing) {
    if (!source.isPrimitive() && target.represents(String.class)) {
      MethodDescription toStringMethod = new TypeDescription.ForLoadedType(Object.class)
        .getDeclaredMethods()
        .filter(named("toString"))
        .getOnly();
      return MethodInvocation.invoke(toStringMethod).virtual(sourceType);
    } else {
      return StackManipulation.Illegal.INSTANCE;
    }
  }
}

L'implémentation ci-dessus vérifie d'abord que la valeur d'entrée n'est pas un type primitif et que le type de variable cible est un type "String". Si ces conditions ne sont pas remplies, ʻAssigner émet ʻIllegalStackManipulation pour invalider la tentative d'allocation. Sinon, il identifie la méthode de type d'objet toString par son nom. Ensuite, utilisez MethodInvocation de Byte Buddy pour créer une StackManipulation qui appelle virtuellement cette méthode par type de source. Enfin, vous pouvez intégrer cet ʻAssigner personnalisé avec, par exemple, l'implémentation FixedValue` de Byte Buddy comme suit:

new ByteBuddy()
  .subclass(Object.class)
  .method(named("toString"))
    .intercept(FixedValue.value(42)
      .withAssigner(new PrimitiveTypeAwareAssigner(ToStringAssigner.INSTANCE),
                    Assigner.Typing.STATIC))
  .make()

Lorsque la méthode toString est appelée sur une instance du type ci-dessus, la valeur de chaîne 42 est renvoyée. Ceci n'est possible qu'en appelant la méthode toString et en utilisant un assigneur personnalisé qui convertit le type ʻInteger en String. Enveloppant davantage l'assignateur personnalisé avec le PrimitiveTypeAwareAssigner intégré qui effectue la mise en boîte automatique de la primitive ʻint fournie à un type d'encapsuleur avant de déléguer cette affectation de valeur primitive encapsulée à son assignateur interne. Notez s'il vous plaît. Les autres assigneurs intégrés sont «VoidAwareAssigner» et «ReferenceTypeAwareAssigner». Assurez-vous d'implémenter des méthodes hashCode et ʻequals significatives dans vos assigneurs personnalisés. Ces méthodes sont généralement appelées à partir des méthodes correspondantes dans ʻImplementation qui utilisent un assignateur particulier. En outre, implémentez l'assignateur en tant qu'énumération singleton pour éviter de le faire manuellement.

Creating a custom parameter binder

Nous avons déjà mentionné dans la section précédente qu'il est possible d'étendre l'implémentation de MethodDelegation pour gérer les annotations définies par l'utilisateur. Pour cela, vous devez fournir un ParameterBinder personnalisé qui sait comment gérer une annotation donnée. À titre d'exemple, je voudrais définir une annotation simplement dans le but d'insérer une chaîne fixe dans un paramètre annoté. Tout d'abord, définissez une telle annotation «StringValue».

@Retention(RetentionPolicy.RUNTIME)
@interface StringValue {
  String value();
}

Vous devez définir le RuntimePolicy approprié pour rendre les annotations visibles au moment de l'exécution. Sinon, l'annotation ne sera pas conservée à l'exécution et Byte Buddy n'aura aucune chance de la trouver. Ce faisant, la propriété value ci-dessus contient la chaîne qui sera affectée comme valeur au paramètre annoté.

Vous devez créer un "ParameterBinder" correspondant qui peut utiliser une annotation personnalisée pour créer un "StackManipulation" qui représente la liaison pour ce paramètre. Ce classeur de paramètres est appelé à chaque fois et l'annotation correspondante se trouve sur le paramètre par MethodDelegation. Il est facile d'implémenter un classeur de paramètres personnalisé pour les annotations de cet exemple.

enum StringValueBinder
    implements TargetMethodAnnotationDrivenBinder.ParameterBinder<StringValue> {
 
  INSTANCE; // singleton
 
  @Override
  public Class<StringValue> getHandledType() {
    return StringValue.class;
  }
 
  @Override
  public MethodDelegationBinder.ParameterBinding<?> bind(AnnotationDescription.Loaded<StringValue> annotation,
                                                         MethodDescription source,
                                                         ParameterDescription target,
                                                         Implementation.Target implementationTarget,
                                                         Assigner assigner,
                                                         Assigner.Typing typing) {
    if (!target.getType().asErasure().represents(String.class)) {
      throw new IllegalStateException(target + " makes illegal use of @StringValue");
    }
    StackManipulation constant = new TextConstant(annotation.loadSilent().value());
    return new MethodDelegationBinder.ParameterBinding.Anonymous(constant);
  }
}

Tout d'abord, le classeur de paramètres vérifie que le paramètre target est en fait de type String. Si ce n'est pas le cas, il lève une exception notifiant à l'utilisateur l'annotation du placement illégal de cette annotation. Sinon, créez simplement un TextConstant qui représente le chargement de la chaîne de pile constante dans la pile d'exécution. Ce StackManipulation est finalement enveloppé comme un ParameterBinding anonyme renvoyé par la méthode. Vous pouvez également avoir spécifié la liaison de paramètre ʻUnique ou ʻIllegal. Les liaisons uniques sont identifiées par tout objet qui vous permet d'obtenir cette liaison depuis ʻAmbiguityResolver`. Dans une étape ultérieure, un tel résolveur peut vérifier si la liaison de paramètre est enregistrée avec un identificateur unique, puis déterminer si cette liaison est meilleure que d'autres méthodes liées avec succès. Je peux le faire. Avec une liaison illégale, vous pouvez dire à Byte Buddy que certaines paires de méthodes «source» et «cible» sont incompatibles et ne peuvent pas être liées ensemble.

Ce sont déjà toutes les informations dont vous avez besoin pour utiliser des annotations personnalisées dans votre implémentation MethodDelegation. Après avoir reçu le ParameterBinding, assurez-vous que la valeur est liée au paramètre correct, ou rejetez la paire actuelle de méthodes source et cible comme non liée. De plus, cela permettra à ʻAmbiguityResolvers` d'examiner les liaisons uniques. Enfin, exécutons cette annotation personnalisée.

class ToStringInterceptor {
  public static String makeString(@StringValue("Hello!") String value) {
    return value;
  }
}
 
new ByteBuddy()
  .subclass(Object.class)
  .method(named("toString"))
    .intercept(MethodDelegation.withDefaultConfiguration()
      .withBinders(StringValueBinder.INSTANCE)
      .to(ToStringInterceptor.class))
  .make()

Spécifier StringValueBinder comme seul classeur de paramètres remplace toutes les valeurs par défaut. Vous pouvez également ajouter un classeur de paramètres à celui qui est déjà enregistré. Si le ToStringInterceptor n'a qu'une seule méthode cible, la méthode toString interceptée de la classe dynamique est liée à l'appel de cette dernière méthode. Lorsque la méthode cible est appelée, Byte Buddy affecte la valeur de chaîne de l'annotation comme seul paramètre de la méthode cible.

Recommended Posts

[Français] Tutoriel Byte Buddy
Résumé de la traduction du didacticiel Apache Shiro
Truffle Tutorial Slides Mémo de traduction personnel ①
[Introduction à Docker] Tutoriel officiel (traduction en japonais)