Spring Data JPA: Bringing Files

Salutations, c'est le deuxième article sur Spring Data JPA. La première partie a été entièrement consacrée aux râteaux sous-marins, ainsi qu'aux astuces expérimentées. Dans cette partie, nous expliquerons comment affiner le cadre selon vos besoins. Tous les exemples décrits sont disponibles ici .


Comtes


Commençons peut-être par une tâche simple et en même temps courante: lors du chargement d'une entité, il est nécessaire de télécharger sélectivement sa «fille». Prenons un exemple simple:


@Entity public class Child { @Id private Long id; @JoinColumn(name = "parent_id") @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) private Parent parent; } @Entity public class Parent { @Id private Long id; } 

L'entité enfant dans notre exemple est paresseuse: nous ne voulons pas charger de données inutiles (et joindre une autre table dans la requête SQL) lors de la réception de Child . Mais dans certains cas, dans notre demande, nous savons avec certitude que nous aurons besoin à la fois de l'enfant et de ses parents. Si vous quittez l'entité paresseux, nous recevons 2 demandes distinctes. Si vous appliquez un chargement rapide en supprimant FetchType.LAZY , les deux entités seront toujours chargées à la première demande (et nous ne le voulons pas).


JPQL fournit une bonne solution prête à l'emploi - c'est le mot clé fetch :


 public interface ChildRepository extends JpaRepository<Child, Long> { @Query("select c from Child c join fetch c.parent where c.id = :id") Child findByIdFetchParent(@Param("id") Long id); } 

Cette demande est simple et claire, mais elle présente des inconvénients:


  • nous avons en fait dupliqué la logique de JpaRepository::findById ajoutant un chargement explicite
  • chaque requête décrite à l'aide de @Query vérifiée au démarrage de l'application, ce qui nécessite l'analyse de la requête, la vérification des arguments, etc. (voir org.springframework.data.jpa.repository.query.SimpleJpaQuery :: validateQuery ). Tout cela est un travail qui prend du temps et de la mémoire.
  • l'utilisation d'une telle approche dans un grand projet avec des dizaines de référentiels et d'entités entrelacées (parfois avec une douzaine de «filles») conduira à une explosion combinatoire.

Les comptes viennent à notre aide:


 @Entity @NamedEntityGraphs(value = { @NamedEntityGraph( name = Child.PARENT, attributeNodes = @NamedAttributeNode("parent") ) }) public class Child { public static final String PARENT = "Child[parent]"; @Id private Long id; @JoinColumn(name = "parent_id") @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) private Parent parent; } 

Le graphique lui-même est facile à décrire; les difficultés commencent lors de son utilisation. Spring Data JPA sur sa page suggère de le faire de cette façon (selon notre cas):


 public interface GroupRepository extends JpaRepository<GroupInfo, String> { @EntityGraph(value = Child.PARENT) @Query("select c from Child c where c.id = :id") Child findByIdFetchParent(@Param("id") Long id); } 

Ici, nous voyons tous les mêmes problèmes (sauf que la demande écrite est devenue un peu plus facile). Vous pouvez les terminer d'un seul coup à l'aide d'un réglage fin. Créez votre propre interface, que nous utiliserons pour créer des référentiels au lieu du JpaRepository encadré:


 @NoRepositoryBean public interface BaseJpaRepository<T, ID extends Serializable> extends JpaRepository<T, ID> { T findById(ID id, String graphName); } 

