Первые шаги в Spring, Rest API, акцент на PUT в связке с фронтендом

Немного о себе: На данный момент я студент Skillbox и прохожу курс “Java-разработчик”. Не в коем случае не реклама, рассказываю немного о себе. Начал учить джаву с мая 2019 года, до этого немного самостоятельно изучал HTML, CSS и JS.

Собственно, подтолкнуло меня на написание этой статьи осознание работы фронтенда с бэкендом вместе и непонимание 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, да-да именно так, толкового объяснения этому я пока не нашел, думаю со временем я это узнаю.

И сразу выложу всю структуру приложения.

image

Первое что я сделал, это создал Main.java класс для запуска приложения с аннотацией @SpringBootApplication:

@SpringBootApplication
public class Main {
    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}

И уже можно нажать run и даже запустится Мой сервер!

image

Запущен на порту 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 открывается описание и дата.

Кусок кода main.js, реализация GET

$(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.

Кусок кода main.js, реализация PUT

//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.

Ресурсы:

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр