vendredi 26 mai 2023

Thymeleaf et htmx

Thymeleaf est un moteur de template serveur régulièrement utilisé le monde Java, principalement avec le framework Spring. Dans cet article, nous présentation quelques architectures possibles avec ce framework ainsi que l'utilisation de Htmx afin de rendre les applications thymeleaf plus dynamiques.

 

 Architecture d'application

Voici un ensemble d'architecture varié qu'il est possible de faire avec Thymeleaf

 
La première étant la plus simple et la plus utilisée dans les exemples montrés sur la toile.
J'ai pu expérimenter la cinquième. Le mvc était utilisé pour construire les interfaces utilisateurs, par contre toutes les opérations de suppression, de mise à jour ou de création de données utilisaient une architecture rest. Le javascript était utilisé afin de reloader la page.

C'est une solution simple et rapide à mettre en place face à une solution SPA avec Angular, React ou bien Vue.

HDA

L'augmentation du temps de développement, la complexité des frameworks, courbe d'apprentissage élevé, maintenance onéreuse ont fait reculé certains.

React à htmx

 

Hypermedia Driven Application combine la facilité des multiples pages application (mpa) avec l'expérience utilisateur des single page application (spa). 

Une panoplie de librairie existe pour apporter l'expérience utilisateur à des frameworks orientés serveur.

unpoly, htmx, alpine, hyperscript... permettent via l'ajout de mot clé dans le html de rendre des applications utilisant des framework serveurs: jsf, jsp, thymeleaf... plus dynamique, d'éviter de charger la page au complet..

Htmx

Htmx est une petite librairie qui donne accès à ajax, css, sse et websocket via des attributs en html.

Cette librairie  htmx-spring-boot-thymeleaf facilite l'utilisation de htmx avec spring boot et thymeleaf. Elle n'est pas obligatoire.

Vidéo d'une présentation de thymeleaf avec htmx

https://youtu.be/okCdaBTQsik

Une autre présentation

https://www.youtube.com/watch?v=38WAVRfxPxI

Une liste d'exemple est disponible via ce lien.

Éviter le rafraîchissement de la page 

Notre exemple est un template ayant un fragment pour les css, un autre pour le menu et un autre pour le javascript.
 
index.html
<!DOCTYPE html>
<html lang="en">
<head th:replace="~{fragments/css :: headcss}">
<meta charset="UTF-8">
<title>test</title>
</head>
<body class="container">
<div th:replace="~{fragments/navbarmenu :: menu}"/>

<section id="main" class="fade-me-out">

<div id="modals-here"></div>
</section>

<div th:replace="~{fragments/jsscripts :: jsscripts}"/>

</body>
</html>

 

 Fragment pour le css

<!DOCTYPE html>
<html lang="en">
<head th:fragment="headcss">

<title>Generic form UI</title>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" />
<link rel="stylesheet" type="text/css" th:href="@{/css/datepicker-bs5.min.css}" href="/css/datepicker-bs5.min.css" />
</head>
<body>

</body>
</html>


Fragment pour le javascript

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>js scripts</title>
</head>
<body>
<div th:fragment="jsscripts">

<script type="text/javascript" th:src="@{/js/genericui.js}" src="/js/genericui.js"></script>
<script type="text/javascript" th:src="@{/js/bootstrap.bundle.min.js}" src="/js/bootstrap.bundle.min.js"></script>
<script type="text/javascript" th:src="@{/js/datepicker-full.min.js}" src="/js/datepicker-full.min.js"></script>

<script type="text/javascript" th:src="@{/js/htmx.min.js}" src="/js/htmx.min.js"></script>
<script type="text/javascript" th:src="@{/js/inputmask.min.js}" src="/js/inputmask.min.js"></script>

<script type="text/javascript" th:src="@{/js/bootstrap-validation.js}" src="/js/bootstrap-validation.js"></script>

<script type="text/javascript">
document.addEventListener('htmx:afterRequest', function(evt) {

Inputmask().mask(document.querySelectorAll("input"));

const matchesDate = document.getElementsByClassName("date1");

for (let i = 0; i < matchesDate.length; i++) {
const datepicker = new Datepicker(matchesDate[i], {
buttonClass: 'btn',
});
}

});

</script>
</div>
</body>
</html>

Fragment pour le menu

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>NavBar</title>
</head>
<body>

