Собственно, подтолкнуло меня на написание этой статьи осознание работы фронтенда с бэкендом вместе и непонимание PUT запроса. Везде где я “гуглил” был реализован Rest API с запросами POST и GET, иногда с DELETE и не было примеров фронтенда. Хочется донести, в первую очередь, таким же как я реализацию REST API вместе с фронтендом, чтобы пришло понимание. Но статья предназначена не только для новичков коим я являюсь, а также для опытных юзеров Spring технологий, потому как в комментариях хочется увидеть праведные наставления старших товарищей. Ведь я буду описывать мое решение опираясь на свой опыт (читайте отсутствие опыта).
Я столкнулся с проблемой понимания Spring, а конкретно с запросом PUT, то бишь изменение данных элемента в БД. Также опишу запросы POST и GET. В общем стандартный CRUD (поправьте если я не прав). А также немного фронтенда, то бишь как там отправляется запрос на сервер и обрабатывается ответ.
Я использовал:
- Maven
- MySQL
- IntelliJ IDEA
Так же хочу оговорится, что фронтенд частично был в учебном проекте, мною реализованы запросы PUT и DELETE.
Весь проект можно посмотреть на GitHub.
Небольшой лайвхак: При создании проекта с maven версия java прыгает на пятую, чтобы это исправить в pom.xml прописываем следующее, где число — это версия.
<properties>
<maven.compiler.target>11</maven.compiler.target>
<maven.compiler.source>11</maven.compiler.source>
</properties>
Подключение Spring
Для начала в pom.xml подключаем Spring boot, как parent, объясняется это тем, чтобы дальнейшее подключение зависимостей не конфликтовало по версиям:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.4.RELEASE</version>
</parent>
Теперь подключаем Spring web отвечает за запуск приложения:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Создаем и запускаем приложение
Начинать писать приложение нужно в правильной директории, а именно src/main/java/main, да-да именно так, толкового объяснения этому я пока не нашел, думаю со временем я это узнаю.
И сразу выложу всю структуру приложения.
Первое что я сделал, это создал Main.java класс для запуска приложения с аннотацией
@SpringBootApplication
:@SpringBootApplication
public class Main {
public static void main(String[] args) {
SpringApplication.run(Main.class, args);
}
}
И уже можно нажать run и даже запустится Мой сервер!
Запущен на порту 8080. Можно пройти по адресу
http://localhost:8080/
и мы увидим ошибку 404 ведь пока нет страниц.Справедливости ради надо осуществить фронтенд.
Нужно подключить зависимость в pom.xml для шаблонизации HTML.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
Для начала стартовая страница index.html в директории src/main/resources/templates.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ToDo List</title>
<script src="/js/jquery-3.4.0.min.js"></script>
<script src="/js/main.js"></script>
<link rel="stylesheet" type="text/css" href="/css/styles.css">
</head>
<body>
<div id="todo-form">
<form>
<label>Название дела:
</label>
<input id="todo-form-name" type="text" name="name" value="">
<label>Описание:
</label>
<input id="todo-form-description" type="text" name="description" value="">
<label>Дата и время:
</label>
<input id="todo-form-date" type="date" name="date" value="">
<hr>
</form>
</div>
<h1>Список дел</h1>
<button id="show-add-todo-list">Добавить дело</button>
<br><br>
<div id="todo-list">
</div>
</body>
</html>
Так же пропишем стили в директории src/main/resources/static/css создаем styles.css
* {
font-family: Arial, serif;
}
#todo-form {
display: none;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
background-color: #88888878;
}
#todo-form form {
background-color: white;
border: 1px solid #333;
width: 300px;
padding: 20px;
}
#todo-form h2 {
margin-top: 0;
}
#todo-form label {
display: block;
}
#todo-form form > * {
margin-bottom: 5px;
}
h4 {
margin-bottom: 0;
}
Можно попробовать запустить приложение и перейти
http://localhost:8080/
и можно любоваться стартовой страницей, правда пока без экшена.И естественно js с подключением jQuery в директории src/main/resources/static/js, еще раз оговорюсь, в учебном проекте уже существовал jQuery и часть написанного main.js.
Все таки хаб про Java и Spring, поэтому ссылки на полный код js думаю будет достаточно:
Ссылка на jquery-3.4.0.min.js
Ссылка на main.js.
Ниже будет особое внимание запросу GET и PUT. Как со стороны сервера, так и со стороны фроненда.
Сейчас можно попробовать запустить проект и убедиться в том, что фронтенд работает и экшен тоже (кнопка добавить запускает форму).
Взаимодействие с БД
Следующий шаг — сущность для взаимодействия с базой данных для этого подключаем зависимость Spring data jpa:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
И в директории src/main/java/main/model создаю POJO класс Todo прикрепляю аннотацию
@Entity
.Объявляем поля, у меня будет: id, name, description, date.
Отдельное внимание
setDate()
, я поступил именно таким образом, на входе String и затем преобразование в java.util.Date да еще и с atStartOfDay().atZone(ZoneId.of("UTC")
, также обращаю внимание на аннотацию поля date:package main.model;
import javax.persistence.*;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
@Entity
public class Todo {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private int id;
private String name;
private String description;
@Temporal(TemporalType.DATE)
private Date date;
//getters and setters …
public void setDate(String date) {
this.date = Date.from(LocalDate.parse(date).atStartOfDay().atZone(ZoneId.of("UTC")).toInstant());
}
}
Добавляем зависимость в pom.xml для установки соединения с MySQL:
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.19</version>
</dependency>
В директории src/main/resources создаем application.properties и записываем данные для подключения к БД:
spring.datasource.url=jdbc:mysql://localhost:3306/todolist?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=password
spring.jpa.hibernate.ddl-auto=none
Теперь переходим к созданию репозитория. В директории src/main/java/main/model создаем интерфейс TodoRepository с аннотацией
@Repository
и наследуем CrudRepository<Todo, Integer>. Лирическое отступление — как я понял это такая прокладка между БД и контроллером, в этом то и хорош Spring, не нужно создавать сокеты, не нужно париться о взаимодействии с БД, он все делает за тебя.package main.model;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TodoRepository extends CrudRepository<Todo, Integer> {
}
Собственно, через этот репозиторий будет происходить общение с БД.
Теперь пора создавать контролер, где будут обрабатываться запросы от фронтенда, взаимодействие с БД и ответы фронтенду.
В директории src/main/java/main/controller создаем класс TodoController с аннотацией
@RestController
, объявляем переменную TodoRepository и инициализируем через конструктор. Начнем с POST запроса. Создаем метод add() принимающий Todo и возвращающий int (id), помечаем аннотацией
@PostMapping(“/todo-list/”)
и путь куда будем добавлять. Берем репозиторий и методом save()
сохраняем в базе данных объект Todo, который пришел с запросом. Просто волшебство. @PostMapping("/todo-list/")
public int add(Todo todo) {
Todo newTodo = todoRepository.save(todo);
return newTodo.getId();
}
В общем аналогично с GET и DELETE, но с использованием id и возвращением Todo в оболочке ResponseEntity. Также заметьте параметр метода
get()
помечен аннотацией, ниже немного подробнее. Далее формируется ответ ResponseEntity.ok(todoOptional.get());
, то есть код 200 или же если не найдено по данному id возвращает код 404 с телом null.@GetMapping("/todo-list/{id}")
public ResponseEntity<Todo> get(@PathVariable int id){
Optional<Todo> todoOptional = todoRepository.findById(id);
if(todoOptional.isEmpty()){
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
}
return ResponseEntity.ok(todoOptional.get());
}
Что же происходит на стороне фронтенда?
На примере GET:
клик по ссылке в списке todo => вытаскиваем id todo => формируется запрос (обратите внимание, сам id не передается в метод. Id в методе get() извлекается из (value="/todo-list/{id}") именно для этого нужна аннотация
@PathVariable
в параметре метода) => приходит ответ в виде объекта Todo => фронтенд делает то, что посчитает нужным, в данном случае у Todo открывается описание и дата. $(document).on('click', '.todo-link', function(){
var link = $(this);
var todoId = link.data('id');
$.ajax({
method: "GET",
url: '/todo-list/' + todoId,
success: function(response)
{
if($('.todo-div > span').is('#' + todoId)){
return;
}
link.parent().append(codeDataTodo(response, todoId));
},
error: function(response)
{
if(response.status == 404) {
alert('Дело не найдено!');
}
}
});
return false;
});
Создадим еще один контроллер, который будет сразу выводить todo-list на стартовую станицу. Также работаем с репозиторием и достаем список Todo, а затем магическим образом todoList передается на фронденд:
@Controller
public class DefaultController {
@Autowired
TodoRepository todoRepository;
@RequestMapping("/")
public String index(Model model){
Iterable<Todo> todoIterable = todoRepository.findAll();
ArrayList<Todo> todoList = new ArrayList<>();
for(Todo todo : todoIterable){
todoList.add(todo);
}
model.addAttribute("todoList", todoList);
return "index";
}
}
Вот с такими поправками в index.html происходит динамическая загрузка todoList:
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<div id="todo-list">
<div class="todo-div" th:each="todo : ${todoList}" th:attr="id=${todo.id}">
<a href="#" class="todo-link" th:attr="data-id=${todo.id}" th:text="${todo.name}"></a>
<br>
</div>
</div>
Запрос PUT
В TodoController создаем метод
put()
c аннотацией @PutMapping
на входе Map<String, String> с аннотацией @RequestParam
и int, который извлекается из value, на выходе Todo завернутый в ResponseEntity. А также у репозитория нет метода update() поэтому происходит все следующим образом:извлекается Todo из БД через todoRepository по id => присваиваются новые параметры Todo => сохраняется в БД через репозиторий => высылается ответ фронтенду
@PutMapping(value = "todo-list/{id}")
public ResponseEntity<Todo> put(@RequestParam Map<String, String> mapParam, @PathVariable int id){
Optional<Todo> todoOptional = todoRepository.findById(id);
if(todoOptional.isEmpty()){
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
}
todoOptional.get().setName(mapParam.get("name"));
todoOptional.get().setDescription(mapParam.get("description"));
todoOptional.get().setDate(mapParam.get("date"));
todoRepository.save(todoOptional.get());
return ResponseEntity.ok(todoOptional.get());
}
На фронтенде в это время:
клик по кнопке “Изменить” => с элемента собираются данные Todo => редактируется форма под изменение дела (переименовывается название формы и кнопка, подставляется в input value данные Todo) => открывается форма => вбиваются данные для изменения => клик по кнопке “Изменить” в форме => сбор данных => формируется запрос PUT (путь, данные) => получение ответа измененного объекта Todo, но с тем же id => фронтенд делает, что пожелает, в данном случае замена данных Todo.
//Update _todo and show updating _todo form
$(document).on('click', '#show-update-todo-list', function(){
var buttonUpdate = $(this);
var todoId = buttonUpdate.data('id');
var todoName = buttonUpdate.data('name');
var todoDescription = buttonUpdate.data('description');
var todoDate = buttonUpdate.data('date');
todoFormNameAndButton('Изменить дело', 'Изменить', 'update-todo');
todoInputValue(todoName, todoDescription, todoDate);
$('#todo-form').css('display', 'flex');
$('#update-todo').click(function() {
var data = $('#todo-form form').serialize();
$.ajax({
method: "PUT",
url: '/todo-list/' + todoId,
data: data,
success: function(response) {
$('#todo-form').css('display', 'none');
response.date = response.date.slice(0,10);
$('.todo-div#' + todoId + ' > a').text(response.name);
$('.todo-div#' + todoId +' > span').replaceWith(codeDataTodo(response, todoId));
}
});
return false;
});
});
Более подробно ознакомиться с проектом можно на гитхабе.
Написано для новичков от новичка, но хотелось бы услышать конструктивную критику от опытных юзеров, так же круто если объясните в комментариях почему при запросе PUT на стороне контроллера приходит Map<String, String>, почему я не могу подать туда Todo.
Ресурсы:
Sultansoy
Ну, во первых PUT не относится к понимани спринга никак. PUT это всего лишь один из http запросов. Спринг веб же может обработать этот запрос.
Касаемо же самого запроса, по мне как-то не очень удобно работать с мапкой. Приходится хардкодить ключи, что не очень красиво, да и зачем, когда можно было создать либо ДТО с нужными полями, либо прям тот же класс, а после воспользоваться либо BeanUtils::copyProperties, либо напрямую засетать id и отправить на сейв соответственно.
Также туду репозиторий может дать ошибку на сейве, если например какое-то поле помечено как non null, а вам в мепке такой ключ не пришел. Тут опять же все зависит от контракта. Если договориться, что PUT — полное обновление, а PATCH — частичное, тогда другой разговор. Но опять же, никто вас не страхует от того, что с фронта придут неверные данные.
Ну и последний пункт, я бы не стал держать логику в контроллере. Для того есть сервисы. В спринге контроллеры реализованы больше для роутинга, как по мне. Потому стараюсь максимально не пихать туда логику.
И маленькая придирочка, но это уже к код стайлу. Я бы не стал вызывать каждый раз гет от optional. Записал бы в переменную и пользовался бы ей.
skkovalenko Автор
Да, я имел ввиду именно запрос PUT. Возможно плохо выразил мысль. Почему мапа, да просто не до чего другого пока не дошёл и меня этот вопрос мучает. Про ДТО почитаю, спасибо. И вообще замечательный комментарий, есть над чем подумать. Благодарю.