Первые шаги в 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.

Ресурсы:

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 23

    +2
    Ну, во первых PUT не относится к понимани спринга никак. PUT это всего лишь один из http запросов. Спринг веб же может обработать этот запрос.
    Касаемо же самого запроса, по мне как-то не очень удобно работать с мапкой. Приходится хардкодить ключи, что не очень красиво, да и зачем, когда можно было создать либо ДТО с нужными полями, либо прям тот же класс, а после воспользоваться либо BeanUtils::copyProperties, либо напрямую засетать id и отправить на сейв соответственно.
    Также туду репозиторий может дать ошибку на сейве, если например какое-то поле помечено как non null, а вам в мепке такой ключ не пришел. Тут опять же все зависит от контракта. Если договориться, что PUT — полное обновление, а PATCH — частичное, тогда другой разговор. Но опять же, никто вас не страхует от того, что с фронта придут неверные данные.
    Ну и последний пункт, я бы не стал держать логику в контроллере. Для того есть сервисы. В спринге контроллеры реализованы больше для роутинга, как по мне. Потому стараюсь максимально не пихать туда логику.
    И маленькая придирочка, но это уже к код стайлу. Я бы не стал вызывать каждый раз гет от optional. Записал бы в переменную и пользовался бы ей.
      +1

      Да, я имел ввиду именно запрос PUT. Возможно плохо выразил мысль. Почему мапа, да просто не до чего другого пока не дошёл и меня этот вопрос мучает. Про ДТО почитаю, спасибо. И вообще замечательный комментарий, есть над чем подумать. Благодарю.

      0
      Рекомендую :https://start.spring.io/, либо в самой Intellij есть меню
      +1

      Подобных туториалов по спрингу навалом. А код на js устарел лет на 5.
      Пожалуйста, не надо писать статьи, если вы только учитесь. Вы же сами не знаете, какие ошибки допускаете.

        +1
        Как определить в какой момент разработчик может закончить обучение, чтобы начать писать статьи по вашим критериям?
          0
          Когда разработчик знаком хотя бы с документацией фреймворка, который использует.
          Подразумевается, что с языком, на котором ведется разработка человек знаком, с общими принципами программирования знаком, желательно бы еще подкрепить некоторыми частями математики: дискретная математика, теория графов, алгоритмы.
            +1
            • Когда разработчик напишет приложение (или его кусок), которое будет работать и им будут пользоваться люди
            • При этом, код этого разработчика должен будет пройти кодревью у более опытных людей (в идеале у нескольких разных)
            • Когда изучит best practices в своей сфере
            • Когда разработчик сможет аргументированно отстаивать свою точку зрения: почему он выбрал такую технологию, почему он использует именно такую архитектуру, зачем здесь эти библиотека или фреймворк, почему другая библиотека или фреймворк менее уместны.

            Иными словами, когда у него будет хоть какой-то опыт создания реальных программ и взаимодействия с коллегами (хоть в офисе, хоть на условном гитхабе). Обычно после этого разработчик уже может осознано выбирать свои инструменты, а стало быть, может советовать другим их применять и как именно лучше всего это делать.

              0
              Да очень просто: в тот момент когда разработчик сталкивается с какой то проблемой, явно выходящей за рамки документации, которую, ну вот никак не получается решить. И статьи соответствующие не гуглятся. Вот тогда, разумеется после полномасштабного исследования проблемы и можно начать писать статьи.

              0

              Тут больше туториал не по спрингу. Потому как я младенец в спринге. Здесь скорее сама связка фронта и бэкенда. Мне было сложно понять, представить связку фронта, бэка и БД, как это все взаимодействует. С высоты вашего опыта, возможно это наивный туториал и вообще не нужный, но мне бы такой пригодился некоторое время назад. Наглядных примеров не нашел, если вы можете рекомендовать подобный материал, прошу не стесняйтесь. С уважением.

                0
                Здесь скорее сама связка фронта и бэкенда.

                Которая в жизни выглядит обычно по-другому: фронт и бэк живут в разных проектах и частенько обитают в разных доменах. Понимаете, именно из-за отсутствия у вас опыта вы не можете пока понять эту проблему. Если бы я писал такой туториал, то я бы знал, какие проблемы будут актуальны в дальнейшем, и обратил бы на них внимание.


                Если хочется показать простое взаимодействие, то незачем привлекать jquery и использовать древний стиль в js (вообще, тут камень в огород skillbox, почему в 2020 в учебном проекте такой неактуальный js?). Используйте современный fetch для запросов, document.querySelector для поиска в dom, и другой нативный API.
                Далее, желательно сказать, что так далеко вы не уедете, и рано или поздно нужно будет подключить либо TypeScript, либо babel, плюс не забыть про полифилы, чтобы всё работало в разных версиях разных браузеров. То есть нужно подключать какие-то сборщики и компиляторы. Вот статья с примером, как всё это организовать, исходники тут.
                Далее, в реальном мире нужно вести фронт и бэк в разных проектах, и при этом возникают иные проблемы взаимодействия: фронт поднимается на одном домене, бэк на другом. Из фронтового проекта не получится сделать запрос к бэку из-за политики кросс-доменных запросов. Так что нужно либо настраивать CORS на сервере, либо поднимать прокси на фронте.


                Наглядных примеров не нашел, если вы можете рекомендовать подобный материал, прошу не стесняйтесь.

                Пожалуйста


                Очень подробно, с реактом (хотя тоже не лучшие практики реактостроения, но всё равно современнее)
                С VueJS, менее подробно. Зато с разделением фронта и бэка и одним из способов разработки проектов по-отдельности — использованием прокси.
                С angular, добавляется CORS на бэке

                  0

                  Принимайте мои благодарности за ваши советы и замечание, непременно возьму на вооружение.
                  С уважением.

                    0
                    Привет! Спасибо, что обратили внимание и отметили нас.
                    По js и jquery идёт обновление программы курса.
                    По поводу использования fetch для запросов – на следующей неделе выложим обновлённый модуль http (для Java).
                0

                Век живи, век учись. Что же мне теперь статьи не писать) минутка юмора

                0
                Коль статья для новичков — можно глупый вопрос? А spring саму базу данных должен создавать при старте? У меня mysql 8 в докере. Создаю базу руками — все работает. Не создаю — Caused by: javax.persistence.PersistenceException: [PersistenceUnit: default] Unable to build Hibernate SessionFactory; nested exception is org.hibernate.exception.SQLGrammarException: Unable to open JDBC Connection for DDL

                В пропертях
                spring.datasource.url=jdbc:mysql://localhost:3306/todolist
                spring.datasource.username=root
                spring.datasource.password=
                spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
                spring.jpa.database-platform = org.hibernate.dialect.MySQL5Dialect
                spring.jpa.generate-ddl=true
                spring.jpa.hibernate.ddl-auto = update
                  0

                  Пропишите spring.jpa.hibernate.ddl-auto = create

                    0
                    java.sql.SQLSyntaxErrorException: Unknown database 'todolist'

                    2020-04-15 16:00:09.541 WARN 24584 — [ main] ConfigServletWebServerApplicationContext: Exception encountered during context initialization — cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Invocation of init method failed; nested exception is javax.persistence.PersistenceException: [PersistenceUnit: default] Unable to build Hibernate SessionFactory; nested exception is org.hibernate.exception.SQLGrammarException: Unable to open JDBC Connection for DDL execution
                      0

                      О простите. БД должна уже существовать. Он создает таблицы по сущности

                        0
                        А способы создать БД из spring вообще есть?
                        Мне попадались в статьях параметр application.properties «CREATE DATABASE IF NOT EXIST», но заставить работать я его не смог.
                          +1

                          spring.datasource.url=jdbc:mysql://localhost:3306/social_network?createDatabaseIfNotExist=true


                          Вот такое нарыл

                            0
                            Ура! Спасибо тебе мил человек.
                    0
                    Попробуй spring.jpa.hibernate.ddl-auto = create
                    0
                    Вместо такого:
                    Iterable<Todo>  todoIterable = todoRepository.findAll();
                    ArrayList<Todo> todoList = new ArrayList<>();
                    for(Todo todo : todoIterable){
                         todoList.add(todo);
                    }
                    

                    предлагаю делать лаконичнее:
                    List<Todo> todoList = todoRepository.findAll().stream()
                         .collect(Collectors.toList());
                    


                    Часто предполагается фильтрование или преобразование данных перед возвратом из БД, что отлично реализуется при помощи стримовых filter() и map()

                    Only users with full accounts can post comments. Log in, please.