Points Clés
- L'historique de Java
- L'API Reflection
- Java Native
- Le futur de Java
Java a beaucoup évolué au cours de ses 25 ans, principalement en termes de performances, d'intégration avec des conteneurs et aussi du fait qu'il se met toujours à jour tous les six mois. Mais comment évoluent les frameworks Java dans cette nouvelle ère, où de nouvelles demandes d'architecture logicielle apparaissent, comme l'actuel cloud native ? À quels défis devront-ils faire face pour maintenir Java en vie pendant encore 25 ans ? Le but de cet article est de parler un peu de l'histoire et des défis pour les nouvelles générations et des tendances des frameworks que, en tant que développeurs Java, nous utilisons au quotidien. Afin de comprendre les mouvements actuels, il est important de comprendre un peu l'histoire de l'architecture informatique et logicielle. Parlons de la relation entre l'homme et la machine, un point très important, que nous pouvons diviser en trois étapes :
- Une machine pour de nombreuses personnes : l'ère du mainframe
- Une machine pour une personne : le PC de génération ou l'ordinateur personnel
- De nombreuses machines pour de nombreuses personnes : actuellement, le nombre d'appareils est beaucoup plus élevé, par exemple, il est très courant qu'une seule personne ait une smartwatch, un téléphone portable, un ordinateur portable et les utilise simultanément
L'abondance des machines a également eu un impact sur l'architecture logicielle, principalement sur le nombre de couches physiques :
- Une seule couche : liée au Mainframe
- Trois couches physiques : le modèle le plus utilisé aujourd'hui
- L'ère des microservices : le bas prix des ordinateurs a contribué à augmenter le nombre de couches physiques
Un point important est que l'environnement cloud, associé à l'automatisation, a accéléré plusieurs processus, du nombre de couches physiques au nombre de déploiements. Par exemple, il fut un temps dans l'histoire où la normale était d'un déploiement par an. Avec la méthodologie Agile, la culture d'intégration du secteur du développement avec celui des opérations et de nombreuses autres techniques, a fait passer cette fréquence à une fois par mois, par jour, voire plusieurs déploiements quotidiens, y compris le vendredi à 18 heures.
Quel est le problème avec Java ?
Avec l'évolution des architectures vers un environnement axé sur le cloud, des déploiements constants et des automatisations telles que CI / CD, plusieurs exigences ont changé et sont par conséquent venues les différentes plaintes envers Java, notamment:
- Startup : certainement le plus gros reproche. En comparaison avec d'autres langues, le temps de démarrage est très élevé
- Démarrage à froid : JIT est une merveilleuse ressource qui permet de nombreuses améliorations du code à l'exécution, cependant, qu'en est-il de l'application qui effectue des déploiements constants ? Le JIT n'est pas aussi critique qu'un meilleur démarrage
- Mémoire : certes, la JVM est toujours l'un des rares langages à pouvoir profiter d'un thread natif, cependant, cela pose un gros problème par rapport à Green Thread, qui est lié à la consommation de mémoire. C'est l'une des raisons pour lesquelles nous utilisons des pools de threads. Cependant, les fonctionnalités du thread natif ont-elles un sens dans un environnement de programmation réactif ? Un autre point qui nécessite beaucoup de ressources mémoire sont le JIT (Just in time compiler) et le GC (Garbage Collector) qui agissent également dans plusieurs threads, mais avons-nous besoin de cette ressource lorsque nous travaillons avec serverless ? Après tout, il a tendance à s'exécuter une seule fois et plus tard, la ressource sera renvoyée au système d'exploitation. Par curiosité, Java travaille sur le concept de Virtual Thread.
Malgré cela, Java reste un langage populaire, en particulier en termes de performance. Il est courant de voir des entreprises de technologie sortir d'autres langages et migrer vers Java comme Spotify, qui a laissé Python de côté et migré vers Java, précisément pour avoir plus de performances dans leurs applications. Un autre point important concerne les applications Big Data, où la plupart d'entre elles sont développées en Java comme Hadoop, la base de données NoSQL Cassandra et l'indexeur de texte Lucene.
En regardant de plus près les codes des applications d'entreprise, en moyenne 90% du code des projets appartiennent à des tiers, c'est-à-dire que presque tout le code d'une application Java est réalisé par des frameworks.
Metadata dans le monde Java
Mais pourquoi utilisons-nous autant de frameworks? Une réponse simple serait, la grande majorité de ces outils facilitent le processus d'une certaine manière, en particulier dans une conversion ou un mappeur. Par exemple, lors de la conversion d'entités Java en fichiers XML ou en bases de données.
Le but est d'essayer de diminuer l'impédance entre les paradigmes. Par exemple, réduire la distance par rapport aux bases de données relationnelles et à l'orientation des objets, sans parler des bonnes pratiques où Java fonctionne avec camelCase et les bases de données relationnelles fonctionnent avec snake_case. Une des stratégies pour fournir ce service utilise la création de métadonnées. Ce sont ces métadonnées qui garantissent que nous établissons la relation entre la base de données et une classe Java. Par exemple, il est très courant dans les premières spécifications du monde Java de travailler avec ce type d'approche comme l’orm.xml réalisé par JPA.
Par exemple, en créant une entité qui représente les dieux de la mythologie grecque, la classe ressemblerait à ceci :
public class God {
private String id;
private String name;
private Integer age;
//getter and setter
}
La prochaine étape serait de créer un fichier XML pour établir la relation entre la classe Java et les instructions de mapping avec la base de données. Ce fichier sera lu au moment de l'exécution pour générer des métadonnées.
<entity class="entity.God" name="God">
<table name="God"/>
<attributes>
<id name="id"/>
<basic name="name">
<column name="NAME" length="100"/>
</basic>
<basic name="age"/>
</attributes>
</entity>
Ces métadonnées sont lues et générées au moment de l'exécution et ce sont ces données qui rendront la partie la plus dynamique du langage. Cependant, cette génération de données serait inutile s'il n'y a pas de ressource permettant d'apporter des modifications au moment de l'exécution. Tout cela est possible grâce à une ressource informatique qui vous donne la possibilité de traiter, examiner et effectuer une introspection de types de structures de données. Nous parlons de la réflexion. La fonctionnalité Reflection, contrairement à ce que pensent de nombreux développeurs Java, n'est pas une fonctionnalité exclusive dans ce langage, elle existe également dans les langages GO, C #, PHP et Python. Dans le monde Java, le package de l’API Reflection existe depuis la version 1.1. Cette fonctionnalité permet la création d'outils ou de frameworks génériques, qui augmentent la productivité. Cependant, de nombreuses personnes ont eu des problèmes avec cette approche.
Avec l'évolution du langage et de tout l'écosystème Java, les développeurs ont remarqué que rendre ces métadonnées générées très éloignées du code (comme c'était le cas jusque-là avec les fichiers XML), n'était souvent pas intuitif, sans parler de l'augmentation de la difficulté de temps pour effectuer la maintenance, car il était nécessaire de le changer à deux endroits, par exemple, lorsqu'il était nécessaire de mettre à jour un champ, il fallait changer la classe Java et la base de données.
Afin de rendre cela encore plus facile, l'une des améliorations qui ont eu lieu dans Java 5 à la mi-2004 a été le support des métadonnées pour Java via la JSR 175, connue affectueusement par les initiés sous le nom d’annotations en Java. Par exemple, pour revenir à l'entité précédente, le second fichier pour la configuration n’est plus nécessaire. Toutes les informations sont rassemblées dans un seul fichier.
@Entity
public class God {
@Id
private String id;
@Column
private String name;
@Column
private Integer age;
}
Certes, la combinaison de la réflexion et des notations a apporté plusieurs avantages au monde des outils Java au fil des ans. Il y a plusieurs avantages à partir desquels nous pouvons mettre en évidence la connectivité, c'est-à-dire que puisque toute validation a lieu au moment de l'exécution, il est possible d'ajouter des bibliothèques qui fonctionnent dans le style «plug and play». Après tout, qui n'a jamais été surpris par l’API ServiceLoader et toute la "magie" qui peut être faite avec ?
La réflexion garantit également un côté plus dynamique du langage. Grâce à cette fonctionnalité, il est possible de faire une encapsulation très forte, comme la création d'attributs sans getter et setter qui à l’exécution sera résolu avec la méthode setAccessible.
Class<Good> type = ...;
Object value =...;
Entity annotation = type.getAnnotation(Entity.class);
Constructor<?>[] constructors = type.getConstructors();
T instance = (T) constructors[0].newInstance();
for (Field field : type.getDeclaredFields()) {
field.setAccessible(true);
field.set(instance, value);
}
Mais la réflexion a des problèmes
Comme pour chaque décision et choix d'architecture, il y a des impacts négatifs. Le point important dans ce cas est que même l'utilisation de Reflection pose des problèmes aux applications qui utilisent ces frameworks.
Le premier point est que son plus grand avantage vient en conjonction avec les inconvénients du traitement à l’'exécution. Ainsi, la première étape qu'une application doit faire est de rechercher les informations des métadonnées, de sorte que l'application ne sera fonctionnelle que lorsque l'ensemble du processus sera terminé. Imaginez un conteneur d'injection de dépendances, qui, comme CDI, devra analyser les classes pour vérifier les contextes et dépendances respectifs. Cette durée tend à augmenter beaucoup avec la vulgarisation de l'écosystème CDI grâce à l'extension CDI.
Outre le temps de démarrage d'une application, grâce aux traitements à l’'exécution, il y a aussi le problème de la consommation de mémoire. Il est très naturel que les frameworks, afin d'éviter un accès constant aux API de réflexions, créent leur propre structure mémoire. Cependant, en plus de cette consommation de mémoire lors de l'utilisation de toute ressource nécessitant une réflexion dans une instance de classe, il chargera toutes les informations à la fois dans la classe ReflectionData en software reference. Si vous n'êtes pas familier avec les références en Java, il est important de dire que SoftwareReference ne sera éligible pour le GC que s'il ne dispose pas de suffisamment de mémoire dans le Heap.
Autrement dit, un simple getSimpleName devra charger toutes les informations de la classe.
//java.lang.Class
public String getSimpleName() {
ReflectionData<T> rd = reflectionData();
String simpleName = rd.simpleName;
if (simpleName == null) {
rd.simpleName = simpleName = getSimpleName0();
}
return simpleName;
}
Sur la base de ces informations, lors de l'utilisation de la combinaison de réflexion et des annotations lors de l'exécution, nous aurons à la fois un problème de traitement et également lors du démarrage d'une application lorsque nous analyserons la consommation de mémoire. Chaque framework a son but et, en travaillant avec ces instances en Java, CDI recherche des beans pour définir les portées et JPA pour effectuer le mapping entre Java et la base de données relationnelle, il est naturel que chacun effectue ses propres traitement et nécessite de disposer des métadonnées en fonction de vos besoins.
Ce type de conception n'était pas un problème avant le moment présent, cependant, comme nous l'avons déjà mentionné, la façon dont nous fabriquons des logiciels a radicalement changé de la programmation au déploiement. Il était très courant de déployer dans une timebox où une période d'inactivité du système était créée, généralement pendant la nuit, au point que le processus de «warmup» ne posait jamais de problème. Cependant, avec plusieurs déploiements tout au long de la journée, l'évolutivité horizontale automatique et d'autres changements dans le processus de développement logiciel ont fait du temps de démarrage de certaines applications une exigence minimale.
Un autre point qui mérite d'être mentionné est certainement lié à l'ère du cloud.
Actuellement, le nombre de services fournis par le cloud a considérablement augmenté. Parmi eux, il y a serveless, car l'approche actuelle n'a pas de sens dans un processus qui ne sera exécuté qu'une seule fois, puis détruit. Graeme Rocher parle de ces défis dans le monde du framework Java dans l'une de ses conférences les plus célèbres pour présenter Micronaut.
Outre les problèmes de retard lié au chargement de tout en mémoire et de latence au démarrage par rapport à l’utilisation de l’API Reflection, il existe également un autre problème, la vitesse et les performances d'exécution par rapport au code natif, en plus d'utiliser la Reflection pour rendre la solution plus lente qu’un code généré. Autant qu'il existe plusieurs optimisations dans le monde JIT, travailler avec le rend encore meilleur, mais ces optimisations sont encore très timides par rapport au code natif.
Une option pour améliorer les optimisations et garantir des validations dynamiques dans les frameworks serait de faire cette manipulation sans avoir besoin de Reflection, le but serait de préparer les classes pour le démarrage de l'application. En d'autres termes, pendant l'exécution, lisez les informations à partir des annotations et créez des classes qui effectuent de telles manipulations.
Ce type de manipulation est possible grâce à la JSR 199, ce qui permet à ces classes de manipulation de donner une optimisation plus forte au JIT.
JavaSource<Entity> source = new JavaSource() {
@Override
public String getName() {
return fullClassName;
}
@Override
public String getJavaSource() {
return source;
}
};
JavaFileObject fileObject = new JavaFileObject(...);
compiler.getStandardFileManager(diagnosticCollector, ...);
Cependant, le point important est que dans le processus, en plus de la lecture des informations de classe, l'interpolation de texte pour générer une classe en plus de la compilation de classe sera incluse. Autrement dit, la consommation de mémoire a tendance à augmenter considérablement avec le temps et la puissance de calcul nécessaires pour démarrer une application.
Dans une analyse comparant l'accès direct, la réflexion et combiné avec l'API de compilation, Geoffrey De Smet mentionne dans un article que lors de l'utilisation de l'approche de compilation au moment de l'exécution, le code est environ 5% plus lent par rapport au code natif, c'est-à-dire qu'il est 104% plus lent qu'avec la réflexion uniquement. Cependant, il y a un coût très élevé au démarrage de l'application.
La solution de cold start en Java
Comme nous l'avons mentionné dans l'histoire des applications dans le monde Java, la Reflection a été largement utilisée, principalement pour sa connectivité, mais cela a amené à considérer le temps de démarrage de l'application et la consommation de mémoire élevée comme un défi majeur. Une solution possible est que pour générer ces métadonnées au moment de l'exécution, elles ont été faites lors de la compilation. Cette approche présenterait certains avantages:
- Cold-start : l'ensemble du processus aurait lieu au moment de la compilation, c'est-à-dire qu'au démarrage de l'application, les métadonnées seraient déjà générées et prêtes à être utilisées
- Mémoire : les données traitées ne sont pas nécessaires, par exemple pour accéder aux informations ReflectionData des classes, économiser de la mémoire
- Vitesse : comme déjà mentionné, l'accès via Reflection a tendance à ne pas être aussi rapide que le code compilé, en plus d'être soumis à des optimisations JIT
Heureusement, cette solution existe déjà dans le monde Java grâce à l'API JSR 269 Pluggable Annotation Processing. En plus des avantages que nous avons mentionnés ci-dessus, il y a aussi une fonctionnalité qui a émergé dans Java 9, la JEP 295 Ahead of Time Compilation qui parmi ses avantages est la possibilité de compiler une classe en code natif, très intéressante pour les applications qui veulent démarrer rapidement. Cependant, il manque quelques points : l'effet plug and play, puisque toutes les validations sont faites lors de la compilation, une nouvelle bibliothèque devra être utilisée explicitement.
En pensant à un mappeur de base de données, il est important de laisser soit l'attribut public, soit le getter et le setter visibles d'une manière ou d'une autre, même si cela n'a pas de sens pour l'entreprise, comme lorsque vous travaillez avec un identifiant généré automatiquement, car le framework fournira cette ID, un setter sur cet attribut a tendance à être un échec de l'encapsulation.
@SupportedAnnotationTypes("org.soujava.medatadata.api.Entity")
public class EntityProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
//....
}
}
L'utilisation du code natif
L'une des grandes caractéristiques du serverless est qu'il fonctionne comme un backend en tant que service, c'est-à-dire que nous payons pour l'exécution du processus. Suivant ce raisonnement, l'utilisation d'une JVM à plusieurs reprises n'est pas nécessaire car elle possède des fonctionnalités qui n'ont pas de sens pour une seule exécution. Parmi eux, on peut citer le GC et aussi le JIT. Une des stratégies consisterait à utiliser GraalVM pour créer des images natives. Cette approche rend la JVM inutile au moment de l'exécution. Sur la base de l'organisation structurée des ordinateurs d'Andrew S. Tanenbaum, nous aurions les couches suivantes avec et sans la JVM.
Très utile pour une seule exécution, cependant, il est important de noter que lorsque nous avons de nombreuses exécutions, il y a des cas où les performances peuvent être supérieures avec la JVM au lieu d'utiliser uniquement le code natif.
Conclusion
Le nombre de frameworks dans le monde Java a tendance à augmenter, en particulier lorsque nous prenons en compte la diversité des exigences et des styles que les applications ont tendance à avoir, soit en se concentrant sur un démarrage potentiel, soit en se concentrant sur la connectivité. La direction que prendront les architectures est encore incertaine, que ce soit dans les microservices, les macroservices, ou simplement, le retour au monolithe. Cependant, il est très probable qu'il y aura plusieurs options encore plus à une époque où l'adoption du cloud se renforce, sans parler de la croissance et des styles de services existant dans cette voie. Et ce sera aux institutions comme la Fondation Eclipse avec Jakarta EE de comprendre toutes ces voies et de travailler sur les spécifications qui soutiendront ces styles.
A propos de l'auteur
Otávio Santana est un ingénieur logiciel avec une vaste expérience dans le développement open source, avec plusieurs contributions à JBoss Weld, Hibernate, Apache Commons et à d'autres projets. Axé sur le développement multilingue et les applications haute performance, Otávio a travaillé sur de grands projets dans les domaines de la finance, du gouvernement, des médias sociaux et du commerce électronique. Membre du comité exécutif du JCP et de plusieurs groupes d'experts JSR, il est également un champion Java et a reçu le JCP Outstanding Award et le Duke's Choice Award.