Salta el contingut

Spring Boot Thymeleaf - Guia Completa

Introducció a Thymeleaf

Thymeleaf és un template engine modern orientat a Java que permet generar contingut HTML dinàmic a partir del servidor. A diferència de les APIs REST que retornen JSON, Thymeleaf integra lògica de negoci amb la renderització de vistes HTML directament.

Comparació: REST API vs Thymeleaf

Aspecte REST API (JSON) Thymeleaf (HTML)
Resposta JSON estructurat HTML renderitzat
Client Frontend separat (Angular, React) Browser directament
Arquitectura Microserveis, decoupled Monolítica, integrada
SEO Pobre (client-side rendering) Bé (server-side rendering)
Complexitat Més simple per SPAs Més adequat per aplicacions tradicionals

Instal·lació i Configuració

1. Afegir Dependència

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

2. Estructura de Projecte

my-app/
├── src/main/java/com/example/myapp/
│   ├── MyAppApplication.java
│   ├── controller/
│   │   └── UserController.java
│   ├── service/
│   │   └── UserService.java
│   ├── repository/
│   │   └── UserRepository.java
│   └── entity/
│       └── User.java
├── src/main/resources/
│   ├── templates/
│   │   ├── layout/
│   │   │   └── base.html
│   │   ├── user/
│   │   │   ├── list.html
│   │   │   ├── create.html
│   │   │   └── edit.html
│   │   └── index.html
│   ├── static/
│   │   ├── css/
│   │   │   └── style.css
│   │   └── js/
│   │       └── script.js
│   └── application.yml

3. Configuració application.yml

spring:
  thymeleaf:
    cache: false  # Desactivar cache en desenvolupament
    check-template-location: true
    prefix: classpath:/templates/
    suffix: .html
    mode: HTML5
    encoding: UTF-8

  # Base de dades
  datasource:
    url: jdbc:mysql://localhost:3306/myapp_db
    username: root
    password: password

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false

server:
  port: 8080

Fundaments de Thymeleaf

Sintaxi Bàsica

1. Mostrar Variables

<!-- Mostrar contingut dinàmic -->
<p th:text="${message}">Placeholder text</p>

<!-- Mostrar sense escapar HTML -->
<div th:utext="${htmlContent}"></div>

<!-- Null-safe: si la variable és null, mostra placeholder -->
<p th:text="${user?.name ?: 'No name'}">Default</p>

2. Condicionals

<!-- if -->
<div th:if="${user.age >= 18}">
    <p>Ets major d'edat</p>
</div>

<!-- unless (opuesto a if) -->
<div th:unless="${user.premium}">
    <p>Actualitza a versió premium</p>
</div>

<!-- switch/case -->
<div th:switch="${user.role}">
    <p th:case="'ADMIN'">Eres administrador</p>
    <p th:case="'USER'">Eres usuario estándar</p>
    <p th:case="*">Rol desconocido</p>
</div>

3. Iteracions (Loops)

<!-- Iterar sobre llista -->
<table>
    <thead>
        <tr>
            <th>Nom</th>
            <th>Email</th>
            <th>Accions</th>
        </tr>
    </thead>
    <tbody>
        <tr th:each="user : ${users}">
            <td th:text="${user.name}"></td>
            <td th:text="${user.email}"></td>
            <td>
                <a th:href="@{/users/{id}(id=${user.id})}">Veure</a>
                <a th:href="@{/users/{id}/edit(id=${user.id})}">Editar</a>
            </td>
        </tr>
    </tbody>
</table>

<!-- Variables d'estat -->
<div th:each="user, stat : ${users}">
    <p>Index: <span th:text="${stat.index}"></span></p>
    <p>Count: <span th:text="${stat.count}"></span></p>
    <p>Parell: <span th:text="${stat.even}"></span></p>
    <p>Primer: <span th:text="${stat.first}"></span></p>
    <p>Últim: <span th:text="${stat.last}"></span></p>
</div>
<!-- Link simple -->
<a th:href="@{/users}">Llistar usuaris</a>

<!-- Link amb paràmetres -->
<a th:href="@{/users/{id}(id=${user.id})}">Veure usuari</a>
<a th:href="@{/users/search(query=${searchTerm})}">Cercar</a>

<!-- Link amb múltiples paràmetres -->
<a th:href="@{/products(page=${page},size=${size},sort=${sort})}">
    Pàgina siguiente
</a>

Controlador i Views

Controlador amb Thymeleaf

package com.example.myapp.controller;