Maintenant implémentation:


 public class BaseJpaRepositoryImpl<T, ID extends Serializable> extends SimpleJpaRepository<T, ID> implements BaseJpaRepository<T, ID> { private final JpaEntityInformation<T, ?> entityInfo; private final EntityManager entityManager; public BaseJpaRepositoryImpl(JpaEntityInformation<T, ?> ei, EntityManager em) { super(ei, em); this.entityInfo = ei; this.entityManager = em; } @Override public T findById(ID id, String graphName) { Assert.notNull(id, "The given id must not be null!"); //  EntityGraph<?> graph = entityManager.getEntityGraph(graphName); Map<String, Object> hints = singletonMap(QueryHints.HINT_LOADGRAPH, graph); return entityManager.find(getDomainClass(), id, hints); } 

BaseJpaRepositoryImpl maintenant Spring à utiliser BaseJpaRepositoryImpl comme base pour tous les référentiels de notre application:


 @EnableJpaRepositories(repositoryBaseClass = BaseJpaRepositoryImpl.class) public class AppConfig { } 

Maintenant, notre méthode sera disponible à partir de tous les référentiels hérités de notre BaseJpaRepository .


Cette approche présente un inconvénient qui peut mettre un porc très gras.


Essayez de penser par vous-même

Le problème est que Hibernate (au moins au moment de la rédaction) ne correspond pas aux noms des graphiques et aux graphiques eux-mêmes. Pour cette raison, il peut y avoir une erreur d'exécution lorsque nous exécutons quelque chose comme


 Optional<MyEntity> entity = repository.findById(id, NON_EXISTING_GRAPH); 

Vous pouvez vérifier l'intégrité de la solution à l'aide du test:


 @Sql("/ChildRepositoryGraphTest.sql") public class ChildRepositoryGraphTest extends TestBase { private final Long childId = 1L; @Test public void testGraph_expectFieldInitialized() { Child child1 = childRepository.findOne(childId, Child.PARENT); boolean initialized = Hibernate.isInitialized(child1.getParent()); assertTrue(initialized); } @Test public void testGraph_expectFieldNotInitialized() { Child child1 = childRepository .findById(childId) .orElseThrow(NullPointerException::new); boolean initialized = Hibernate.isInitialized(child1.getParent()); assertFalse(initialized); } } 

Quand les arbres étaient gros


Et nous étions petits et inexpérimentés, nous devions souvent voir ce code:


 public List<DailyRecord> findBetweenDates(Date from, Date to) { StringBuilder query = new StringBuilder("from Record "); if (from != null) { query.append(" where date >=").append(format(from)).append(" "); } if (to != null) { if (from == null) { query.append(" where date <= " + format(to) + " "); } else { query.append(" and date <= " + format(to) + " "); } } return em.createQuery(query.toString(), DailyRecord.class).getResultList(); } 

Ce code collecte la demande pièce par pièce. Les inconvénients de cette approche sont évidents:


  • vous devez faire beaucoup avec vos mains
  • là où il y a du travail manuel - il y a des erreurs
  • pas de coloration syntaxique (des fautes de frappe apparaissent lors de l'exécution)
  • il est très difficile d'étendre et de maintenir le code

Un peu plus tard, l'API Criteria est apparue, ce qui nous a permis de presser un peu le code ci-dessus:


 public List<DailyRecord> findBetweenDates(Date from, Date to) { Criteria criteria = em .unwrap(Session.class) .createCriteria(DailyRecord.class); if (from != null) { criteria.add(Expression.ge("date", from)); } if (to != null) { criteria.add(Expression.le("date", to)); } return criteria.list(); } 

L'utilisation de critères présente plusieurs avantages:


  • la possibilité d'utiliser un métamodèle au lieu de valeurs "câblées" comme "date"
  • certaines erreurs dans la construction de la requête s'avèrent être des erreurs de compilation, c'est-à-dire qu'elles sont déjà détectées lors de l'écriture
  • le code est plus court et plus intelligible qu'avec des chaînes de collage stupides

Il existe également des inconvénients:


  • le code est assez compliqué pour comprendre
  • pour apprendre à écrire de telles requêtes, vous devez remplir votre main (je me souviens de la douleur la plus folle lorsque j'ai dû faire face à la correction d'erreurs dans de telles requêtes, consistant parfois en 100-150 lignes, avec branchement, etc.)
  • une requête complexe est plutôt lourde (50 lignes sont loin de la limite)

Je veux le développer facilement et avec plaisir, donc je n'aime pas ces deux méthodes.


Tournons-nous vers l'entité que nous avons déjà examinée:


 @Entity public class Child { @Id private Long id; @JoinColumn(name = "parent_id") @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) private Parent parent; //... @OneToMany(mappedBy = "owner", cascade = CascadeType.ALL) @LazyCollection(value = LazyCollectionOption.EXTRA) private List<Toy> toys = new ArrayList<>(); } 

J'aimerais pouvoir charger une entité dans différents modes (et leurs combinaisons):


  • charger (ou non) le parent
  • charger (ou non) des jouets
  • trier les enfants par âge

Si vous résolvez ce problème de front, c'est-à-dire en écrivant un grand nombre de requêtes qui correspondent au mode de chargement sélectionné, cela entraînera très rapidement une explosion combinatoire:


  @Query("select c from Child c join fetch c.parent order by c.age") List<Child> findWithParentOrderByAge(); @Query("select c from Child c join fetch c.toys order by c.age") List<Child> findWithToysOrderByAge(); @Query("select c from Child c join fetch c.parent join fetch c.toys") List<Child> findWithParentAndToys(); //... 

Il existe un moyen simple et élégant de résoudre ce problème: une combinaison de moteurs SQL / HQL et de modèles. «Freemarker» a été utilisé sur mes projets, bien que d'autres solutions puissent être utilisées («Timlif», «Mustash», etc.).


Commençons à créer. Tout d'abord, nous devons décrire la requête dans un fichier qui reçoit l' *.hql.ftl ou *.sql.ftl (si vous utilisez du SQL "pur"):


 #* @vtlvariable name="fetchParent" type="java.lang.Boolean" *# #* @vtlvariable name="fetchToys" type="java.lang.Boolean" *# #* @vtlvariable name="orderByAge" type="java.lang.Boolean" *# select child from Child child #if($fetchParent) left join fetch child.parent #end #if($fetchToys) left join fetch child.toys #end #if($orderByAge) order by child.age #end 

Vous avez maintenant besoin d'un gestionnaire:


 @Component @RequiredArgsConstructor public class TemplateParser { private final Configuration configuration; @SneakyThrows public String prepareQuery(String templateName, Map<String, Object> params){ Template template = configuration.getTemplate(templateName); return FreeMarkerTemplateUtils.processTemplateIntoString(template, params); } } 

Rien de compliqué. Accès au référentiel. Évidemment, l'interface héritant de JpaRepository ne nous convient pas. Au lieu de cela, nous profiterons de l'occasion pour créer nos propres référentiels:


 public interface ChildRepositoryCustom { List<Child> findAll(boolean fetchParent, boolean fetchToys, boolean order); } @RequiredArgsConstructor public class ChildRepositoryImpl extends BaseDao implements ChildRepositoryCustom { private final TemplateParser templateParser; @Override public List<Child> findAll(boolean fetchParent, boolean fetchToys, boolean order) { Map<String, Object> params = new HashMap<>(); params.put("fetchParent", fetchParent); params.put("fetchToys", fetchToys); params.put("orderByAge", orderByAge); String query = templateParser.prepareQuery(BASE_CHILD_TEMPLATE.name, params); return em.createQuery(query, Child.class).getResultList(); } @RequiredArgsConstructor enum RepositoryTemplates { BASE_CHILD_TEMPLATE("BaseChildTemplate.hql.ftl"); public final String name; } } 

Pour rendre la méthode findUsingTemplate accessible à partir de ChildRepository vous devez procéder comme findUsingTemplate :


 public interface ChildRepository extends BaseJpaRepository<Child, Long>, ChildRepositoryCustom { //... } 

Une caractéristique importante associée au nom

Spring ne liera notre classe et nos interfaces qu'avec le nom correct:


  • Childrepository
  • ChildRepository Custom
  • ChildRepository Impl

N'oubliez pas ceci, car en cas d'erreur dans le nom, une exception inintelligible sera levée, d'où il est impossible de comprendre la cause de l'erreur.


Maintenant, en utilisant cette approche, vous pouvez résoudre des problèmes plus complexes. Supposons que nous devions effectuer une sélection en fonction des caractéristiques sélectionnées par l'utilisateur. En d'autres termes, si l'utilisateur n'a pas spécifié les dates "de" et "à", il n'y aura pas de filtrage temporel. Si seule la date "de" ou uniquement la date "à" est spécifiée, le filtrage sera à sens unique. Si les deux dates sont spécifiées, seuls les enregistrements entre les dates spécifiées entrent dans la sélection:


 @Getter @RequiredArgsConstructor public class RequestDto { private final LocalDate from; private final LocalDate to; public boolean hasDateFrom() { return from != null; } public boolean hasDateTo() { return to != null; } } @Override public List<Child> findAll(ChildRequest request) { Map<String, Object> params = singletonMap("request", request); String query = templateParser.prepareQuery(TEMPLATE.name, params); return em.createQuery(query, Child.class).getResultList(); } 

Maintenant, le modèle:


 <#-- @ftlvariable name="request" type="...RequestDto" --> select child from Child child <#if request.hasDateFrom() && request.hasDateTo()> where child.birthDate >= :dateFrom and child.birthDate <= :dateTo <#elseif request.hasDateFrom()> where child.birthDate >= :dateFrom <#elseif request.hasDateTo()> where child.birthDate <= :dateTo </#if> 

Oracle et nvl


Considérez l'essence:


 @Entity public class DailyRecord { @Id private Long id; @Column private String currency; @Column(name = "record_rate") private BigDecimal rate; @Column(name = "fixed_rate") private BigDecimal fxRate; @Setter(value = AccessLevel.PRIVATE) @Formula("select avg(r.record_rate) from daily_record r where r.currency = currency") private BigDecimal avgRate; } 

Cette entité est utilisée dans la requête (SGBD, rappelons-le, nous avons Oracle):


 @Query("select nvl(record.fxRate, record.avgRate) " + " from DailyRecord record " + "where record.currency = :currency") BigDecimal findRateByCurrency(@Param("currency") String currency); 

Il s'agit d'une demande valide et fonctionnelle. Mais il y a un petit problème, que les experts SQL remarqueront probablement. Le fait est que nvl dans Oracle n'est pas paresseux. En d'autres termes, lorsque nous appelons la méthode findRateByCurrency , le journal des findRateByCurrency


 select nvl( dr.fixed_rate, select avg(r.record_rate) from daily_record r where r.currency = dr.currency ) from daily_record dr where dr.currency = ? 

Même si la valeur dr.fixed_rate présente, le SGBD calcule toujours la valeur renvoyée par la deuxième expression dans nvl , qui dans notre cas


 select avg(r.record_rate) from daily_record r where r.currency = dr.currency) 

Le lecteur sait probablement déjà comment esquiver le poids inutile de la demande: bien sûr, c'est le mot-clé coalesce , qui se compare favorablement à nvl sa paresse, ainsi que la possibilité d'accepter plus de 2 expressions. Corrigeons notre demande:


 @Query("select coalesce(record.fxRate, record.avgRate) " + " from DailyRecord record " + "where record.currency = :currency") BigDecimal findRateByCurrency(@Param("currency") String currency); 

Et puis, comme on dit, tout d'un coup:


 select nvl(dr.fixed_rate, select avg(r.record_rate) from daily_record r where r.currency = dr.currency) from daily_record dr where dr.currency = ? 

La demande est restée la même. C'est parce que le dialecte oracle de la boîte transforme la coalesce en une chaîne nvl .


Remarque

Si vous souhaitez reproduire ce problème, supprimez la deuxième ligne du constructeur de la classe CustomOracleDialect et exécutez le DailyRecordRepositoryTest::findRateByCurrency


Pour esquiver cela, vous devez créer votre propre dialecte et l'utiliser dans l'application:


 public class CustomOracleDialect extends Oracle12cDialect { public CustomOracleDialect() { super(); registerFunction("coalesce", new StandardSQLFunction("coalesce")); } } 

Oui, c'est tellement simple. Maintenant, nous lions le dialecte créé à l'application:


 spring: jpa: database-platform: com.luxoft.logeek.config.CustomOracleDialect 

Une autre façon (obsolète):
 spring: jpa: properties: hibernate.dialect: com.luxoft.logeek.config.CustomOracleDialect 

L'exécution répétée de la demande donne au koalesk convoité:


 select coalesce(dr.fixed_rate, select avg(r.record_rate) from daily_record r where r.currency = dr.currency) from daily_record dr where dr.currency = ? 

Oracle et demandes de page


En général, l'achèvement d'un dialecte offre de riches opportunités pour la manipulation de requêtes. Souvent, lors du développement d'une application et d'une face Web, la tâche de paginer les données est rencontrée. En d'autres termes, nous avons plusieurs centaines de milliers d'enregistrements dans la base de données, mais ils sont affichés en paquets de 10/50/100 enregistrements par page. La date de printemps prête à l'emploi offre au développeur des fonctionnalités similaires:


 @Query("select new com.luxoft.logeek.data.BriefChildData(" + "c.id, " + "c.age " + ") from Child c " + " join c.parent p " + "where p.name = ''") Page<BriefChildData> browse(Pageable pageable); 

Cette approche présente un inconvénient important, à savoir l'exécution de deux requêtes, dont la première obtient les données, et la seconde détermine leur nombre total dans la base de données (cela est nécessaire pour afficher la quantité totale de données dans l'objet Page ). Dans notre cas, un appel à cette méthode donne les requêtes suivantes (journalisation à l'aide de p6spy):


 select * from ( select c.id, c.age from child c inner join parent p on c.parent_id = p.id where p.name = '' ) where rownum <= 3; select count(c.id) from child c inner join parent p on c.parent_id = p.id where p.name = '' 

Si la requête est lourde (de nombreuses tables sont jointes par une colonne non indexable, juste beaucoup de jointures, une condition de sélection difficile, etc.), cela peut devenir un problème. Mais puisque nous avons Oracle, alors en utilisant la pseudo-colonne rownum, vous pouvez vous en tirer avec une seule demande.


Pour ce faire, nous devons terminer notre dialecte et décrire la fonction utilisée pour compter tous les enregistrements:


 public class CustomOracleDialect extends Oracle12cDialect { public CustomOracleDialect() { super(); registerFunction("coalesce", new StandardSQLFunction("coalesce")); registerFunction("total_count", new TotalCountFunc()); } } public class TotalCountFunc implements SQLFunction { @Override public boolean hasArguments() { return true; } @Override public boolean hasParenthesesIfNoArguments() { return true; } @Override public Type getReturnType(Type type, Mapping mapping) { return StandardBasicTypes.LONG; } @Override public String render(Type type, List arguments, SessionFactoryImplementor factory) { if (arguments.size() != 1) { throw new IllegalArgumentException("Only 1 argument acceptable"); } return " count(" + arguments.get(0) + ") over () "; } } 

Maintenant, écrivez une nouvelle requête (dans la classe ChildRepositoryImpl ):


 @Override public Page<BriefChildData> browseWithTotalCount(Pageable pageable) { String query = "select " + " c.id as id," + " c.age as age, " + " total_count(c.id) as totalCount" + " from Child c " + "join c.parent p " + "where p.name = ''"; List<BriefChildData> list = em.unwrap(Session.class) .createQuery(query) .setFirstResult((int) pageable.getOffset()) .setMaxResults(pageable.getPageSize()) .setResultTransformer(Transformers.aliasToBean(BriefChildData.class)) .getResultList(); if (list.isEmpty()) { return new PageImpl(Collections.emptyList()); } long totalCount = list.get(0).getTotalCount(); return new PageImpl<>(list, pageable, totalCount); } 

Lors de l'appel de ce code, une requête sera exécutée


 select * from (select c.id, c.age, count(c.id) over () -- <----- from child c inner join parent p on c.parent_id = p.id where p.name = '') where rownum <= 3 

En utilisant l'expression count(c.id) over () vous pouvez obtenir la quantité totale de données et l'obtenir de la classe de données pour la passer au constructeur PageImpl . Il existe un moyen de le faire plus élégamment, sans ajouter un autre champ à la classe de données, considérez-le comme un devoir :) Vous pouvez tester la solution en utilisant le test ProjectionVsDataTest .


Pièges de la personnalisation


Nous avons un projet sympa avec Oracle et Spring Date. Notre tâche est d'améliorer les performances d'un tel code:


 List<Long> ids = getIds(); ids.stream() .map(repository::findById) .filter(Optional::isPresent) .map(Optional::get) .forEach(this::sendToSchool); 

L'inconvénient réside à première vue: le nombre de requêtes de base de données est égal au nombre de clés uniques. Il existe une méthode connue pour surmonter cette difficulté:


 List<Long> ids = getIds(); repository .findAllById(ids) .forEach(this::sendToSchool); 

L'avantage de l'échantillonnage multiple est évident: si auparavant, nous avions de nombreuses requêtes similaires du formulaire


 select p.* from Pupil p where p.id = 1 select p.* from Pupil p where p.id = 2 select p.* from Pupil p where p.id = 3 

alors maintenant ils se sont effondrés en un seul


 select p.* from Pupil p where p.id in (1, 2, 3, ... ) 

Cela semble être plus facile et est devenu bon. Le projet grandit, se développe, les données se multiplient et une fois que l'inévitable arrive:


Aki tonnerre dans le ciel clair
 ERROR - ORA-01795: maximum number of expressions in a list is 1000 

Nous devons à nouveau chercher une issue (ne pas revenir à l'ancienne version). Étant donné qu'Oracle ne lui permet pas d'alimenter plus de 1 000 clés, vous pouvez diviser l'ensemble de données entier en parts égales ne dépassant pas 1 000 et exécuter un nombre multiple de requêtes:


 List<List<Long>> idChunks = cgccLists.partition(ids, 1000); //* idChunks.forEach(idChunk -> repository.findAllById(idChunk).forEach(this::sendToSchool) ); //* cgccLists = com.google.common.collect.Lists 

Cette méthode fonctionne, mais elle sent légèrement (n'est-ce pas?): Si vous rencontrez de telles difficultés dans d'autres endroits, vous devez clôturer le même jardin. Essayons de résoudre le problème de manière plus élégante, notamment en BaseJpaRepositoryImpl . La façon la plus simple de le faire est de transférer la logique ci-dessus vers l'intérieur, en la cachant à l'utilisateur:


 @Override public List<T> findAllById(Iterable<ID> ids) { Assert.notNull(ids, "The given Iterable of Id's must not be null!"); Set<ID> idsCopy = Sets.newHashSet(ids); if (idsCopy.size() <= OracleConstants.MAX_IN_COUNT) { return super.findAllById(ids); } return findAll(idsCopy); } private List<T> findAll(Collection<ID> ids) { List<List<ID>> idChunks = Lists.partition(new ArrayList<>(ids), 1000); return idChunks .stream() .map(this::findAllById) .flatMap(List::stream) .collect(Collectors.toList()); } 

Cela s'est amélioré: d'une part, nous avons nettoyé le code de travail des couches d'infrastructure, et d'autre part, nous avons étendu la portée de notre solution à tous les référentiels de projet qui étendent BaseJpaRepository . Il y a aussi des inconvénients. La principale est plusieurs demandes au lieu d'une, et aussi (découle de la principale) - la nécessité de filtrer les clés, car si cela n'est pas fait, alors la même clé peut apparaître dans différents idChunks . Cela, à son tour, signifie que la même entité sera incluse dans la liste deux fois et, par conséquent, sera traitée deux fois. Nous n'en avons pas besoin, voici donc une autre solution plus sophistiquée:


 @Override public List<T> findAllById(Iterable<ID> ids) { Assert.notNull(ids, "The given Iterable of Id's must not be null!"); ArrayList<ID> idsCopy = Lists.newArrayList(ids); if (idsCopy.size() <= OracleConstants.MAX_IN_COUNT) { return super.findAllById(ids); } return findAll(idsCopy); } private List<T> findAll(ArrayList<ID> ids) { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<T> query = cb.createQuery(getDomainClass()); Root<T> from = query.from(getDomainClass()); Predicate predicate = toPredicate(cb, ids, from); query = query.select(from).where(predicate); return entityManager.createQuery(query).getResultList(); } private Predicate toPredicate(CriteriaBuilder cb, ArrayList<ID> ids, Root<T> root) { List<List<ID>> chunks = Lists.partition(ids, OracleConstants.MAX_IN_COUNT); SingularAttribute<? super T, ?> id = entityInfo.getIdAttribute(); Predicate[] predicates = chunks.stream() .map(chunk -> root.get(id).in(chunk)) .toArray(Predicate[]::new); return cb.or(predicates); } 

Il utilise l'API Criteria, qui permet de construire une dernière requête du formulaire


 select p.* from Pupil p where p.id in (1, 2, ... , 1000) or p.id in (1001, ... , 2000) or p.id in (2001, ... , 3000) 

Il y a une subtilité: une requête similaire peut être exécutée plus longtemps que d'habitude en raison des conditions encombrantes, donc la première méthode peut (parfois) être préférable.


C'est tout, j'espère que les exemples vous ont été utiles et utiles dans le développement quotidien. Les commentaires et ajouts sont les bienvenus.

Source: https://habr.com/ru/post/fr444240/


All Articles