<nav th:fragment="menu" class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" th:href="@{/index2}">Norenda</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" th:href="@{/index}">Index</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" hx-push-url="true" hx:get="@{/modal}" hx-target="#main" hx-swap="innerHTML" hx-trigger="click">Modal</a>
</li>
<li class="nav-item">
<a class="nav-link" hx-push-url="true" hx:get="@{/success}" hx-target="#main" hx-swap="innerHTML swap:1s" hx-trigger="click">Succes</a>
</li>
<li class="nav-item">
<a class="nav-link" hx-push-url="true" hx:get="@{/editor}" hx-target="#main" hx-swap="innerHTML swap:1s" hx-trigger="click">Editeur</a>
</li>
</ul>
<form class="d-flex" role="search" hx:post="@{/search}" hx-target="#main" hx-swap="innerHTML">
<input class="form-control me-2" type="search" placeholder="Recherche" aria-label="Search">
<button class="btn btn-outline-success" type="submit">Recherche</button>
</form>
</div>
</div>
</nav>

</body>
</html>

 

Les deux premiers liens font un chargement complet de la page. 
Le troisième lien affiche une modale
Le quatrième lien affiche un message 
Le cinquième lien affiche un formulaire.

Mis à part les deux premiers liens, il n'y a aucun chargement complet de la page. Il y a remplacement d'une zone par le fragment retourné du serveur.

Un des liens propose une animation.
 
Pour éviter de faire un chargement complet de la page, il faut utiliser certains mots clés.

On mentionne ici que get est fait sur le url mentionnée
hx:get="@{/editor}"

On mentionne ici quel sera la cible du fragment retourné, où ira le contenu
hx-target="#main" 

On mentionne ici qu'on remplace le contenu de la cible par celui du fragment, il serait possible de l'ajouter avant, après
hx-swap="innerHTML"

 

Animation

Quelques animations sont disponibles tels que des fade in, fade out. Il est aussi possible d’utiliser des css pour créer ses propres animations.

Conclusion

Htmx peut vous permettre de rendre encore plus dynamique votre application qui utilise un framework orienté serveur à une fraction du prix en termes de temps, argent et apprentissage. Pour encore plus de fonctionnalité, il faut regarder unpoly.


Vous pouvez télécharger les sources du projet ici.

mercredi 17 mai 2023

Spring data jpa et Hibernate’s Warning “firstResult/maxResults specified with collection fetch”

Nous verrons dans cet article comment corrigé le problème 
Hibernate’s Warning “firstResult/maxResults specified with collection fetch” qui peut survenir lorsqu'il y a un fetch d'effectuer et qu'il y a du paging. Nous utiliserons spring data jpa.

Détail structure

Imaginons une relation 1 à plusieurs


Lorsqu'on obtient un parent, on désire obtenir aussi sa liste d'enfants. En sql, il y aurait une jointure à effectuer. Il est question d'une recherche avancé où on pourrait recherche par plusieurs critères.

Spécification

Création d'une spécification pour construire une requête dynamique

@Component
public class ParentSpecification {

    public Specification<Parent> recherche(RechercheParent recherche) {
        return (root, cq, cb) -> {
            Join<Parent, enfant> enfantJoin;

             List<Predicate> rootPredicates = new ArrayList<>();

             if (Long.class != cq.getResultType()) {
                enfantJoin = (Join) root.fetch("enfants", JoinType.LEFT);
             } else {
                enfantJoin = (Join) root.join("enfants", JoinType.LEFT);
             }

             sqlLikeCondition("nom", recherche.nomParent(), root, cb, rootPredicates);
             sqlEqualCondition("age", recherche.age(), root, cb, rootPredicates);
             return  cb.and(rootPredicates.toArray(new Predicate[rootPredicates.size()]));

    }

}

Repository

Spring data Jpa a une méthode findAll qui accepte en paramètre une spécification. Il y a même une version de la méthode qui accepte aussi un objet Pageable. Afin de pouvoir l'utiliser, le repository dois hérité de JpaSpecificationExecutor.

@Repository
@Transactional(readOnly = true)
public interface ParentRepository extends JpaRepository<Parent, Long>, JpaSpecificationExecutor<Parent> {
}


Il sera alors possible d'effectuer un appel comme celui çi