import com.example.myapp.entity.User;
import com.example.myapp.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@Controller
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    // Mostrar llista d'usuaris
    @GetMapping
    public String listUsers(Model model) {
        List<User> users = userService.getAllUsers();
        model.addAttribute("users", users);
        model.addAttribute("title", "Llistat d'Usuaris");
        return "user/list";  // Retorna templates/user/list.html
    }

    // Mostrar form de creació
    @GetMapping("/create")
    public String createForm(Model model) {
        model.addAttribute("user", new User());
        model.addAttribute("title", "Crear Usuari");
        return "user/create";
    }

    // Guardar usuari
    @PostMapping
    public String saveUser(@ModelAttribute User user) {
        userService.saveUser(user);
        return "redirect:/users";  // Redirigir a la llista
    }

    // Mostrar formulari d'edició
    @GetMapping("/{id}/edit")
    public String editForm(@PathVariable Long id, Model model) {
        User user = userService.getUserById(id);
        model.addAttribute("user", user);
        model.addAttribute("title", "Editar Usuari");
        return "user/edit";
    }

    // Actualizar usuari
    @PutMapping("/{id}")
    public String updateUser(@PathVariable Long id, @ModelAttribute User user) {
        user.setId(id);
        userService.updateUser(user);
        return "redirect:/users";
    }

    // Eliminar usuari
    @DeleteMapping("/{id}")
    public String deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return "redirect:/users";
    }

    // Veure detalls d'usuari
    @GetMapping("/{id}")
    public String viewUser(@PathVariable Long id, Model model) {
        User user = userService.getUserById(id);
        model.addAttribute("user", user);
        return "user/view";
    }
}

Templates Thymeleaf

Template Base (Layout)

<!-- templates/layout/base.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title th:text="${title ?: 'Mi App'}"></title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
    <link th:href="@{/css/style.css}" rel="stylesheet">
</head>
<body>
    <!-- Header -->
    <nav th:insert="~{layout/header :: header}"></nav>

    <!-- Contenido principal -->
    <div class="container mt-4">
        <!-- Missatges d'error/éxit -->
        <div th:if="${param.success}" class="alert alert-success alert-dismissible fade show" role="alert">
            <span th:text="#{message.success}"></span>
            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
        </div>

        <div th:if="${param.error}" class="alert alert-danger alert-dismissible fade show" role="alert">
            <span th:text="#{message.error}"></span>
            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
        </div>

        <!-- Contingut dinàmic -->
        <div th:insert="~{:: content}"></div>
    </div>

    <!-- Footer -->
    <footer th:insert="~{layout/footer :: footer}"></footer>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Header Fragment

<!-- templates/layout/header.html -->
<header th:fragment="header">
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container-fluid">
            <a class="navbar-brand" th:href="@{/}">Mi App</a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav ms-auto">
                    <li class="nav-item">
                        <a class="nav-link" th:href="@{/}">Inicio</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" th:href="@{/users}">Usuarios</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" th:href="@{/logout}">Cerrar sesión</a>
                    </li>
                </ul>
            </div>
        </div>
    </nav>
</header>

Llistat d'Usuaris

<!-- templates/user/list.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="${title}"></title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
</head>
<body>
    <div class="container mt-5">
        <div class="row mb-3">
            <div class="col-md-8">
                <h1 th:text="${title}"></h1>
            </div>
            <div class="col-md-4 text-end">
                <a th:href="@{/users/create}" class="btn btn-primary">
                    Crear Usuari
                </a>
            </div>
        </div>

        <!-- Taula d'usuaris -->
        <div th:if="${users.size() > 0}">
            <table class="table table-striped table-hover">
                <thead class="table-dark">
                    <tr>
                        <th>ID</th>
                        <th>Nom</th>
                        <th>Email</th>
                        <th>Accions</th>
                    </tr>
                </thead>
                <tbody>
                    <tr th:each="user : ${users}">
                        <td th:text="${user.id}"></td>
                        <td th:text="${user.firstName + ' ' + user.lastName}"></td>
                        <td th:text="${user.email}"></td>
                        <td>
                            <a th:href="@{/users/{id}(id=${user.id})}" class="btn btn-sm btn-info">
                                Veure
                            </a>
                            <a th:href="@{/users/{id}/edit(id=${user.id})}" class="btn btn-sm btn-warning">
                                Editar
                            </a>
                            <form th:action="@{/users/{id}(id=${user.id})}" method="post" 
                                  style="display:inline;" onsubmit="return confirm('Segur que vols eliminar?')">
                                <input type="hidden" name="_method" value="DELETE">
                                <button type="submit" class="btn btn-sm btn-danger">
                                    Eliminar
                                </button>
                            </form>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>

        <!-- Missatge si no hi ha usuaris -->
        <div th:if="${users.size() == 0}" class="alert alert-info">
            <p>No hi ha usuaris registrats. 
                <a th:href="@{/users/create}">Crear el primer usuari</a>
            </p>
        </div>
    </div>
</body>
</html>

Formulari de Creació

