Au lieu de "Dédié à ..."
La tâche décrite ci-dessous n'était pas innovante ou sacrément utile, l'entreprise dans laquelle je travaille ne recevra aucun profit pour cela, mais je serai un bonus.
Mais cette tâche était et devait donc être résolue.
Intro
Dans l'article que vous rencontrerez souvent le mot Lombok, je demande aux ennemis de ne pas se précipiter vers les conclusions.
Je ne vais pas me «noyer» pour Lombok ou son absence, moi, comme Geralt Sapkovsky, j'essaie d'être neutre, et je peux lire le code avec ou sans Lombok calmement et sans trembler au cours du siècle.
Mais sur le projet en cours, la bibliothèque mentionnée est présente, et quelque chose me dit que notre projet n'est pas le seul.
Alors voilà.
La dernière fois en java, il y a certainement une tendance vers annotashki. À la gloire du concept d'échec rapide, les paramètres des méthodes sont souvent annotés avec l'annotation @NonNull (de sorte que si quelque chose se passe mal, alors ça passe).
Il existe de nombreuses options d'importation pour cela (ou une annotation similaire dans l'idéologie), mais, comme cela est déjà devenu clair, nous nous concentrerons sur la version
import lombok.NonNull;
Si vous utilisez cette annotation (ou similaire), vous avez un contrat que vous devez vérifier avec un test et tout analyseur de code statique vous le dira avec bonté (Sonar vous le dit exactement).
Tester cette annotation avec un test unitaire est assez simple, le problème est que ces tests vont se multiplier dans votre projet à la vitesse des lapins au printemps, et les lapins, comme vous le savez, violent le principe DRY.
Dans l'article, nous allons écrire un petit cadre de test pour tester le contrat d'annotations @NonNull (et pour que Sonar ne brille pas dans vos yeux avec une méchante lumière rouge).
PS Le nom de la chanson a été inspiré par la chanson du groupe PowerWolf, qui jouait (par golly) quand j'ai écrit le nom (dans l'original, le nom sonne plus positif)
Corps principal
Au départ, nous avons testé l'annotation quelque chose comme ceci:
@Test void methodNameWithNullArgumentThrowException() { try { instance.getAnyType(null); fail("Exception not thrown"); } catch (final NullPointerException e) { assertNotNull(e); } }
Ils ont appelé la méthode et passé null en tant que paramètre annoté avec l'annotation @NonNull.
Ils ont obtenu NPE et étaient satisfaits (Sonar était également content).
Ensuite, ils ont commencé à faire de même, mais avec un assertThrow plus à la mode qui fonctionne via le fournisseur (nous aimons les lambdas):
@TestUnitRepeatOnce void methodNameWithNullArgumentThrowException() { assertThrows(NullPointerException.class, () -> instance.getAnyType(null)); }
Élégant. À la mode. Jeunesse
Il semblerait possible de terminer, les annotations sont testées, alors quoi de plus?
Le problème (pas le problème, mais quand même) de cette méthode de test a «refait surface» lorsqu'un jour j'ai écrit un test pour une méthode, cela a fonctionné avec succès, puis j'ai remarqué qu'il n'y avait pas d'annotation @NonNull sur le paramètre.
C'est compréhensible: vous appelez la méthode de test, sans décrire le comportement des classes moque, par le biais de when () / then (). Le thread d'exécution entre en toute sécurité dans la méthode, quelque part à l'intérieur, il attrape NPE, sur un objet déverrouillé (ou verrouillé, mais sans quand () / then ()), et se bloque, cependant avec NPE, comme vous l'avez averti, ce qui signifie que le test est vert
Il s'avère que nous testons dans ce cas, pas l'annotation, mais on ne sait pas quoi. Le test fonctionnant correctement, nous ne devrions même pas avoir à approfondir la méthode (tomber sur le seuil).
Les annotations @NonNull de Lombok ont une caractéristique: si nous passons de NPE aux annotations, le nom du paramètre est écrit dans l'erreur.
Nous nous impliquerons dans cela, après notre chute de NPE, nous vérifierons en outre le texte de stacktrace, comme ceci:
exception.getCause().getMessage().equals(parameter.getName())
Et si tout à coup ...Dans le cas où Lombok se rafraîchit soudainement et arrête d'écrire le nom du paramètre qui a reçu null dans stacktrace, alors nous passerons en revue la conférence d'Andrei Pangin sur la
JVM TI et rédigerons un plug-in pour la JVM, dans lequel nous transférerons le nom du paramètre.
Tout ne semble rien, maintenant on vérifie vraiment ce qui est nécessaire, mais le problème des «lapins» n'est pas résolu.
Je voudrais avoir un outil qui pourrait être dit, par exemple, comme ceci:
@TestUnitRepeatOnce @SneakyThrows void nonNullAnnotationTest() { assertNonNullAnnotation(YourPerfectClass.class); }
et lui-même irait scanner toutes les méthodes publiques de la classe spécifiée et vérifierait tous leurs paramètres @NonNull avec un test.
Vous direz, obtenez une réflexion et vérifiez si la méthode @NonNull est activée et s'il y a une puce nulle.
Tout ne serait rien, mais RetentionPolicy n'est pas celui-là.
Toutes les annotations ont un paramètre RetentionPolicy, qui peut être de 3 types: SOURCE, CLASS et RUNTIME, donc Lombok a RetentionPolicy.SOURCE par défaut, ce qui signifie que cette annotation n'est pas visible dans Runtime et vous ne la trouverez pas par réflexion.
Dans notre projet, tous les paramètres des méthodes publiques sont annotés (sans compter les primitives), s'il est entendu que le paramètre ne peut pas être nul, si l'inverse est supposé, alors le paramètre est annoté par spring @Nullable. Vous pouvez vous impliquer dans cela, nous rechercherons toutes les méthodes publiques et tous les paramètres qui ne sont pas marqués avec @Nullable et ne sont pas des primitives.
Nous voulons dire que pour tous les autres cas, l'annotation @NonNull devrait être sur les paramètres.
Pour plus de commodité, dans la mesure du possible, nous diffuserons la logique par des méthodes privées, pour commencer, nous aurons toutes les méthodes publiques:
private List<Method> getPublicMethods(final Class clazz) { return Arrays.stream(clazz.getDeclaredMethods()) .filter(METHOD_FILTER) .collect(toList()); }
où METHOD_FILTER est un prédicat régulier dans lequel on dit que:
- La méthode doit être publique
- Il ne doit pas être syntétique (et cela se produit lorsque vous avez une méthode avec un paramètre brut)
- Il ne doit pas être abstrait (sur les classes abstraites séparément et ci-dessous)
- Le nom de la méthode ne doit pas être égal (dans le cas où une sorte de personne malveillante décide de remplir une classe avec égal égal () à l'entrée de notre framework POJO)
Après avoir obtenu toutes les méthodes dont nous avons besoin, nous commençons à les trier en boucle,
si la méthode n'a aucun paramètre, alors ce n'est pas notre candidat:
if (method.getParameterCount() == 0) { continue; }
S'il existe des paramètres, nous devons comprendre s'ils sont annotés @NonNull (plus précisément, devraient-ils l'être, selon
la logique- méthode publique
- pas @Nullable
- pas primitif
Pour ce faire, faites une carte et mettez-y nos paramètres selon la séquence de la méthode, et en face d'eux, nous mettons un drapeau qui dit si l'annotation @NonNull doit être au-dessus ou non:
int nonNullAnnotationCount = 0; int index = 0; val parameterCurrentMethodArray = method.getParameters(); val notNullAnnotationParameterMap = new HashMap<Integer, Boolean>(); for (val parameter : parameterCurrentMethodArray) { if (isNull(parameter.getAnnotation(Nullable.class)) && isFalse(parameter.getType().isPrimitive())) { notNullAnnotationParameterMap.put(index++, true); nonNullAnnotationCount++; } else { notNullAnnotationParameterMap.put(index++, false); } } if (nonNullAnnotationCount == 0) { continue; }
cette carte nous est utile pour ensuite appeler la méthode et la passer nulle à tous les paramètres avec l'annotation @NonNull à son tour, et pas seulement la première.
Le paramètre nonNullAnnotationCount compte combien de paramètres de la méthode doivent être annotés @NonNull, il déterminera le nombre d'interactions d'intégration d'appel pour chaque méthode.
Soit dit en passant, s'il n'y a pas d'annotations @NonNull (il y a des paramètres, mais tous sont primitifs ou @Nullable), alors il n'y a rien à dire:
if (nonNullAnnotationCount == 0) { continue; }
Nous avons en main une carte des paramètres. Nous savons combien de fois appeler une méthode et dans quelles positions mettre à zéro, la question est petite (comme je le pensais naïvement sans comprendre), nous devons créer une instance de la classe et appeler des méthodes sur elles.
Les problèmes commencent lorsque vous réalisez à quel point une instance est différente: elle peut être une classe privée, elle peut être une classe avec un constructeur par défaut, avec un constructeur avec des paramètres, avec tel ou tel constructeur, une classe abstraite, une interface (avec ses méthodes par défaut, qui sont également publiques et qui doivent également être testés).
Et lorsque nous avons construit l'instance par hook ou par escroc, nous devons passer les paramètres à la méthode invoke et ici aussi étendre: comment créer une instance de la classe finale? et Enum? et primitif? et un tableau de primitives (qui est également un objet et peut également être annoté).
Eh bien, faisons-le dans l'ordre.
Le premier cas est une classe avec un constructeur privé:
if (ONLY_ONE_PRIVATE_CONSTRUCTOR_FILTER.test(clazz)) { notNullAnnotationParameterMap.put(currentNullableIndex, false); method.invoke(clazz, invokeMethodParameterArray); makeErrorMessage(method); }
puis nous invoquons simplement notre méthode invoke, lui transmettons l'éclat venu de l'extérieur au test et un tableau de paramètres dans lequel null est déjà chargé à la première position avec le drapeau pour l'annotation @NonNull (rappelez-vous, ci-dessus, nous avons créé la carte @ NonNulls), nous commençons exécuter en boucle et créer un tableau de paramètres, en changeant alternativement la position du paramètre nul et en mettant à zéro l'indicateur avant d'appeler la méthode, de sorte que dans la prochaine intégration, l'autre paramètre devienne nul.
En code, cela ressemble à ceci:
val invokeMethodParameterArray = new Object[parameterCurrentMethodArray.length]; boolean hasNullParameter = false; int currentNullableIndex = 0; for (int i = 0; i < invokeMethodParameterArray.length; i++) { if (notNullAnnotationParameterMap.get(i) && isFalse(hasNullParameter)) { currentNullableIndex = i; invokeMethodParameterArray[i] = null; hasNullParameter = true; } else { mappingParameter(parameterCurrentMethodArray[i], invokeMethodParameterArray, i); } }
La première option d'instanciation a été réglée.
D'autres interfaces, il est impossible de prendre et de créer une instance d'une interface (elle n'a même pas de constructeur).
Par conséquent, avec l'interface, ce sera comme ceci:
if (INTERFACE_FILTER.test(clazz)) { notNullAnnotationParameterMap.put(currentNullableIndex, false); method.invoke(createInstanceByDynamicProxy(clazz, invokeMethodParameterArray), invokeMethodParameterArray); makeErrorMessage(method); }
createInstanceByDynamicProxy nous permet de créer une instance sur une classe si elle implémente au moins une interface, ou est elle-même une interface
Nuancegardez à l'esprit qu'ici, c'est fondamentalement l'interface qui implémente la classe, l'interface de type (et non certaines comparables) est importante, dans laquelle il existe des méthodes que vous implémentez dans la classe cible, sinon l'instance vous surprendra avec son type
mais à l'intérieur c'est comme ça:
private Object createInstanceByDynamicProxy(final Class clazz, final Object[] invokeMethodParameterArray) { return newProxyInstance( currentThread().getContextClassLoader(), new Class[]{clazz}, (proxy, method1, args) -> { Constructor<Lookup> constructor = Lookup.class .getDeclaredConstructor(Class.class); constructor.setAccessible(true); constructor.newInstance(clazz) .in(clazz) .unreflectSpecial(method1, clazz) .bindTo(proxy) .invokeWithArguments(invokeMethodParameterArray); return null; } ); }
RâteauSoit dit en passant, il y avait aussi quelques râteaux ici, je ne me souviens plus lesquels, il y en avait beaucoup, mais vous devez créer un proxy via Lookup.class
L'instance suivante (ma préférée) est une classe abstraite. Et ici, le proxy dynamique ne nous aidera plus, car si une classe abstraite implémente une sorte d'interface, ce n'est clairement pas le type que nous aimerions. Et juste comme ça, nous ne pouvons pas prendre et créer newInstance () à partir d'une classe abstraite. Ici CGLIB viendra à notre aide, une librairie de printemps qui crée des proxys basés sur l'héritage, mais le problème est que la classe cible doit avoir un constructeur par défaut (sans paramètres)
PotinsBien qu'à en juger par les ragots sur Internet depuis le printemps 4, CGLIB peut fonctionner sans, donc: ça ne marche pas!
L'option pour instancier une classe abstraite serait la suivante:
if (isAbstract(clazz.getModifiers())) { createInstanceByCGLIB(clazz, method, invokeMethodParameterArray); makeErrorMessage(); }
makeErrorMessage (), qui était déjà vu dans les exemples de code, supprime le test, si nous appelions la méthode avec le paramètre annoté @NonNull passant null et qu'il ne tombait pas, alors le test ne fonctionnait pas, vous devez abandonner.
Pour le mappage de paramètres, nous avons une méthode commune qui peut mapper et verrouiller les paramètres du constructeur et de la méthode, elle ressemble à ceci:
private void mappingParameter(final Parameter parameter, final Object[] methodParam, final int index) throws InstantiationException, IllegalAccessException { if (isFinal(parameter.getType().getModifiers())) { if (parameter.getType().isEnum()) { methodParam[index] = Enum.valueOf( (Class<Enum>) (parameter.getType()), parameter.getType().getEnumConstants()[0].toString() ); } else if (parameter.getType().isPrimitive()) { mappingPrimitiveName(parameter, methodParam, index); } else if (parameter.getType().getTypeName().equals("byte[]")) { methodParam[index] = new byte[0]; } else { methodParam[index] = parameter.getType().newInstance(); } } else { methodParam[index] = mock(parameter.getType()); } }
Faites attention à la création d'Enum (cerise sur le gâteau), en général, vous ne pouvez pas simplement prendre et créer Enum.
Ici pour les paramètres finaux votre propre mapping, pour les non finaux le vôtre, puis simplement dans le texte (code).
Eh bien, après avoir créé les paramètres pour le constructeur et pour la méthode, nous formons notre instance:
val firstFindConstructor = clazz.getConstructors()[0]; val constructorParameterArray = new Object[firstFindConstructor.getParameters().length]; for (int i = 0; i < constructorParameterArray.length; i++) { mappingParameter(firstFindConstructor.getParameters()[i], constructorParameterArray, i); } notNullAnnotationParameterMap.put(currentNullableIndex, false); createAndInvoke(clazz, method, invokeMethodParameterArray, firstFindConstructor, constructorParameterArray); makeErrorMessage(method);
Nous savons déjà avec certitude que puisque nous avons atteint cette étape du code, cela signifie que nous avons au moins un constructeur, nous pouvons en prendre n'importe quel pour créer une instance, donc nous prenons le premier que nous voyons, voyons s'il a des paramètres dans le constructeur et sinon, alors appelons comme ceci:
method.invoke(spy(clazz.getConstructors()[0].newInstance()), invokeMethodParameterArray);
Eh bien, s'il y a quelque chose comme ça:
method.invoke(spy(clazz.getConstructors()[0].newInstance()), invokeMethodParameterArray);
C'est la logique qui se produit dans la méthode createAndInvoke () que vous avez vue un peu plus haut.
La version complète de la classe de test sous le spoiler, je ne l'ai pas téléchargée sur git, comme je l'ai écrit sur un projet de travail, mais en fait c'est juste une classe qui peut être héritée dans vos tests et utilisée.
Code source public class TestUtil { private static final Predicate<Method> METHOD_FILTER = method -> isPublic(method.getModifiers()) && isFalse(method.isSynthetic()) && isFalse(isAbstract(method.getModifiers())) && isFalse(method.getName().equals("equals")); private static final Predicate<Class> ONLY_ONE_PRIVATE_CONSTRUCTOR_FILTER = clazz -> clazz.getConstructors().length == 0 && isFalse(clazz.isInterface()); private static final Predicate<Class> INTERFACE_FILTER = clazz -> clazz.getConstructors().length == 0; private static final BiPredicate<Exception, Parameter> LOMBOK_ERROR_FILTER = (exception, parameter) -> isNull(exception.getCause().getMessage()) || isFalse(exception.getCause().getMessage().equals(parameter.getName())); protected void assertNonNullAnnotation(final Class clazz) throws Throwable { for (val method : getPublicMethods(clazz)) { if (method.getParameterCount() == 0) { continue; } int nonNullAnnotationCount = 0; int index = 0; val parameterCurrentMethodArray = method.getParameters(); val notNullAnnotationParameterMap = new HashMap<Integer, Boolean>(); for (val parameter : parameterCurrentMethodArray) { if (isNull(parameter.getAnnotation(Nullable.class)) && isFalse(parameter.getType().isPrimitive())) { notNullAnnotationParameterMap.put(index++, true); nonNullAnnotationCount++; } else { notNullAnnotationParameterMap.put(index++, false); } } if (nonNullAnnotationCount == 0) { continue; } for (int j = 0; j < nonNullAnnotationCount; j++) { val invokeMethodParameterArray = new Object[parameterCurrentMethodArray.length]; boolean hasNullParameter = false; int currentNullableIndex = 0; for (int i = 0; i < invokeMethodParameterArray.length; i++) { if (notNullAnnotationParameterMap.get(i) && isFalse(hasNullParameter)) { currentNullableIndex = i; invokeMethodParameterArray[i] = null; hasNullParameter = true; } else { mappingParameter(parameterCurrentMethodArray[i], invokeMethodParameterArray, i); } } try { if (ONLY_ONE_PRIVATE_CONSTRUCTOR_FILTER.test(clazz)) { notNullAnnotationParameterMap.put(currentNullableIndex, false); method.invoke(clazz, invokeMethodParameterArray); makeErrorMessage(method); } if (INTERFACE_FILTER.test(clazz)) { notNullAnnotationParameterMap.put(currentNullableIndex, false); method.invoke(createInstanceByDynamicProxy(clazz, invokeMethodParameterArray), invokeMethodParameterArray); makeErrorMessage(method); } if (isAbstract(clazz.getModifiers())) { createInstanceByCGLIB(clazz, method, invokeMethodParameterArray); makeErrorMessage(); } val firstFindConstructor = clazz.getConstructors()[0]; val constructorParameterArray = new Object[firstFindConstructor.getParameters().length]; for (int i = 0; i < constructorParameterArray.length; i++) { mappingParameter(firstFindConstructor.getParameters()[i], constructorParameterArray, i); } notNullAnnotationParameterMap.put(currentNullableIndex, false); createAndInvoke(clazz, method, invokeMethodParameterArray, firstFindConstructor, constructorParameterArray); makeErrorMessage(method); } catch (final Exception e) { if (LOMBOK_ERROR_FILTER.test(e, parameterCurrentMethodArray[currentNullableIndex])) { makeErrorMessage(method); } } } } } @SneakyThrows private void createAndInvoke( final Class clazz, final Method method, final Object[] invokeMethodParameterArray, final Constructor firstFindConstructor, final Object[] constructorParameterArray ) { if (firstFindConstructor.getParameters().length == 0) { method.invoke(spy(clazz.getConstructors()[0].newInstance()), invokeMethodParameterArray); } else { method.invoke(spy(clazz.getConstructors()[0].newInstance(constructorParameterArray)), invokeMethodParameterArray); } } @SneakyThrows private void createInstanceByCGLIB(final Class clazz, final Method method, final Object[] invokeMethodParameterArray) { MethodInterceptor handler = (obj, method1, args, proxy) -> proxy.invoke(clazz, args); if (clazz.getConstructors().length > 0) { val firstFindConstructor = clazz.getConstructors()[0]; val constructorParam = new Object[firstFindConstructor.getParameters().length]; for (int i = 0; i < constructorParam.length; i++) { mappingParameter(firstFindConstructor.getParameters()[i], constructorParam, i); } for (val constructor : clazz.getConstructors()) { if (constructor.getParameters().length == 0) { val proxy = Enhancer.create(clazz, handler); method.invoke(proxy.getClass().newInstance(), invokeMethodParameterArray); } } } } private Object createInstanceByDynamicProxy(final Class clazz, final Object[] invokeMethodParameterArray) { return newProxyInstance( currentThread().getContextClassLoader(), new Class[]{clazz}, (proxy, method1, args) -> { Constructor<Lookup> constructor = Lookup.class .getDeclaredConstructor(Class.class); constructor.setAccessible(true); constructor.newInstance(clazz) .in(clazz) .unreflectSpecial(method1, clazz) .bindTo(proxy) .invokeWithArguments(invokeMethodParameterArray); return null; } ); } private void makeErrorMessage() { fail(" @NonNull DefaultConstructor "); } private void makeErrorMessage(final Method method) { fail(" " + method.getName() + " @NonNull"); } private List<Method> getPublicMethods(final Class clazz) { return Arrays.stream(clazz.getDeclaredMethods()) .filter(METHOD_FILTER) .collect(toList()); } private void mappingParameter(final Parameter parameter, final Object[] methodParam, final int index) throws InstantiationException, IllegalAccessException { if (isFinal(parameter.getType().getModifiers())) { if (parameter.getType().isEnum()) { methodParam[index] = Enum.valueOf( (Class<Enum>) (parameter.getType()), parameter.getType().getEnumConstants()[0].toString() ); } else if (parameter.getType().isPrimitive()) { mappingPrimitiveName(parameter, methodParam, index); } else if (parameter.getType().getTypeName().equals("byte[]")) { methodParam[index] = new byte[0]; } else { methodParam[index] = parameter.getType().newInstance(); } } else { methodParam[index] = mock(parameter.getType()); } } private void mappingPrimitiveName(final Parameter parameter, final Object[] methodParam, final int index) { val name = parameter.getType().getName(); if ("long".equals(name)) { methodParam[index] = 0L; } else if ("int".equals(name)) { methodParam[index] = 0; } else if ("byte".equals(name)) { methodParam[index] = (byte) 0; } else if ("short".equals(name)) { methodParam[index] = (short) 0; } else if ("double".equals(name)) { methodParam[index] = 0.0d; } else if ("float".equals(name)) { methodParam[index] = 0.0f; } else if ("boolean".equals(name)) { methodParam[index] = false; } else if ("char".equals(name)) { methodParam[index] = 'A'; } } }
Conclusion
Ce code fonctionne et teste les annotations dans un projet réel, pour le moment il n'y a qu'une seule option possible, quand tout ce qui est dit peut être réduit.
Déclarez un setter Lombock dans la classe (s'il y a un spécialiste qui ne place pas le setter dans la classe Pojo, bien que cela ne se produise pas) et le champ sur lequel le setter sera déclaré ne sera pas définitif.
Ensuite, le framework va gentiment dire qu'il existe une méthode publique, et qu'il a un paramètre sur lequel il n'y a pas d'annotation @NonNull, la solution est simple: déclarer explicitement setter et annoter son paramètre en fonction du contexte de la logique @ NonNull / @ Nullable.
Notez que si vous voulez que je sois lié au nom du paramètre de méthode dans vos tests (ou autre chose), dans Runtime les noms des variables dans les méthodes ne sont pas disponibles par défaut, vous trouverez arg [0] et arg [1], etc. .
Pour activer l'affichage des noms de méthode dans Runtime, utilisez le plugin Maven:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>${maven.compiler.plugin.version}</version> <configuration> <source>${compile.target.source}</source/> <target>${compile.target.source}</target> <encoding>${project.build.sourceEncoding}</encoding> <compilerArgs><arg>-parameters</arg></compilerArgs> </configuration> </plugin>
et en particulier cette clé:
<compilerArgs><arg>-parameters</arg></compilerArgs>
J'espère que vous étiez intéressé.