Descarga el proyecto de GitHub.
Ahora que sabemos construir un proyecto con Spring Boot, usar el motor de plantillas Thymeleaf e implementar un CRUD con Spring Mvc, implementemos un proyecto utilizando todo lo anteriormente expuesto. Lo completaremos en un proyecto posterior añadiendo alguna funcionalidad como un autocompletado con jQuery y una paginación.
Además en este proyecto tendremos dos modelos y de esta manera estableceremos una relación uno a muchos, de esta manera, cuando hayamos terminado espero tener un proyecto medianamente serio y realista.
spring.jpa.hibernate.ddl-auto: update
spring.jpa.show-sql: true
spring.jpa.properties.hibernate.dialect: org.hibernate.dialect.MySQL5Dialect
spring.datasource.url: jdbc:mysql://localhost:3306/testdb
spring.datasource.username: root
spring.datasource.password:
spring.datasource.driverClassName: com.mysql.jdbc.Driver
spring.datasource.type: com.zaxxer.hikari.HikariDataSource
## HikariCP config
spring.datasource.hikari.pool-name: pool-hikari-example
spring.datasource.hikari.maximum-pool-size: 10
spring.datasource.hikari.connection-timeout: 60000
Encontramos unas lineas desconocidas en el application.properties:
...
## HikariCP config
spring.datasource.hikari.pool-name: pool-hikari-example
spring.datasource.hikari.maximum-pool-size: 10
spring.datasource.hikari.connection-timeout: 60000
Estamos estableciendo un pool de conexiones con HikariCP. Os dejo unos enlaces interesantes al respecto.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>nz.net.ultraq.thymeleaf</groupId>
<artifactId>thymeleaf-layout-dialect</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- webjars -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>4.1.0</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery-ui</artifactId>
<version>1.12.1</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery-ui-themes</artifactId>
<version>1.12.1</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>font-awesome</artifactId>
<version>5.11.2</version>
</dependency>
</dependencies>
Como novedad vemos el starter spring-boot-starter-data-jpa y los webjars que vamos a necesitar en el proyecto.
Nota: Cuando me refiero al proyecto, estoy refiriendome a este que estamos viendo y al próximo, pues es la continuación de este. Quiere decir que para este puede que me sobren algún webjar, pero me harán falta para la continuación.
Ver el proyecto anterior
package com.wanchopi.models;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import lombok.Data;
import javax.validation.constraints.Email;
/**
* Player entity
* @author Wanchopi
*
*/
@Entity
@Table(name = "PLAYERS")
public @Data class Player {
@Id
@Column(name = "ID")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "NAME")
@NotNull
@Size(min=5, max=24)
private String name;
@Column(name = "EMAIL")
@NotNull
@Email
private String email;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ID_TEAM")
@NotNull
private Team team;
}
package com.wanchopi.models;
import java.util.ArrayList;
import java.util.List;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import javax.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* Team entity
* @author Wanchopi
*
*/
@Entity
@Table(name = "TEAMS")
public class Team {
@Id
@Column(name = "ID")
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Setter @Getter
private Long id;
@Column(name = "NAME")
@Setter @Getter
private String name;
@Column(name = "CITY")
@Setter @Getter
private String city;
@OneToMany(mappedBy = "team")
@Setter @Getter
private List<Player> players = new ArrayList<Player>();
}
Establecemos una relación de muchos a uno, un Team (equipo) puede tener muchos Player (jugadores).
package com.wanchopi.controllers;
import javax.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.wanchopi.models.Player;
import com.wanchopi.repositories.PlayerRepository;
import com.wanchopi.repositories.TeamRepository;
/**
* Main controller
*
* @author Wanchopi
*
*/
@Controller
public class AppController {
@Autowired
private TeamRepository teamRepository;
@Autowired
private PlayerRepository playerRepository;
@InitBinder
public void initBinder(WebDataBinder dataBinder) {
StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
}
@RequestMapping(value = "/")
public String home(ModelMap mp) {
mp.put("players", playerRepository.findAll());
return "index";
}
@GetMapping(value = "/create")
public String showForm(ModelMap mp) {
mp.put("player", new Player());
mp.put("teams", teamRepository.findAll());
return "player-form";
}
@PostMapping(value = "/save")
public String save(@Valid Player player, BindingResult br , ModelMap mp) {
if (br.hasErrors()) {
mp.put("teams", teamRepository.findAll());
return "player-form";
} else {
playerRepository.save(player);
mp.put("players", playerRepository.findAll());
return "redirect:/";
}
}
@GetMapping(value = "/edit/{id}")
public String showEditForm(@PathVariable("id") long id, ModelMap mp) {
Player player = playerRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid user Id:" + id));
mp.put("player", player);
mp.put("teams", teamRepository.findAll());
return "/update-form";
}
@PostMapping(value = "/update")
public String update(@Valid Player player, BindingResult br , ModelMap mp) {
if (br.hasErrors()) {
mp.put("teams", teamRepository.findAll());
return "/update-form";
} else {
playerRepository.save(player);
mp.put("players", playerRepository.findAll());
return "redirect:/";
}
}
@GetMapping(value = "/delete/{id}")
public String delete(@PathVariable("id") long id, ModelMap mp) {
Player player = playerRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid user Id:" + id));
playerRepository.delete(player);
mp.put("players", playerRepository.findAll());
return "redirect:/";
}
}
En el controller los métodos típicos para un CRUD con su validación etc...
package com.wanchopi.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.wanchopi.models.Player;
/**
* Player repository
*
* @author Wanchopi
*
*/
@Repository
public interface PlayerRepository extends JpaRepository<Player, Long>{}
package com.wanchopi.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.wanchopi.models.Team;
/**
* Team repository
*
* @author Wanchopi
*
*/
@Repository
public interface TeamRepository extends JpaRepository<Team, Long>{}
En los repositorios ya hemos visto lo que Spring Data JPA hace por los desarrolladores. No hay nada mas que añadir.
Nota: Cuando completemos este proyecto con una paginación y un autocompletado implementaremos una capa de servicio pues a parte de los métodos que nos ofrece JpaRepository tendremos que implementar los nuestros propios.
package com.wanchopi.controllers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import com.wanchopi.repositories.PlayerRepository;
import com.wanchopi.repositories.TeamRepository;
@RunWith(SpringRunner.class)
@WebMvcTest(AppController.class)
@AutoConfigureMockMvc
public class AppControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private AppController controller;
@MockBean
private TeamRepository teamRepository;
@MockBean
private PlayerRepository playerRepository;
@Test
public void contexLoads() throws Exception {
assertThat(controller).isNotNull();
}
@Test
public void testHome() throws Exception {
this.mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andDo(print())
.andExpect(view().name("index"));
}
}
Test de integración con MockMVC. Hay mucho que decir respecto a los test de integración pero lo dejaremos para otro tutorial pues se extendería este demasiado. Simplemente dejar un enlace como introducción a los test de integración.
<header th:fragment="header" xmlns:th="http://www.w3.org/1999/xhtml">
<!-- Static navbar -->
<nav class="navbar navbar-light bg-light">
<a th:href="@{/}"><img alt="logo" th:src="@{/assets/images/mini-logo.png}"></a>
<form class="form-inline my-2 my-lg-0" action="#" method="post">
<input class="form-control mr-sm-2" type="text" name="keyword" placeholder="Team search"
aria-label="Search" id="teamName">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
</form>
</nav>
<!-- header -->
<div class="container">
<div class="row">
<div class="col-lg-12" align="center">
<img alt="logo" th:src="@{/assets/images/main-logo.png}">
</div>
</div>
</div>
</header>
<div th:fragment="footer" class="footer" xmlns:th="http://www.w3.org/1999/xhtml">
<div class="container">
<p><span th:text="#{text.footer}" class="text-muted">App footer</span></p>
</div>
</div>
<header th:fragment="header" xmlns:th="http://www.w3.org/1999/xhtml">
<!-- header -->
<div class="container">
<div class="row">
<div class="col-lg-12" align="center">
<img alt="logo" th:src="@{/assets/images/main-logo.png}">
</div>
</div>
</div>
</header>
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title layout:title-pattern="$DECORATOR_TITLE - $CONTENT_TITLE">SpringBootThymeleaf</title>
<meta
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
name="viewport" />
<link rel="stylesheet" type="text/css" href="webjars/bootstrap/4.1.0/css/bootstrap.min.css" />
<link rel="stylesheet" type="text/css" href="/webjars/jquery/3.4.1/jquery.min.css" />
<link rel="stylesheet" type="text/css" href="webjars/jquery-ui/1.12.1/jquery-ui.min.css" />
<link rel="stylesheet" type="text/css" href="webjars/jquery-ui-themes/1.12.1/blitzer/theme.css" />
<link rel="stylesheet" type="text/css" href="webjars/font-awesome/5.11.2/css/all.min.css" />
<link rel="stylesheet" type="text/css" th:href="@{/assets/css/main.css}"/>
</head>
<body>
<!--header.html that's here -->
<header>
<div th:replace="fragments/header :: header"></div>
</header>
<div layout:fragment="content">
<!-- Your Page Content Here -->
</div>
<!--footer.html that's here -->
<footer>
<div th:replace="fragments/footer :: footer"></div>
</footer>
<!-- JS -->
<script type="text/javascript" src="/webjars/bootstrap/4.1.0/js/bootstrap.min.js"></script>
<script type="text/javascript" src="/webjars/jquery/3.4.1/jquery.min.js"></script>
<script type="text/javascript" src="/webjars/jquery-ui/1.12.1/jquery-ui.min.js"></script>
<script type="text/javascript">
$(document).ready(function () {
$('.fa-times').css('color', '#ff0000');
$('.fa-edit').css('color', '#0000ff');
});
</script>
<!-- blinking effect -->
<script>
$(document).ready(function blink() {
$('#a-save').fadeIn(750).delay(250).fadeOut(1000, blink);
});
</script>
<script>
$(document).ready(function() {
$('#name').focus();
});
</script>
</body>
</html>
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title layout:title-pattern="$DECORATOR_TITLE - $CONTENT_TITLE">SpringBootThymeleaf</title>
<meta
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
name="viewport" />
<link rel="stylesheet" type="text/css" href="webjars/bootstrap/4.1.0/css/bootstrap.min.css" />
<link rel="stylesheet" type="text/css" th:href="@{/assets/css/main.css}"/>
</head>
<body>
<!--header.html that's here -->
<header>
<div th:replace="fragments/header-error :: header"></div>
</header>
<div layout:fragment="content">
<!-- Your Page Content Here -->
</div>
<!--footer.html that's here -->
<footer>
<div th:replace="fragments/footer :: footer"></div>
</footer>
</body>
</html>
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorator="layouts/layout">
<head>
<title>index :: n.b.a.</title>
</head>
<body>
<!-- body -->
<div layout:fragment="content">
<div class="container-fluid" align="center">
<div class="col-lg-9" style="margin-top: 50px;">
<div th:if="${!players.isEmpty()}">
<table class="table">
<thead class="thead-dark">
<tr align="center">
<th scope="col">ID</th>
<th scope="col">NAME</th>
<th scope="col">E-MAIL</th>
<th scope="col">TEAM</th>
<th scope="col">EDIT</th>
<th scope="col">DELETE</th>
</tr>
</thead>
<tbody>
<tr align="center" th:each="player: ${players}">
<td th:text="${player.id}"></td>
<td th:text="${player.name}"></td>
<td th:text="${player.email}"></td>
<td th:text="${player.team.name}"></td>
<td><a th:href="@{/edit/{id}(id=${player.id})}"><i class="fa fa-edit"></i></a></td>
<td><a th:href="@{/delete/{id}(id=${player.id})}"><i class="fa fa-times"></i></a></td>
</tr>
</tbody>
</table>
</div>
<div th:unless="${!players.isEmpty()}">
<p>no items to show</p>
</div>
<a class="a-save" id="a-save" th:href="@{/create}">save a new Player</a>
</div>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorator="layouts/layout">
<head>
<!-- fonts googleapis-->
<link href="https://fonts.googleapis.com/css?family=Raleway&display=swap" rel="stylesheet">
<title>form :: springboot</title>
</head>
<body>
<!-- body -->
<div layout:fragment="content">
<div class="container-fluid" align="center"> <!-- main container -->
<div class="col-lg-9 player-form">
<form th:action="@{save}" th:object="${player}" method="post" id="player-form">
<!-- name -->
<div class="form-group">
<label for="name" class="col-lg-4 control-label font-weight-bold">Name</label>
<div class="col-lg-4">
<input type="text" th:field="*{name}" class="form-control" id="name" placeholder="Name" required="required"/>
<span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="alert text-danger"></span>
</div>
</div>
<!-- email -->
<div class="form-group">
<label for="email" class="col-lg-4 control-label font-weight-bold">Email</label>
<div class="col-lg-4">
<input type="text" th:field="*{email}" class="form-control" id="email" placeholder="Email" required="required"/>
<span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="alert text-danger"></span>
</div>
</div>
<!-- team -->
<div class="form-group">
<label for="team" class="col-lg-4 control-label font-weight-bold">Team</label>
<div class="col-lg-4" align="left">
<select th:field="*{team}" class="form-control">
<option value=""> Select team </option>
<option th:each="team : ${teams}"
th:value="${team.id}"
th:text="${team.name}"
th:selected="false"
/>
</select>
<span th:if="${#fields.hasErrors('team')}" th:errors="*{team}" class="alert text-danger"></span>
</div>
</div>
<!-- Button -->
<div class="form-group">
<div class="col-lg-4" align="left">
<input type="submit" class="btn" value="Save">
</div>
</div>
</form>
</div>
</div> <!-- /main container -->
</div> <!-- /content -->
</body>
</html>
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorator="layouts/layout">
<head>
<!-- fonts googleapis-->
<link href="https://fonts.googleapis.com/css?family=Raleway&display=swap" rel="stylesheet">
<title>form :: springboot</title>
</head>
<body>
<!-- body -->
<div layout:fragment="content">
<div class="container-fluid" align="center"> <!-- main container -->
<div class="col-lg-9 player-form">
<form th:action="@{/update}" th:object="${player}" method="post" id="player-form">
<!-- id -->
<div class="form-group">
<label for="id" class="col-md-4 control-label font-weight-bold">ID</label>
<div class="col-md-4">
<input type="text" th:field="*{id}" class="form-control" id="id" placeholder="" readonly="readonly"/>
<span th:if="${#fields.hasErrors('id')}" th:errors="*{id}" class="alert text-danger"></span>
</div>
</div>
<!-- name -->
<div class="form-group">
<label for="name" class="col-md-4 control-label font-weight-bold">Name</label>
<div class="col-md-4">
<input type="text" th:field="*{name}" class="form-control" id="name" placeholder="" required="required"/>
<span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="alert text-danger"></span>
</div>
</div>
<!-- email -->
<div class="form-group">
<label for="email" class="col-md-4 control-label font-weight-bold">Email</label>
<div class="col-md-4">
<input type="text" th:field="*{email}" class="form-control" id="email" placeholder="" required="required"/>
<span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="alert text-danger"></span>
</div>
</div>
<!-- team -->
<div class="form-group">
<label for="team" class="col-md-4 control-label font-weight-bold">Team</label>
<div class="col-lg-4" align="left">
<select th:field="*{team}" class="form-control">
<option value=""></option>
<option th:each="team : ${teams}"
th:value="${team.id}"
th:text="${team.name}"
th:selected="false"
/>
</select>
<span th:if="${#fields.hasErrors('team')}" th:errors="*{team}" class="alert text-danger"></span>
</div>
</div>
<!-- Button -->
<div class="form-group">
<div class="col-md-4" align="left">
<input type="submit" class="btn" value="Update">
</div>
</div>
</form>
</div>
</div> <!-- /main container -->
</div>
</body>
</html>
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorator="layouts/layout-error">
<head>
<title>error :: n.b.a.</title>
</head>
<body>
<div layout:fragment="content">
<div class="container-fluid" align="center">
<h1>Custom error page</h1>
<div class="messages-error">
<ul>
<li><span>Status : </span><span th:text="${status}">status</span></li>
<li><span>Error : </span><span th:text="${error}">error</span></li>
<li><span>Message : </span><span th:text="${message}">message</span></li>
<li><span>Path : </span><span th:text="${path}">path</span></li>
<!-- <li><span>Trace : </span><span th:text="${trace}">trace</span></li> -->
</ul>
</div>
<a id="a-error" th:href="@{/}"> - Return main page - </a>
</div>
</div>
</body>
</html>
Que después de ejecutar tendriamos algo así: