Primeros pasos con Thymeleaf en Java y Spring

En este tutorial, profundizaremos en todo lo que necesita saber sobre Thymeleaf para comenzar con este motor de plantillas para Java y Spring.

Introducción

Al desarrollar aplicaciones web, una elección importante es qué motor se encargará de la capa de visualización.

Java Server Pages (JSP) solía ser muy popular, aunque la sobrecarga y el consumo de tiempo eran algunas de las principales desventajas de su uso. Requerían un poco de cambio en el HTML de las páginas.

Hoy en día, hoja de tomillo es ampliamente adoptado y utilizado como motor de plantillas para aplicaciones Spring/MVC. También se puede utilizar para plantillas de correo electrónico en HTML enriquecido. Mientras que los JSP se compilan en clases de servlet de Java, Thymeleaf analiza los archivos de plantilla HTML sin formato. Basado en las expresiones presentes en el archivo, genera contenido estático. Es capaz de procesar HTML, XML, JS, CSS, etc.

Dialectos estándar de hoja de tomillo {#dialectos estándar de hoja de tomillo}

Thymeleaf proporciona una amplia gama de procesadores de atributos listos para usar como parte de sus Dialectos estándar. Estos procesadores son suficientes para el procesamiento de plantillas más típico. Sin embargo, también puede ampliarlos para crear procesadores de atributos personalizados si es necesario.

Echemos un vistazo al segmento más importante del dialecto: las características de expresión estándar. Estas son algunas de las expresiones que usará con bastante frecuencia:

  • Expresiones Variables: ${...}
  • Expresiones de variables de selección: *{...}
  • Expresiones de mensaje: #{...}
  • Expresiones URL de enlace: @{...}
  • Expresiones de fragmentos: ~{...}

Aquí hay algunos literales que probablemente usará:

  • Literales de texto: 'hola mundo', 'Bienvenido a wikihtp',…
  • Literales numéricos: 0, 123, 67.90, …
  • Literales booleanos: verdadero, falso
  • Literal nulo: null

Operaciones básicas:

  • Concatenación de cadenas: +

  • Sustituciones literales: |Bienvenido a ${city}|

  • Operadores binarios: +, -, *, /, `%

  • Operadores binarios: y, o

  • Negación booleana (operador unario): !, not

Comparaciones:

  • Comparadores: >, <, >=, <= (gt, lt, ge, le)
  • Operadores de igualdad: ==, != (eq, ne)

Condicionales:

  • Si-entonces: (si) ? (entonces)
  • If-then-else: (si) ? (entonces) : (más)
  • Predeterminado: (valor) ?: (valor predeterminado)

Todas estas expresiones se pueden usar en combinación entre sí para obtener los resultados deseados.

Dependencia de la hoja de tomillo {#dependencia de la hoja de tomillo}

La forma más fácil de comenzar con Thymleaf a través de Maven es incluir la dependencia:

1
2
3
4
5
<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf</artifactId>
    <version>${version}</version>
</dependency>

O, si estás usando Gradle:

1
compile group: 'org.thymeleaf', name: 'thymeleaf', version: '${version}'

Motor de plantillas y solucionadores de plantillas

Para Thymeleaf, Template Resolver es responsable de cargar las plantillas desde una ubicación determinada, mientras que Template Engine es responsable de procesarlas para un contexto determinado. Tendremos que configurar ambos en una clase de configuración:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public ClassLoaderTemplateResolver templateResolver() {
        ClassLoaderTemplateResolver templateResolver = 
                new ClassLoaderTemplateResolver();
        templateResolver.setPrefix("/templates/");
        templateResolver.setSuffix(".html");
        templateResolver.setCharacterEncoding("UTF-8");

        return templateResolver;
    }

    @Bean
    public SpringTemplateEngine templateEngine() {
        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
        templateEngine.setTemplateResolver(templateResolver());
        return templateEngine;
    }
}

Aquí, instanciamos un templateResolver y establecimos su prefijo y sufijo. Las vistas se ubicarán en el directorio /templates y terminarán en .html.

Después de eso, configuramos templateEngine, simplemente configurando el resolver y devolviéndolo.

Probemos si funciona intentando procesar un mensaje:

1
2
3
4
5
6
7
StringWriter writer = new StringWriter();
Context context = new Context();
TemplateEngine templateEngine = templateEngine();

context.setVariable("message", "Welcome to thymeleaf article");
templateEngine.process("myTemplate", context, writer);
LOG.info(writer.toString());

El motor se utiliza para procesar el archivo myTemplate.html, ubicado en el directorio src/main/resources/templates. El directorio /resources es el predeterminado. Se pasa una variable al contexto, lo que nos permite hacer referencia a ella en la propia plantilla:

1
2
3
4
5
6
7
<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-3.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<body>
    <h1 th:text="${message}"></h1>
</body>
</html>

El atributo th:text evaluará el valor de este mensaje y lo insertará en el cuerpo de la etiqueta en la que se encuentra. En nuestro caso, el cuerpo de la etiqueta <h1>:

1
2
3
4
5
6
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
    <h1>Welcome to thymeleaf article</h1>
</body>
</html>

¡Funciona bien! Avancemos y configuremos un ViewResolver para que podamos completar las vistas a través de los controladores, en lugar de codificar valores en el contexto.

Resolución de vista

Justo debajo de la otra configuración, configuremos ViewResolver. Asigna los nombres de vista a las vistas reales. Esto nos permite simplemente hacer referencia a las vistas en los controladores, en lugar de valores de codificación duros:

1
2
3
4
5
6
7
@Bean
public ViewResolver viewResolver() {
    ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
    viewResolver.setTemplateEngine(templateEngine());
    viewResolver.setCharacterEncoding("UTF-8");
    return viewResolver;
}

Visualización de los atributos del modelo

El uso más básico de la mayoría de los motores como Thymeleaf es mostrar ciertas propiedades/atributos de los modelos. Vamos a crear un controlador de solicitudes que devuelva un objeto con un par de campos establecidos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@GetMapping("/article")
public ModelAndView getArticle(ModelAndView modelAndView) {
    Article article = new Article();
    article.setAuthor(getName());
    article.setContent(getArticleContent());
    article.setTitle(getTitle());
    modelAndView.addObject("article", article);
    modelAndView.setViewName("articleView");
    return modelAndView;
}

El controlador está devolviendo la vista, llamada articleView y un objeto llamado article. Estos dos ahora están interconectados. Podemos acceder al artículo en la página articleView. Esto es similar a cómo inyectamos el ‘mensaje’ en el objeto ‘Contexto’ la última vez.

Echemos un vistazo a cómo podemos acceder a un objeto y mostrar sus valores en una página:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-3.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<link th:href="@{/css/app.css}" rel="stylesheet"/>
<body class='typora-export os-windows'>
<div id='write' class='is-node'>
    <h1 th:text="${article.title}">Article title</h1>
    <h4 th:text="${article.author}">Author name</h4>
    <p th:text="${article.content}">contetnt</p></div>
</body>
</html>

Usando la expresión variable, ${...}, hacemos referencia al objeto article e inyectamos los campos en los atributos th:text en consecuencia. Así es como se vería la página renderizada:

displaying model attributes

Nota: Si una etiqueta tiene un cuerpo, th:text lo anulará. Si el valor no está presente o si hay problemas para mostrarlo, se usará el cuerpo en su lugar.

Variables locales

Las variables locales en Thymeleaf son muy útiles. Las variables locales se definen dentro de un fragmento específico de una plantilla. Están disponibles solo en el ámbito del fragmento de definición.

Con las variables locales, evitamos la necesidad de hacer todo en el controlador y realizar operaciones en la página misma. Vamos a ver:

1
2
3
4
5
<tr th:each="article : ${articles}">
    <td th:text="${article.name}">name</td>
    <td th:text="${article.author}">author</td>
    <td th:text="${article.description">description</td>
</tr>

Aquí, la variable artículo es una variable local. Representa un objeto ‘artículo’ de la lista de ‘artículos’. No podemos hacer referencia a la variable artículo fuera de la tabla HTML.

La variable artículo no fue transmitida por el controlador, sino que se definió en la propia página. El atributo th:each asignará nuevos valores al objeto article en cada paso de la lista.

Esto se vería algo como:

article list

Otra forma de definir variables locales es a través del atributo th:with:

1
2
3
4
5
<div th:with="article=${articles[0]}">
    <p>
        This article is writen by <span th:text="${article.author}">John Doe</span>.
    </p>
</div>

Aquí, hemos definido una variable a través de th:with como el primer elemento de la lista transmitida por el controlador. Podemos hacer referencia a esta variable desde dentro de la etiqueta <div> en la que está definida.

De manera similar, podemos definir múltiples variables con un solo atributo th:with:

1
2
3
4
5
6
7
8
<div th:with="article=${articles[0]}, category=${categories[1]}">
    <p>
        This article is writen by <span th:text="${article.author}">John Doe</span>.
    </p>
    <p>
        Category <span th:text="${category.name}">John Doe</span>.
    </p>
</div>

También podemos usar estas variables locales para realizar la manipulación o recuperación de datos para reducir las invocaciones del controlador:

1
2
<div th:with="article=${articles[0]}, author=${authors[article.author]}">
</div>

Tenga en cuenta que usamos la variable ‘artículo’ para obtener los detalles del ‘autor’ del mapa del autor. Esto nos permite reutilizar la variable dentro del mismo atributo.

Además, ahora ya no necesitamos depender del controlador para compartir los detalles del autor de cada artículo, sino que podemos pasar la lista de autores además de la lista de artículos:

1
2
3
4
5
6
7
@GetMapping("/articles")
public ModelAndView getArticles(ModelAndView modelAndView) {
    modelAndView.addObject("articles", getArticles());
    modelAndView.addObject("authors", getAuthors());
    modelAndView.setViewName("articles");
    return modelAndView;
}

No tiene que establecer variables locales vinculadas a objetos. Puede usar con la misma facilidad literales de cadena o números:

1
2
3
<div th:with="name = 'John', age = 25}">
    <p> Hello, <span th:text="${name}"></span>!</p>
</div>

Expresiones de variables de selección

Lo que vale la pena señalar aquí son Expresiones de variables de selección. Echemos un vistazo a cómo funcionan:

1
2
3
4
5
<div th:object="${article}">
    <td th:text="*{name}">name</td>
    <td th:text="*{author}">author</td>
    <td th:text="*{description">description</td>
</tr>

En lugar de escribir ${article.name}, ${article.author}, etc., podemos simplemente poner una expresión *{...}. El atributo th:object define a qué objeto pertenecen los campos referenciados.

Creación de formularios y entradas

El manejo de formularios es frecuente y es una de las formas más fundamentales en que un usuario puede enviar información a nuestro backend. Thymeleaf proporciona varios atributos para crear y manejar envíos de formularios.

El atributo th:action reemplaza el atributo HTML action de un <formulario>. El atributo th:object se utiliza para vincular los campos del formulario a un objeto. Esto es similar a modelAttribute o commandName que normalmente usaría con JSP.

Echemos un vistazo a la definición de un formulario:

1
2
<form th:action="@{/article}" th:object="${article}" method="post">
</form>

Aquí, a través de una expresión de enlace, el formulario activa una solicitud POST a la URL /article. El objeto enlazado es un artículo. Ahora, necesitaremos ingresar algunos campos de entrada para que podamos completar la información del artículo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<form th:action="@{/article}" th:object="${article}" method="post">
    <div class='is-node custom-form'>
        <label>Title:</label>
        <input type="text" th:field="*{title}"/>
    </div>
    <div class='is-node custom-form'>
        <label>Content:</label>
        <textarea th:field="*{content}"/>
    </div>
</form>

Hemos vinculado un ‘artículo’ a este formulario, por lo que el ’título’ y el ‘contenido’ a los que se hace referencia pertenecen a él.

Ahora, una vez que el usuario ingrese el contenido en estos campos, querremos procesarlo y guardarlo en la base de datos. Hagamos un manejador /form que renderice el formulario en la página primero:

1
2
3
4
5
6
7
@GetMapping("/form")
public ModelAndView getArticleForm(ModelAndView modelAndView) {
    Article article = new Article();
    modelAndView.addObject("article", article);
    modelAndView.setViewName("articleForm");
    return modelAndView;
}

form display post

Tenemos que agregar un objeto ‘artículo’ en blanco al formulario, de lo contrario, el atributo ’th:object’ no sería válido. Ahora, hagamos un controlador de solicitud POST que el formulario acierte:

1
2
3
4
5
@PostMapping("/article")
public String saveArticle(@ModelAttribute Article article) {
    articleService.saveArticle(article);
    return "articles";
}

Aquí, la anotación @ModelAttribute vincula el modelo recibido al objeto que lo precede. Todo está empaquetado en el objeto article que luego se guarda a través de un servicio clásico que amplía el CrudRepository.

Sin embargo, una forma rudimentaria como esta a menudo no es suficiente. Echemos un vistazo a cómo podemos agregar botones de radio, casillas de verificación, menús desplegables, etc.

Botones de radio {#botones de radio}

Para agregar un botón de radio, haríamos una etiqueta <input> clásica y definiríamos su tipo a través de HTML. La tarea de Thymeleaf es vincular el campo y el valor de ese botón de opción al th:object del formulario:

1
2
3
4
5
6
7
8
9
<form th:action="@{/article}" th:object="${article}" method="post">
    <div>
        <label>Select a Category:</label>
        <div th:each="category : ${categories}">
            <input type="radio" th:field="*{category}" th:value="${category}" />
            <label th:for="${#ids.prev('category')}" th:text="${category}"></label>
        </div>
    </div>
</form>

Una vez renderizado, esto sería algo como:

radio buttons

casillas de verificación

Las casillas de verificación funcionan exactamente de la misma manera:

1
2
3
4
5
6
7
8
9
<form th:action="@{/article}" th:object="${article}" method="post">
    <div class='is-node custom-form'>
        <label>Select Areas:</label>
        <div th:each="area : ${areas}">
            <input type="checkbox" th:field="*{area}" th:value="${area}"/>
            <label th:for="${#ids.prev('area')}" th:text="${area}"></label>
        </div>
    </div>
</form>

Esto se vería como:

checkboxes

Menús de opciones

Y finalmente, echemos un vistazo a cómo podemos poner algunas opciones:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<form th:action="@{/article}" th:object="${article}" method="post">
    <div class='is-node custom-form'>
        <label>Select a Technology:</label>
        <select th:field="*{technology}">
            <option th:each="technology : ${technologies}" th:value="${technology}"
                    th:text="${technology}">
            </option>
        </select>
    </div>
</form>

Normalmente, las opciones se representan a partir de una lista. En este caso, hemos creado una etiqueta <opción> para cada tecnología en una lista, y hemos asignado el valor tecnología para que el usuario lo vea.

Esto se vería algo como:

option menus

Declaraciones condicionales

Los sitios web no son estáticos. Dependiendo de ciertas evaluaciones, los elementos se muestran, ocultan, reemplazan o personalizan. Por ejemplo, podríamos optar por mostrar un mensaje en lugar de una tabla si no hay filas en la base de datos.

Echemos un vistazo a algunas declaraciones condicionales básicas en Thymeleaf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<body>
    <table th:if="${not #list.isEmpty(articles)}">
        <tr>
            <th>Name</th>
            <th>Author</th>
            <th>Description</th>
            <th>Category</th>
            <th>Date</th>
        </tr>
        <tr th:each="article : ${articles}">
            <td th:text="${article.name}">name</td>
            <td th:text="${article.author}">author</td>
            <td th:text="${article.description">description</td>
            <td th:text="${article.category}">category</td>
            <td th:text="${article.date}">date</td>
        </tr>
    </table>

    <div th:if="${#lists.isEmpty(kv)}">
        <h2>No data found</h2>
    </div>
</body>

th:if se usa como una sentencia if regular. Si la lista de artículos no está vacía, completamos una tabla; si está vacía, mostramos un mensaje. Aquí, la #lista es un objeto de utilidad que se utiliza para realizar métodos de conveniencia en las colecciones.

Además, también podemos tener declaraciones th:switch y th:case. Son bastante sencillos:

1
2
3
4
5
6
7
<div>
    <td th:switch="${article.category}">
        <span th:case="'TECHNOLOGY'" th:text="Technical Articles"/>
        <span th:case="'FASHION'" th:text="About latest fashion trends"/>
        <span th:case="'FOOD'" th:text="Are you hungry..."/>
    </td>
</div>

Solo se muestra el caso coincidente.

Externalización de texto para internacionalización {#externalización de texto para internacionalización}

Fuera de la caja, Thymeleaf viene con soporte de internacionalización. Cree un archivo myTemplate.properties en el mismo directorio que el de sus plantillas.

Vamos a hacer un mensaje y asignarle un valor:

1
welcome.message=Welcome to Stack Abuse

Ahora, en cualquier plantilla, podemos hacer referencia al valor solicitando welcome.message con una Expresión de mensaje:

1
2
3
<body>
    <h1 th:text="#{welcome.message}"></h1>
</body>

Para usar diferentes configuraciones regionales, cree más archivos como myTemplate_de.properties. Al crear el contexto para la plantilla, en la configuración original, simplemente pásele la configuración regional:

1
Context context = new Context(Locale.GERMAN);

Fragmentos y diseños

Algunas cosas en una página no cambian mucho a lo largo de todo el front-end. Es decir, el encabezado y el pie de página suelen ser exactamente iguales. Además, una vez que estos se modifican/actualizan, debe ir a todas y cada una de las páginas y actualizar el código allí también.

Este código repetitivo se puede reutilizar y simplemente hacer referencia en cada página. Thymeleaf nos ofrece fragmentos, que son archivos individuales que puedes insertar en otro archivo. Vamos a crear un fragmento de encabezado e incluirlo en otra plantilla:

1
2
3
4
5
6
7
8
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
  <body> 
    <div th:fragment="header_fragment">
      <h1>Welcome to Stack Abuse</h1>
    </div>
  </body>  
</html>

Guardaremos este archivo, llamado header.html en el mismo directorio que otras plantillas. Sin embargo, muchos los guardan en un subdirectorio llamado fragmentos.

Ahora, querremos incluir este encabezado en otra página. Tenga en cuenta que esto no incluirá el archivo completo. Solo el <div> que marcamos como th:fragment. Pongamos este encabezado encima de nuestro mensaje de bienvenida:

1
2
3
4
<body>
    <div id="holder" th:insert="header :: header_fragment"></div>
    <h1 th:text="#{welcome.message}"></h1>
</body>

Cuando rendericemos este archivo, la página HTML se verá así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
    <div id="holder">
        <div>
            <h1>Welcome to Stack Abuse Article</h1>
        </div>
    </div
    <h1>Welcome to world</h1>
</body>
</html>

Ahora, hay tres formas de incluir fragmentos: th:insert, th:replace y th:include.

th:insert agrega el fragmento como un nodo secundario dentro de la etiqueta adjunta. Como podemos ver en el ejemplo anterior, el fragmento de encabezado se inserta en <div> con la identificación holder.

th:replace reemplazará la etiqueta actual con el fragmento:

1
2
3
4
<body>
    <div id="holder" th:replace="header :: header_fragment"></div>
    <h1 th:text="#{welcome.message}"></h1>
</body>

Esto se traduciría como:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
    <div>
        <h1>Welcome to Stack Abuse Article</h1>
    </div>
    <h1>Welcome to world</h1>
</body>
</html>

El <div> con el ID de titular ahora se reemplaza con el fragmento.

th:include es un predecesor de la etiqueta th:replace y funciona de la misma manera. Ahora, está en desuso.

Gestión de errores y mensajes de error

El manejo de errores es un aspecto muy importante de las aplicaciones web. Cuando algo está mal, queremos guiar al usuario para que solucione los problemas creados por el usuario, como envíos de formularios incorrectos.

En aras de la simplicidad, usaremos javax.validations para verificar los campos de envío de un formulario:

1
2
3
4
5
6
7
8
@PostMapping("/article")
public String saveArticle(@ModelAttribute @Valid Article article, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        return "articleForm";
    }
    articleService.saveArticle(article);
    return "redirect:articles";
}

Este es un controlador de envío de formulario clásico. Hemos empaquetado la información en un objeto ‘artículo’ y la hemos guardado en una base de datos. Sin embargo, esta vez, marcamos el artículo como @Valid y agregamos una verificación para la instancia BindingResult.

La anotación @Valid se asegura de que la información del objeto recibida y empaquetada se ajuste a las validaciones que hemos establecido en el modelo Artículo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class Article {
    @NotNull
    @Size(min = 2, max = 30)
    private String title;
    private String author;
    @NotNull
    @Size(min = 2, max = 1000)
    private String content;
    private String category;
    private String technology;
    private String area;
}

Si hay alguna violación de estas reglas, bindingResults.hasErrors() devolverá true. Y así devolvemos el formulario. en lugar de redirigir al usuario a la página /articles.

Los errores se mostrarán en el formulario, en los lugares designados que hemos establecido con th:errors:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<form th:action="@{/article}" th:object="${article}" method="post">
    <div class='is-node custom-form'>
        <label>Title:</label>
        <input type="text" th:field="*{title}"/>
        <span class="field-error" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Name Error</span>
    </div>
    <div class='is-node custom-form'>
        <label>Content:</label>
        <textarea th:field="*{content}"/>
        <span class="field-error" th:if="${#fields.hasErrors('content')}" th:errors="*{content}">Name Error</span>
    </div>
</form> 

Usando un par de condicionales y los convenientes métodos #fields.hasErrors(), podemos hacerle saber al usuario cuál es el problema con las validaciones y educadamente solicitar una revisión de la información enviada.

Así es como se vería la página renderizada:

manejo de errores con thymeleaf

Alternativamente, también podemos agrupar todos los errores usando un comodín o todos:

1
2
<li class="field-error" th:each="error : ${#fields.errors('*')}" th:text="${error}" />
<li class="field-error" th:each="error : ${#fields.errors('all')}" th:text="${error}" />

error group

Conclusión

Este artículo pretende ser una puerta de entrada a Thymeleaf, un motor de plantillas moderno y muy popular para aplicaciones Java/Spring.

Si bien no hemos profundizado en el motor, que es bastante extenso, el material cubierto debería ser más que suficiente para que pueda comenzar con una buena base para funciones más avanzadas.