
Présentation
Presque n'importe quel système d'information interagit d'une manière ou d'une autre avec des magasins de données externes. Dans la plupart des cas, il s'agit d'une base de données relationnelle et, souvent, une sorte de cadre ORM est utilisé pour travailler avec des données. ORM élimine la plupart des opérations de routine, offrant à la place un petit ensemble d'abstractions supplémentaires pour travailler avec des données.
Martin Fowler a publié un article intéressant, l'une des principales réflexions: «Les ORM nous aident à résoudre un grand nombre de problèmes dans les applications d'entreprise ... Cet outil ne peut pas être qualifié de joli, mais les problèmes qu'il traite ne sont pas non plus agréables. Je pense que l'ORM mérite plus de respect et de compréhension. »
Nous utilisons ORM de manière très intensive dans le cadre CUBA , nous connaissons donc de première main les problèmes et les limites de cette technologie, car CUBA est utilisé dans divers projets à travers le monde. De nombreux sujets peuvent être abordés en relation avec l'ORM, mais nous nous concentrerons sur l'un d'entre eux: le choix entre les méthodes «paresseuses» (paresseuses) et «gourmandes» (avides) d'échantillonnage de données. Nous parlerons de différentes approches pour résoudre ce problème avec des illustrations de l'API JPA et de Spring, et décrirons également comment (et pourquoi exactement) ORM est utilisé dans CUBA et quel travail nous faisons pour améliorer le travail avec les données dans notre cadre.
Échantillonnage des données: paresseux ou non?
Si votre modèle de données n'a qu'une seule entité, vous ne remarquerez probablement aucun problème lorsque vous travaillez avec ORM. Regardons un petit exemple. Supposons que nous ayons une entité User ()
qui possède deux attributs: ID
et Name ()
:
public class User { @Id @GeneratedValue private int id; private String name;
Pour obtenir une instance de cette entité à partir de la base de données, il suffit d'appeler une méthode de l'objet EntityManager
:
EntityManager em = entityManagerFactory.createEntityManager(); User user = em.find(User.class, id);
Les choses deviennent un peu plus intéressantes lorsqu'une relation un-à-plusieurs apparaît:
public class User { @Id @GeneratedValue private int id; private String name; @OneToMany private List<Address> addresses;
Si nous devons extraire une instance d'utilisateur de la base de données, la question se pose: «Sélectionnons-nous également des adresses?». Et la «bonne» réponse ici est: «Cela dépend de ...» Dans certains cas, nous aurons besoin d'adresses, dans d'autres - pas. En règle générale, ORM propose deux façons de récupérer des enregistrements dépendants: paresseux et gourmand. Par défaut, la plupart des ORM utilisent la méthode paresseuse. Mais, si nous écrivons ce code:
EntityManager em = entityManagerFactory.createEntityManager(); User user = em.find(User.class, 1); em.close(); System.out.println(user.getAddresses().get(0));
... puis nous obtenons l'exception “LazyInitException”
, ce qui “LazyInitException”
terriblement les nouveaux arrivants qui viennent de commencer à travailler avec ORM. Et voici le moment où vous devez commencer une histoire sur ce que sont les instances «attachées» et «détachées» d'une entité, quelles sont les sessions et les transactions.
Oui, cela signifie que l'entité doit être «attachée» à la session afin que vous puissiez sélectionner les données dépendantes. Eh bien, ne fermons pas les transactions tout de suite, et la vie deviendra immédiatement plus facile. Et ici, un autre problème se pose: les transactions s'allongent, ce qui augmente le risque de blocage. Raccourcir les transactions? C'est possible, mais si vous créez beaucoup, beaucoup de petites transactions, nous obtenons le «Conte de Komar Komarovich - un long nez et une Misha poilue - une courte queue» sur la façon dont la horde de minuscules moustiques ours a gagné - cela se produira avec la base de données. Si le nombre de petites transactions augmente considérablement, des problèmes de performances se poseront.
Comme cela a été dit, lors de la récupération des données sur un utilisateur, les adresses peuvent être requises ou non.Par conséquent, selon la logique métier, vous devez sélectionner la collection ou non. Il est nécessaire d'ajouter de nouvelles conditions au code ... Hmmm ... Quelque chose se complique.
Et si vous essayez un autre type d'échantillon?
public class User { @Id @GeneratedValue private int id; private String name; @OneToMany(fetch = FetchType.EAGER) private List<Address> addresses;
Eh bien ... vous ne pouvez pas dire que cela aidera beaucoup. Oui, nous allons nous débarrasser du LazyInit
détesté et il n'est pas nécessaire de vérifier si l'entité est attachée à la session ou non. Mais maintenant, nous pouvons avoir des problèmes de performances, car nous n'avons pas toujours besoin d'adresses, mais nous sélectionnons toujours ces objets dans la mémoire du serveur.
Avez-vous d'autres idées?
Spring jdbc
Certains développeurs sont tellement fatigués d'ORM qu'ils passent à des cadres alternatifs. Par exemple, sur Spring JDBC, qui offre la possibilité de convertir des données relationnelles en données d'objet en mode "semi-automatique". Le développeur écrit des requêtes pour chaque cas où un ensemble particulier d'attributs est nécessaire (ou le même code est réutilisé pour les cas où les mêmes structures de données sont nécessaires).
Cela nous donne une grande flexibilité. Par exemple, vous pouvez sélectionner un seul attribut sans créer l'objet entité correspondant:
String name = this.jdbcTemplate.queryForObject( "select name from t_user where id = ?", new Object[]{1L}, String.class);
Ou sélectionnez un objet sous la forme habituelle:
User user = this.jdbcTemplate.queryForObject( "select id, name from t_user where id = ?", new Object[]{1L}, new RowMapper<User>() { public User mapRow(ResultSet rs, int rowNum) throws SQLException { User user = new User(); user.setName(rs.getString("name")); user.setId(rs.getInt("id")); return user; } });
Vous pouvez également sélectionner une liste d'adresses pour l'utilisateur, il vous suffit d'écrire un peu plus de code et de composer correctement la requête SQL pour éviter le problème des requêtes n + 1 .
Soooo, encore compliqué. Oui, nous contrôlons toutes les requêtes et la façon dont les données sont mappées sur des objets, mais nous devons écrire plus de code, apprendre le SQL et savoir comment les requêtes sont exécutées dans la base de données. Personnellement, je pense que la connaissance de SQL est une compétence requise pour un programmeur d'applications, mais tout le monde ne pense pas de cette façon, et je ne vais pas m'engager dans des polémiques. Après tout, la connaissance des instructions d'assemblage x86 de nos jours est également facultative. Réfléchissons mieux à la façon de faciliter la vie des programmeurs.
JPA EntityGraph
Et prenons un peu de recul et pensons, de quoi avons-nous besoin? Il semble que nous devons simplement indiquer exactement les attributs dont nous avons besoin dans chaque cas. Eh bien, faisons-le! JPA 2.1 a introduit une nouvelle API - EntityGraph (graphique d'entité). L'idée est très simple: nous utilisons des annotations pour décrire ce que nous choisirons dans la base de données. Voici un exemple:
@Entity @NamedEntityGraphs({ @NamedEntityGraph(name = "user-only-entity-graph"), @NamedEntityGraph(name = "user-addresses-entity-graph", attributeNodes = {@NamedAttributeNode("addresses")}) }) public class User { @Id @GeneratedValue private int id; private String name; @OneToMany(fetch = FetchType.LAZY) private Set<Address> addresses;
Deux graphiques sont décrits pour cette entité: user-only-entity-graph
graphique d'entité user-only-entity-graph
ne sélectionne pas l'attribut Addresses
(marqué comme paresseux), tandis que le deuxième graphique indique à ORM de sélectionner cet attribut. Si nous marquons les Addresses
comme désireuses, le graphique sera ignoré et les adresses seront sélectionnées de toute façon.
Ainsi, dans JPA 2.1, vous pouvez échantillonner des données comme ceci:
EntityManager em = entityManagerFactory.createEntityManager(); EntityGraph graph = em.getEntityGraph("user-addresses-entity-graph"); Map<String, Object> properties = Map.of("javax.persistence.fetchgraph", graph); User user = em.find(User.class, 1, properties); em.close();
Cette approche simplifie considérablement le travail, pas besoin de penser séparément aux attributs paresseux et à la longueur des transactions. Un avantage supplémentaire est que le graphique est appliqué au niveau de la requête SQL, donc les données «supplémentaires» ne sont pas sélectionnées dans l'application Java. Mais il y a un petit problème: vous ne pouvez pas dire quels attributs ont été sélectionnés et lesquels ne l'ont pas été. Il existe une API pour vérifier, cela se fait en utilisant la classe PersistenceUtil
:
PersistenceUtil pu = entityManagerFactory.getPersistenceUnitUtil(); System.out.println("User.addresses loaded: " + pu.isLoaded(user, "addresses"));
Mais c'est assez ennuyeux et tout le monde n'est pas prêt à faire de telles vérifications. Y a-t-il autre chose que vous pouvez simplifier et ne pas afficher les attributs qui n'ont pas été sélectionnés?
Projections de printemps
Le Spring Framework a une grande chose appelée Projections (et ce n'est pas la même chose que les projections dans Hibernate ). Si vous devez sélectionner uniquement certains attributs d'une entité, une interface avec les attributs nécessaires est créée et Spring sélectionne les «instances» de cette interface dans la base de données. À titre d'exemple, considérons l'interface suivante:
interface NamesOnly { String getName(); }
Vous pouvez maintenant définir un référentiel Spring JPA pour récupérer les entités utilisateur comme suit:
interface UserRepository extends CrudRepository<User, Integer> { Collection<NamesOnly> findByName(String lastname); }
Dans ce cas, après avoir appelé la méthode findByName, dans la liste résultante, nous obtenons des entités qui n'ont accès qu'aux attributs définis dans l'interface! Selon le même principe, on peut choisir des entités dépendantes, c'est-à-dire sélectionnez immédiatement la relation «maître-détail». De plus, Spring génère du SQL «correct» dans la plupart des cas, c'est-à-dire seuls les attributs décrits dans la projection sont sélectionnés dans la base de données, ce qui est très similaire au fonctionnement des graphiques d'entité.
Il s'agit d'une API très puissante. Lorsque vous définissez des interfaces, vous pouvez utiliser des expressions SpEL, utiliser des classes avec une sorte de logique intégrée au lieu d'interfaces, et bien plus encore, tout est décrit en détail dans la documentation .
Le seul problème avec les projections est qu'à l'intérieur, elles sont implémentées en tant que paires clé-valeur, c'est-à-dire sont en lecture seule. Cela signifie que même si nous définissons une méthode de définition pour la projection, nous ne pourrons pas enregistrer les modifications via les référentiels CRUD ou via EntityManager. Les projections sont donc des DTO qui peuvent être reconvertis en Entité et enregistrés uniquement si vous écrivez votre propre code pour cela.
Comment sélectionner des données dans CUBA
Dès le début du développement du framework CUBA, nous avons essayé d'optimiser la partie du code qui fonctionne avec la base de données. Chez CUBA, nous utilisons EclipseLink comme base pour l'API d'accès aux données. Ce qui est bien avec EclipseLink, c'est qu'il a pris en charge le chargement partiel d'entité dès le début, et c'était un facteur décisif dans le choix entre lui et Hibernate. Dans EclipseLink, vous pouvez spécifier des attributs à charger bien avant l'apparition de la norme JPA 2.1. CUBA a sa propre façon de décrire un graphe d'entité, appelé Vues CUBA . Représentations CUBA est une API plutôt développée, vous pouvez hériter de certaines représentations d'autres, les combiner, en appliquant à la fois aux entités maître et détail. Une autre motivation pour créer des vues CUBA est que nous voulions utiliser des transactions courtes afin de pouvoir travailler avec des entités détachées dans l'interface utilisateur Web.
Dans CUBA, les vues sont décrites dans un fichier XML, comme dans l'exemple ci-dessous:
<view class="com.sample.User" extends="_minimal" name="user-minimal-view"> <property name="name"/> <property name="addresses" view="address-street-only-view"/> </property> </view>
Cette vue sélectionne l'entité User
et son name
attribut local, et sélectionne également des adresses en leur appliquant la vue address-street-only-view
. Tout cela se produit (attention!) Au niveau de la requête SQL. Lorsque la vue est créée, vous pouvez l'utiliser dans la sélection de données à l'aide de la classe DataManager:
List<User> users = dataManager.load(User.class).view("user-edit-view").list();
Cette approche fonctionne bien, tout en consommant du trafic réseau de manière économique, car les attributs inutilisés ne sont tout simplement pas transférés de la base de données vers l'application, mais, comme dans le cas de JPA, il y a un problème: on ne peut pas dire quels attributs de l'entité ont été chargés. Et dans CUBA, il y a une exception “IllegalStateException: Cannot get unfetched attribute [...] from detached object”
, qui, comme LazyInit
, doit avoir été rencontré par tous ceux qui écrivent en utilisant notre framework. Comme dans le JPA, il existe des moyens de vérifier quels attributs ont été chargés et lesquels ne le sont pas, mais, encore une fois, écrire de tels contrôles est une tâche fastidieuse et laborieuse qui dérange beaucoup les développeurs. Il faut inventer autre chose pour ne pas alourdir les gens avec un travail que, en théorie, les machines peuvent faire.
Concept - CUBA View Interfaces
Mais que faire si vous essayez de combiner des graphiques d'entités et des projections? Nous avons décidé de l'essayer et développé des interfaces pour les interfaces de vue d'entité qui suivent l'approche de projection Spring. Ces interfaces sont traduites en vues CUBA au démarrage de l'application et peuvent être utilisées dans le DataManager. L'idée est simple: nous décrivons une interface (ou un ensemble d'interfaces), qui est un graphe d'entité.
interface UserMinimalView extends BaseEntityView<User, Integer> { String getName(); void setName(String val); List<AddressStreetOnly> getAddresses(); interface AddressStreetOnly extends BaseEntityView<Address, Integer> { String getStreet(); void setStreet(String street); } }
Il est à noter que pour certains cas spécifiques, vous pouvez réaliser des interfaces locales, comme dans le cas d' AddressStreetOnly
partir de l'exemple ci-dessus, afin de ne pas «polluer» l'API publique de votre application.
Dans le processus de démarrage d'une application CUBA (dont la plupart est l'initialisation du contexte Spring), nous créons par programmation des vues CUBA et les plaçons dans le référentiel du bean interne en contexte.
Vous devez maintenant modifier légèrement l'implémentation de la classe DataManager afin qu'elle accepte les vues d'interface, et vous pouvez sélectionner des entités de cette manière:
List<UserMinimalView> users = dataManager.load(UserMinimalView.class).list();
Sous le capot, un objet proxy est généré qui implémente l'interface et encapsule l'instance d'entité sélectionnée dans la base de données (de la même manière que dans Hibernate). Et, lorsque le développeur appelle la valeur d'attribut, le proxy délègue l'appel de méthode à l'instance «réelle» de l'entité.
En développant ce concept, nous essayons de tuer deux oiseaux avec une pierre:
- Les données qui ne sont pas décrites dans l'interface ne sont pas chargées dans l'application, ce qui permet d'économiser les ressources du serveur.
- Le développeur ne peut utiliser que les attributs accessibles via l'interface (et, par conséquent, sélectionnés dans la base de données), éliminant ainsi les exceptions
UnfetchedAttribute
dont nous avons parlé plus haut.
Contrairement aux projections Spring, nous encapsulons les entités dans des objets proxy, de plus, chaque interface hérite de l'interface CUBA standard - Entity
. Cela signifie que les attributs Entity View peuvent être modifiés, puis enregistrez ces modifications dans la base de données à l'aide de l'API CUBA standard pour travailler avec les données.
Et, soit dit en passant, le «troisième lièvre» - vous pouvez rendre les attributs en lecture seule si vous définissez une interface avec des méthodes getter uniquement. Ainsi, nous avons déjà défini les règles de modification au niveau de l'API d'entité.
De plus, vous pouvez effectuer certaines opérations locales pour les entités détachées à l'aide des attributs disponibles, par exemple, la conversion de chaîne de nom, comme dans l'exemple ci-dessous:
@MetaProperty default String getNameLowercase() { return getName().toLowerCase(); }
Notez que les attributs calculés peuvent être retirés du modèle de classe d'entité et transférés vers des interfaces applicables à une logique métier particulière.
Une autre caractéristique intéressante est l'héritage d'interface. Vous pouvez créer plusieurs vues avec différents ensembles d'attributs, puis les combiner. Par exemple, vous pouvez créer une interface pour une entité Utilisateur avec les attributs nom et e-mail, et une autre avec les attributs nom et adresses. Maintenant, si vous devez sélectionner le nom, l'adresse e-mail et les adresses, vous n'avez pas besoin de copier ces attributs dans la troisième interface, il vous suffit d'hériter des deux premières vues. Et oui, les instances de la troisième interface peuvent être transmises à des méthodes qui acceptent des paramètres avec le type d'interface parent, les règles de POO sont les mêmes pour tout le monde.
Une conversion entre les vues a également été implémentée - chaque interface a une méthode reload (), dans laquelle vous pouvez passer la classe de vue comme paramètre:
UserFullView userFull = userMinimal.reload(UserFullView.class);
UserFullView peut contenir des attributs supplémentaires, de sorte que l'entité sera rechargée à partir de la base de données, si nécessaire. Et ce processus est retardé. L'accès à la base de données se fera uniquement lors du premier accès aux attributs de l'entité. Cela ralentira un peu le premier appel, mais cette approche a été choisie intentionnellement - si l'instance d'entité est utilisée dans le module «Web», qui contient l'interface utilisateur et ses propres contrôleurs REST, ce module peut être déployé sur un serveur distinct. Et cela signifie que la surcharge forcée de l'entité créera un trafic réseau supplémentaire - accès au module principal puis à la base de données. Ainsi, en reportant la surcharge jusqu'au moment où cela est nécessaire, nous économisons du trafic et réduisons le nombre de requêtes de base de données.
Le concept est conçu comme un module pour CUBA, un exemple d'utilisation peut être téléchargé depuis GitHub .
Conclusion
Il semble que dans un avenir proche, nous continuerons à utiliser massivement ORM dans les applications d'entreprise simplement parce que nous avons besoin de quelque chose qui transformera les données relationnelles en objets. Bien sûr, des solutions spécifiques seront développées pour des applications complexes, uniques et à très haute charge, mais il semble que les frameworks ORM vivront aussi longtemps que les bases de données relationnelles.
Dans CUBA, nous essayons de simplifier au maximum le travail avec ORM, et dans les futures versions, nous introduirons de nouvelles fonctionnalités pour travailler avec les données. Il sera difficile de dire si ce seront des interfaces de présentation ou autre chose, mais je suis sûr d'une chose: nous continuerons à simplifier le travail avec les données dans les futures versions du framework.