 Page<Parent> pageParent = parentRepository.findAll(parentSpecification.search(parentRecherche), pageable);

Détail du problème

Par contre, dans les logs il y aura le message org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory


Dans la base de donnée avec une requête sql le résultat serait par exemple
Chaque enfant est relié à son parent.

Habituellement, Hibernate, le orm par défaut utilisé par spring boot, lorsque le paging est utilisé il va prendre x première valeurs jusqu'à y. Par exemple si on mentionne qu'on veut les 5 premières résultats on obtiendrait


Ce qui est pas vraiment ce qu'on voudrait, mais bien les 5 premiers parents.

Hibernate donne le message “firstResult/maxResults specified with collection fetch” car il applique alors la pagination en mémoire sans appliquer la pagination et ensuite retourne le résult voulue.
Si votre table à énormément de donnée, vous pouvez obtenir rapidement des problèmes de performance, surtout si vous effectuer des rêquetes sans aucun critères de recherche.

Solution

Afin de palier à ce problème, nous allons redéfinir une méthode spring data jpa

Dans la classe de spécification, il faudra ajouter

public Specification<Parent> idIn(Set<Long> parentIds) {
        return (root, cq, cb) -> {

            if (parentIds == null || parentIds.isEmpty()) {
                return null;
            }

            return root.get("parentId").in(parentIds);

        };
    }

Il faut créer une nouvelle interface avec la méthode findEntityIds

@NoRepositoryBean
public interface CustomJpaSpecificationExecutor<E, ID extends Serializable> extends JpaSpecificationExecutor<E> {

    Page<ID> findEntityIds(Specification<E> specification, Pageable pageable);
}

Par la suite il faut créer une classe implémentant cette interface


public class CustomBaseSimpleJpaRepository<E, ID extends Serializable> extends SimpleJpaRepository<E, ID> implements CustomJpaSpecificationExecutor<E, ID> {

    private final EntityManager entityManager;
    private final JpaEntityInformation<E, ID> entityInformation;

    public CustomBaseSimpleJpaRepository(JpaEntityInformation<E, ID> entityInformation, EntityManager entityManager) {
        super(entityInformation, entityManager);
        this.entityManager = entityManager;
        this.entityInformation = entityInformation;
    }

    private static long executeCountQuery(TypedQuery<Long> query) {

        Assert.notNull(query, "TypedQuery must not be null");

        List<Long> totals = query.getResultList();
        long total = 0L;

        for (Long element : totals) {
            total += element == null ? 0 : element;
        }

        return total;
    }

    @Override
    public Page<ID> findEntityIds(Specification<E> specification, Pageable pageable) {
        CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
        CriteriaQuery<ID> criteriaQuery = criteriaBuilder.createQuery(entityInformation.getIdType());
        Root<E> root = criteriaQuery.from(getDomainClass());

        // Get the entities ID only
        criteriaQuery.select((Path<ID>) root.get(entityInformation.getIdAttribute()));

        // Apply specification(s) to restrict results or to write dynamic queries
        Predicate predicate = specification.toPredicate(root, criteriaQuery, criteriaBuilder);

        if (predicate != null) {
            criteriaQuery.where(predicate);
        }

        // Update Sorting
        Sort sort = pageable.isPaged() ? pageable.getSort() : Sort.unsorted();
        if (sort.isSorted()) {
            criteriaQuery.orderBy(toOrders(sort, root, criteriaBuilder));
        }
        TypedQuery<ID> typedQuery = entityManager.createQuery(criteriaQuery);

        // Update Pagination attributes
        if (pageable.isPaged()) {
            typedQuery.setFirstResult((int) pageable.getOffset());
            typedQuery.setMaxResults(pageable.getPageSize());
        }

        return PageableExecutionUtils.getPage(typedQuery.getResultList(), pageable,
                () -> this.executeCountQuery(getCountQuery(specification, getDomainClass())));

    }

}

Il faudra faire deux appels

Page<Long> pageParentId = parentRepository.findEntityIds(parentSpecification.search(parentRecherche), pageable);

List<Parent> parents = parentRepository.findAll(parentSpecification.search(parentRecherche).and(parentSpecification.idIn(Set.copyOf(pageParentId.getContent()))));

return new PageImpl<>(parents, pageable, pageParentId.getTotalElements());

Spring data jpa lancera 3 requêtes.

Une première requête pour obtenir les id des parents.
Une autre requête pour obtenir le nombre total de donnée 
Une troisième requête nécessitant les id de la première requête sera effectué