
Nous aimons tous détecter les erreurs lors de la compilation, au lieu des exceptions d'exécution. Le moyen le plus simple de les corriger est que le compilateur lui-même affiche tous les endroits qui doivent être corrigés. Bien que la plupart des problèmes ne puissent être détectés qu'au démarrage du programme, nous essayons toujours de le faire dès que possible.
En blocs d'initialisation de classes, en constructeurs d'objets, au premier appel d'une méthode, etc. Et parfois, nous avons de la chance, et même au stade de la compilation, nous en savons assez pour vérifier le programme pour certaines erreurs.
Dans cet article, je veux partager l'expérience de la rédaction d'un tel test. Plus précisément, créer une annotation pouvant générer des erreurs, comme le fait le compilateur. A en juger par le fait qu'il n'y a pas tant d'informations sur ce sujet dans RuNet, les situations heureuses décrites ci-dessus ne le sont pas souvent.
Je décrirai l'algorithme général de vérification, ainsi que toutes les étapes et nuances pour lesquelles j'ai passé du temps et des cellules nerveuses.
Énoncé du problème
Dans cette section, je vais donner un exemple d'utilisation de cette annotation. Si vous savez déjà quelle vérification vous voulez faire, vous pouvez la sauter en toute sécurité. Je suis sûr que cela n'affectera pas l'intégralité de la présentation.
Maintenant, nous parlerons davantage de l'amélioration de la lisibilité du code que de la correction des bogues. Un exemple, on pourrait dire, de la vie, ou plutôt de mon projet de loisir.
Supposons qu'il existe une classe UnitManager, qui, en fait, est une collection d'unités. Il a des méthodes pour ajouter, supprimer, obtenir une unité, etc. Lors de l'ajout d'une nouvelle unité, le gestionnaire lui attribue un identifiant. La génération d'id est déléguée à la classe RotateCounter, qui renvoie un nombre dans la plage donnée. Et il y a un petit problème, RotateCounter ne peut pas savoir si l'ID sélectionné est libre. Selon le principe de l'inversion de dépendance, vous pouvez créer une interface, dans mon cas, c'est RotateCounter.IClient, qui a une seule méthode isValueFree (), qui reçoit id et renvoie true si id est libre. Et UnitManager implémente cette interface, crée une instance de RotateCounter et la transmet à lui-même en tant que client.
J'ai fait juste ça. Mais, après avoir ouvert la source de UnitManager quelques jours après avoir écrit, je suis tombé dans une stupeur facile après avoir vu la méthode isValueFree (), qui ne correspondait pas vraiment à la logique de UnitManager. Il serait beaucoup plus simple s'il était possible de spécifier quelle interface implémente cette méthode. Par exemple, en C #, d'où je suis venu à Java, une implémentation d'interface explicite permet de faire face à ce problème. Dans ce cas, tout d'abord, vous ne pouvez appeler la méthode qu'avec un transtypage explicite vers l'interface. Deuxièmement, et plus important dans ce cas, le nom de l'interface (et sans le modificateur d'accès) est explicitement indiqué dans la signature de la méthode, par exemple:
IClient.isValueFree(int value) { }
Une solution consiste à ajouter une annotation avec le nom de l'interface qui implémente cette méthode. Quelque chose comme
@Override
, uniquement avec une interface. Je suis d'accord, vous pouvez utiliser une classe interne anonyme. Dans ce cas, tout comme en C #, la méthode ne peut pas simplement être appelée sur l'objet, et vous pouvez immédiatement voir quelle interface elle implémente. Mais cela augmentera la quantité de code, par conséquent, dégradera la lisibilité. Oui, et vous devez en quelque sorte l'obtenir de la classe - créez un getter ou un champ public (après tout, il n'y a pas de surcharge d'instructions cast en Java non plus). Pas une mauvaise option, mais je n'aime pas ça.
Au début, je pensais qu'en Java, comme en C #, les annotations sont des classes complètes et peuvent en être héritées. Dans ce cas, il vous suffit de créer une annotation qui hérite de
@Override
. Mais ce n'était pas le cas, et j'ai dû plonger dans le monde étonnant et effrayant des chèques au stade de la compilation.
Exemple de code UnitManager public class Unit { private int id; } public class UnitManager implements RotateCounter.IClient { private final Unit[] units; private final RotateCounter idGenerator; public UnitManager(int size) { units = new Unit[size]; idGenerator = new RotateCounter(0, size, this); } public void addUnit(Unit unit) { int id = idGenerator.findFree(); units[id] = unit; } @Implement(RotateCounter.IClient.class) public boolean isValueFree(int value) { return units[value] == null; } public void removeUnit(int id) { units[id] = null; } } public class RotateCounter { private final IClient client; private int next; private int minValue; private int maxValue; public RotateCounter(int minValue, int maxValue, IClient client) { this.client = client; this.minValue = minValue; this.maxValue = maxValue; next = minValue; } public int incrementAndGet() { int current = next; if (next >= maxValue) { next = minValue; return current; } next++; return current; } public int range() { return maxValue - minValue + 1; } public int findFree() { int range = range(); int trysCounter = 0; int id; do { if (++trysCounter > range) { throw new IllegalStateException("No free values."); } id = incrementAndGet(); } while (!client.isValueFree(id)); return id; } public static interface IClient { boolean isValueFree(int value); } }
Un peu de théorie
Je ferai une réservation tout de suite, toutes les méthodes ci-dessus sont des instances, donc, par souci de concision, je vais indiquer les noms de méthode avec le nom de type et sans paramètres:
<_>.<_>()
.
Le traitement des éléments au stade de la compilation implique des classes de processeur spéciales. Ce sont des classes qui héritent de
javax.annotation.processing.AbstractProcessor
(vous pouvez simplement implémenter l'interface
javax.annotation.processing.Processor
). Vous pouvez en savoir plus sur les processeurs
ici et
ici . La méthode la plus importante est le processus. Dans lequel nous pouvons obtenir une liste de tous les éléments annotés et effectuer les vérifications nécessaires.
@Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment env) { return false; }
Au début, sincèrement naïf, je pensais que travailler avec des types au stade de la compilation se faisait en termes de réflexion, mais ... non. Tout est basé sur des éléments là-bas.
Element (
javax.lang.model.element.Element ) - l'interface principale pour travailler avec la plupart des éléments structurels du langage. Un élément a des descendants qui déterminent plus précisément les propriétés d'un élément particulier (pour plus de détails, voir
ici ):
package ds.magic.example.implement;
TypeMirror (
javax.lang.model.type.TypeMirror ) est quelque chose comme Class <?> Renvoyé par la méthode getClass (). Par exemple, ils peuvent être comparés pour savoir si les types d'éléments correspondent. Vous pouvez l'obtenir en utilisant la méthode
Element.asType()
. Ce type renvoie également certaines opérations de type, telles que
TypeElement.getSuperclass()
ou
TypeElement.getInterfaces()
.
Types (
javax.lang.model.util.Types ) - Je vous conseille de regarder de plus près cette classe. Vous pouvez y trouver beaucoup de choses intéressantes. En substance, il s'agit d'un ensemble d'utilitaires pour travailler avec des types. Par exemple, il vous permet de récupérer un TypeElement à partir d'un TypeMirror.
private TypeElement asTypeElement(TypeMirror typeMirror) { return (TypeElement)processingEnv.getTypeUtils().asElement(typeMirror); }
TypeKind (
javax.lang.model.type.TypeKind ) - une énumération qui vous permet de clarifier les informations de type, de vérifier si le type est un tableau (ARRAY), un type personnalisé (DECLARED), une variable de type (TYPEVAR), etc. Vous pouvez l'obtenir via
TypeMirror.getKind()
ElementKind (
javax.lang.model.element.ElementKind ) - énumération, vous permet de clarifier les informations sur l'élément, de vérifier si l'élément est un paquet (PACKAGE), une classe (CLASS), une méthode (METHOD), une interface (INTERFACE), etc.
Nom (
javax.lang.model.element.Name ) - l'interface pour travailler avec le nom de l'élément, peut être obtenue via
Element.getSimpleName()
.
Fondamentalement, ces types me suffisaient pour écrire un algorithme de vérification.
Je veux noter une autre caractéristique intéressante. Les implémentations des interfaces Element dans Eclipse se trouvent dans les packages org.eclipse ..., par exemple, les éléments qui représentent les méthodes sont de type
org.eclipse.jdt.internal.compiler.apt.model.ExecutableElementImpl
. Cela m'a donné l'idée que ces interfaces sont implémentées par chaque IDE indépendamment.
Algorithme de validation
Vous devez d'abord créer l'annotation elle-même. Beaucoup a déjà été écrit à ce sujet (par exemple
ici ), donc je ne m'attarderai pas là-dessus en détail. Je peux seulement dire que pour notre exemple, nous devons ajouter deux annotations
@Target
et
@Retention
. La première indique que notre annotation ne peut être appliquée qu'à la méthode, et la seconde que l'annotation n'existera que dans le code source.
Les annotations doivent être spécifiées quelle interface implémente la méthode annotée (la méthode à laquelle l'annotation est appliquée). Cela peut être fait de deux manières: soit spécifiez le nom complet de l'interface avec une chaîne, par exemple
@Implement("com.ds.IInterface")
, soit passez directement la classe d'interface:
@Implement(IInterface.class)
. La deuxième voie est clairement meilleure. Dans ce cas, le compilateur surveillera le nom d'interface correct. Par ailleurs, si vous appelez ce membre
value (), lors de l'ajout d'annotations à la méthode, vous n'aurez pas besoin de spécifier explicitement le nom de ce paramètre.
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.SOURCE) public @interface Implement { Class<?> value(); }
Ensuite, le plaisir commence - la création du processeur. Dans la méthode du processus, nous obtenons une liste de tous les éléments annotés. Ensuite, nous obtenons l'annotation elle-même et sa signification - l'interface spécifiée. En général, le cadre de classe de processeur ressemble à ceci:
@SupportedAnnotationTypes({"ds.magic.annotations.compileTime.Implement"}) @SupportedSourceVersion(SourceVersion.RELEASE_8) public class ImplementProcessor extends AbstractProcessor { private Types typeUtils; @Override public void init(ProcessingEnvironment procEnv) { super.init(procEnv); typeUtils = this.processingEnv.getTypeUtils(); } @Override public boolean process(Set<? extends TypeElement> annos, RoundEnvironment env) { Set<? extends Element> annotatedElements = env.getElementsAnnotatedWith(Implement.class); for(Element annotated : annotatedElements) { Implement annotation = annotatedElement.getAnnotation(Implement.class); TypeMirror interfaceMirror = getValueMirror(annotation); TypeElement interfaceType = asTypeElement(interfaceMirror);
Je veux noter que vous ne pouvez pas simplement obtenir et obtenir des annotations de valeur comme ça. Lorsque vous essayez d'appeler
annotation.value()
, une
exception MirroredTypeException est levée, mais vous pouvez en obtenir un TypeMirror. Cette méthode de triche, ainsi que la bonne réception de la valeur, j'ai trouvé
ici :
private TypeMirror getValueMirror(Implement annotation) { try { annotation.value(); } catch(MirroredTypeException e) { return e.getTypeMirror(); } return null; }
La vérification elle-même se compose de trois parties, si au moins l'une d'entre elles échoue, vous devez afficher un message d'erreur et passer à l'annotation suivante. Par ailleurs, vous pouvez afficher un message d'erreur à l'aide de la méthode suivante:
private void printError(String message, Element annotatedElement) { Messager messager = processingEnv.getMessager(); messager.printMessage(Kind.ERROR, message, annotatedElement); }
La première étape consiste à vérifier si les annotations de valeur sont une interface. Ici, tout est simple:
if (interfaceType.getKind() != ElementKind.INTERFACE) { String name = Implement.class.getSimpleName(); printError("Value of @" + name + " must be an interface", annotated); continue; }
Ensuite, vous devez vérifier si la classe dans laquelle se trouve la méthode annotée implémente réellement l'interface spécifiée. Au début, j'ai implémenté ce test avec mes mains. Mais ensuite, en utilisant de bons conseils, j'ai regardé
Types et y
Types.isSubtype()
trouvé la méthode
Types.isSubtype()
, qui vérifiera l'arborescence d'héritage entière et retournera true si l'interface spécifiée est là. Surtout, il peut fonctionner avec des types génériques, contrairement à la première option.
TypeElement enclosingType = (TypeElement)annotatedElement.getEnclosingElement(); if (!typeUtils.isSubtype(enclosingType.asType(), interfaceMirror)) { Name className = enclosingType.getSimpleName(); Name interfaceName = interfaceType.getSimpleName(); printError(className + " must implemet " + interfaceName, annotated); continue; }
Enfin, vous devez vous assurer que l'interface possède une méthode avec la même signature que celle annotée. Je voudrais utiliser la méthode
Types.isSubsignature()
, mais, malheureusement, cela ne fonctionne pas correctement si la méthode a des paramètres de type. Alors nous retroussons nos manches et écrivons tous les chèques avec nos mains. Et nous en avons encore trois. Eh bien, plus précisément, la signature de la méthode se compose de trois parties: le nom de la méthode, le type de la valeur de retour et la liste des paramètres. Vous devez parcourir toutes les méthodes de l'interface et trouver celle qui a réussi les trois vérifications. Il serait bon de ne pas oublier que la méthode peut être héritée d'une autre interface et effectuer récursivement les mêmes vérifications pour les interfaces sous-jacentes.
L'appel doit être placé à la fin de la boucle dans la méthode de processus, comme ceci:
if (!haveMethod(interfaceType, (ExecutableElement)annotatedElement)) { Name name = interfaceType.getSimpleName(); printError(name + " don't have \"" + annotated + "\" method", annotated); continue; }
Et la méthode haveMethod () elle-même ressemble à ceci:
private boolean haveMethod(TypeElement interfaceType, ExecutableElement method) { Name methodName = method.getSimpleName(); for (Element interfaceElement : interfaceType.getEnclosedElements()) { if (interfaceElement instanceof ExecutableElement) { ExecutableElement interfaceMethod = (ExecutableElement)interfaceElement;
Vous voyez le problème? Non? Et elle est là. Le fait est que je n'ai pas pu trouver un moyen d'obtenir les paramètres de type réels pour les interfaces génériques. Par exemple, j'ai une classe qui implémente l'interface
Predicate :
MyPredicate implements Predicate<String> { @Implement(Predicate.class) boolean test(String t) { return false; } }
Lors de l'analyse de la méthode dans la classe, le type du paramètre est
String
, et dans l'interface, c'est
T
, et toutes les tentatives pour obtenir
String
au lieu de cela n'ont mené à rien. En fin de compte, je n'ai rien trouvé de mieux que d'ignorer les paramètres de type. La vérification sera passée avec tous les paramètres de type réels, même s'ils ne correspondent pas. Heureusement, le compilateur générera une erreur si la méthode n'a pas d'implémentation par défaut et n'est pas implémentée dans la classe de base. Mais encore, si quelqu'un sait comment contourner cela, je serai extrêmement reconnaissant pour l'indice.
Connectez-vous à Eclipse
Personnellement, j'adore Eclipce et dans ma pratique je ne l'ai utilisé que. Par conséquent, je vais décrire comment connecter le processeur à cet IDE. Pour qu'Eclipse puisse voir le processeur, vous devez le placer dans un fichier .JAR distinct, dans lequel l'annotation elle-même sera également. Dans ce cas, vous devez créer le dossier
META-INF / services dans le projet et y créer le fichier
javax.annotation.processing.Processor et indiquer le nom complet de la classe de processeur:
ds.magic.annotations.compileTime.ImplementProcessor
, dans mon cas. Juste au cas où, je vais faire une capture d'écran, mais quand rien n'a fonctionné pour moi, j'ai presque commencé à pécher sur la structure du projet.

Ensuite, collectez .JAR et connectez-le à votre projet, d'abord en tant que bibliothèque régulière, afin que l'annotation soit visible dans le code. Ensuite, nous connectons le processeur (
ici est plus détaillé). Pour ce faire, ouvrez les
propriétés du
projet et sélectionnez:
- Compilateur Java -> Traitement des annotations et cochez la case "Activer le traitement des annotations".
- Compilateur Java -> Traitement des annotations -> Factory Path cochez la case "Activer les paramètres spécifiques au projet". Cliquez ensuite sur Ajouter des fichiers JAR ... et sélectionnez le fichier JAR créé précédemment.
- Accepte de reconstruire le projet.
Résumé
Tous ensemble et dans le projet Eclipse peuvent être vus sur
GitHub . Au moment de l'écriture, il n'y a que deux classes, si l'annotation peut être appelée ainsi: Implement.java et ImplementProcessor.java. Je pense que vous avez déjà deviné leur but.
Peut-être que cette annotation peut sembler inutile pour certains. C'est peut-être le cas. Mais personnellement, je l'utilise moi-même au lieu de
@Override
, lorsque les noms de méthode ne correspondent pas bien à l'objectif de la classe. Et jusqu'à présent, je n'ai aucune envie de me débarrasser d'elle. En général, j'ai fait une annotation pour moi-même, et le but de l'article était de montrer quel râteau j'attaquais. J'espère que je l'ai fait. Merci de votre attention.
PS. Merci aux utilisateurs de
ohotNik_alex et
Comdiv pour leur aide dans la correction des bugs.