<!-- templates/user/create.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="${title}"></title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
</head>
<body>
    <div class="container mt-5">
        <div class="row">
            <div class="col-md-6 offset-md-3">
                <h1 th:text="${title}"></h1>

                <form th:action="@{/users}" th:object="${user}" method="post" class="mt-4">

                    <!-- Nom -->
                    <div class="mb-3">
                        <label for="firstName" class="form-label">Nom</label>
                        <input type="text" class="form-control" id="firstName" 
                               th:field="*{firstName}" required>
                        <small class="text-danger" th:if="${#fields.hasErrors('firstName')}" 
                               th:errors="*{firstName}"></small>
                    </div>

                    <!-- Cognom -->
                    <div class="mb-3">
                        <label for="lastName" class="form-label">Cognom</label>
                        <input type="text" class="form-control" id="lastName" 
                               th:field="*{lastName}" required>
                        <small class="text-danger" th:if="${#fields.hasErrors('lastName')}" 
                               th:errors="*{lastName}"></small>
                    </div>

                    <!-- Email -->
                    <div class="mb-3">
                        <label for="email" class="form-label">Email</label>
                        <input type="email" class="form-control" id="email" 
                               th:field="*{email}" required>
                        <small class="text-danger" th:if="${#fields.hasErrors('email')}" 
                               th:errors="*{email}"></small>
                    </div>

                    <!-- Botns -->
                    <div class="d-flex gap-2">
                        <button type="submit" class="btn btn-primary">
                            Guardar
                        </button>
                        <a th:href="@{/users}" class="btn btn-secondary">
                            Cancelar
                        </a>
                    </div>
                </form>
            </div>
        </div>
    </div>
</body>
</html>

Validació de Formularis

Entity amb Anotacions de Validació

package com.example.myapp.entity;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "El nom és obligatori")
    @Size(min = 2, max = 50, message = "El nom ha de tenir entre 2 i 50 caràcters")
    private String firstName;

    @NotBlank(message = "El cognom és obligatori")
    @Size(min = 2, max = 50)
    private String lastName;

    @NotBlank(message = "L'email és obligatori")
    @Email(message = "L'email ha de ser vàlid")
    @Column(unique = true)
    private String email;
}

Controlador amb Validació

@PostMapping
public String saveUser(@Valid @ModelAttribute User user, 
                       BindingResult bindingResult, 
                       Model model) {

    // Si hi ha errors de validació
    if (bindingResult.hasErrors()) {
        model.addAttribute("title", "Crear Usuari");
        return "user/create";  // Retorna el formulari amb errors
    }

    userService.saveUser(user);
    return "redirect:/users?success=true";
}

Template amb Mostrada d'Errors

<form th:action="@{/users}" th:object="${user}" method="post">

    <div class="mb-3">
        <label for="email" class="form-label">Email</label>
        <input type="email" 
               class="form-control" 
               id="email" 
               th:field="*{email}"
               th:classappend="${#fields.hasErrors('email') ? 'is-invalid' : ''}">

        <!-- Mostrar error si existeix -->
        <div class="invalid-feedback" 
             th:if="${#fields.hasErrors('email')}" 
             th:errors="*{email}">
        </div>
    </div>

</form>

Internacionalització (i18n)

Configurar Idiomes

# application.yml
spring:
  messages:
    basename: messages
    encoding: UTF-8
    fallback-to-system-locale: false
    default-locale: ca

Arxius de Mensajes

# src/main/resources/messages.properties (Català)
message.title=Benvingut a la meva app
message.welcome=Benvingut, {0}
message.save=Guardar
message.cancel=Cancelar
message.delete=Eliminar
message.success=Operació realitzada correctament
message.error=Ha ocorregut un error

# src/main/resources/messages_en.properties (Anglès)
message.title=Welcome to my app
message.welcome=Welcome, {0}
message.save=Save
message.cancel=Cancel
message.delete=Delete
message.success=Operation completed successfully
message.error=An error occurred

# src/main/resources/messages_es.properties (Espanyol)
message.title=Bienvenido a mi app
message.welcome=Bienvenido, {0}

Usar els Missatges

<!-- Missatge simple -->
<h1 th:text="#{message.title}"></h1>

<!-- Missatge amb paràmetres -->
<p th:text="#{message.welcome(${user.name})}"></p>

<!-- Selector d'idioma -->
<a th:href="@{(lang=ca)}">Català</a>
<a th:href="@{(lang=en)}">English</a>
<a th:href="@{(lang=es)}">Español</a>

Millors Pràctiques

1. Usar Fragments per a Reutilització

<!-- Definir fragment -->
<div th:fragment="message">
    <div class="alert alert-info">
        <span th:text="${message}"></span>
    </div>
</div>

<!-- Usar fragment -->
<div th:insert="~{fragments :: message}"></div>

2. Separar Lògica en Service

// NO fer això en el controller
model.addAttribute("result", complexCalculation());

// FER AIXÒ en el service
model.addAttribute("result", userService.complexCalculation());

3. Usar DTOs

@GetMapping
public String listUsers(Model model) {
    List<UserDTO> users = userService.getAllUsersAsDTO();
    model.addAttribute("users", users);
    return "user/list";
}

4. Cache de Templates en Producció

spring:
  thymeleaf:
    cache: false  # Development
    # cache: true  # Production

Comparació: REST API vs Thymeleaf en Spring Boot

Característica REST API Thymeleaf
Tipus de resposta JSON HTML
Client Aplicació separada Navegador
SEO Dolent (client rendering) Bon (server rendering)
Complexitat Simpler per APIs Simpler per aplicacions MVC
Interactivitat Necessita JS framework HTML natiu + JS
Escalabilitat Millor per microserveis Millor per monòlit

Recursos