Skip to main content

Контроллер

В этой и следующей главах мы будем разрабатывать простейший сайт для работы со списком задач.

cmd

Исходники этого проекта можно скачать здесь.

Rest

Для общения с сервером необходимо отправлять ему данные и обрабатывать их ответы. То, как устроены посылки, называется стандартом.

Хотя при общении с сервером и встречаются очень разные задачи, всё равно можно выделить некоторые общие принципы. Такие принципы сформулированы в архитектуре REST. REST расшифровывается как Representational State Transfer - передача состояния представления.

Напомню: представление в концепции MVC(Model View Controller) - это View. Вместо того чтобы рисовать интерфейс сайта за счёт ресурсов сервера, он называется backend - бэкенд, мы создаём универсальный способ обращения к серверу, который просто выдаёт те или иные данные в формате json.

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

REST работает с помощью протокола HTTP, по сути этот протокол позволяет передавать любые данные, но основу представляют текстовые файлы. Грубо говоря, мы обращаемся к серверу с тем или иным запросом, а он в ответ возвращает нам текстовый файл.

Каждая точка доступа для запроса называется endpoint - конечная точка, А то, к чему она даёт доступ - ресурс.

Любая конечная точка в своей сути - просто веб-адрес. Веб-адрес состоит из ip или домена сервера и пути до неё:

http://web.site/rest/request1?arg1=val1&arg2=val2
  • http://web.site/ - это адрес сайта,
  • rest/request1 - URI,
  • arg1=val1&arg2=val2 - параметры запроса

Путь к ресурсу rest/request1 называется URI, расшифровывается как Uniform Resource Identifier - универсальный идентификатор ресурса.

Параметры запроса передаются после знака вопроса в виде аргумент=значение, значения разделяются символом амперсанта &.

URI в REST принято начинать с множественной формы существительного, описывающего ресурс.

Допустим, что в приложении списка дел есть ещё и авторы задачи. Тогда, чтобы получить список всех пользователей, следует использовать URI

/users

Если мы хотим получить конкретного пользователя с id 1

/users/1

Чтобы запросить список дел конкретного пользователя

/users/1/tasks

Получить конкретную задачу конкретного пользователя

/users/1/tasks/4

Цифры - это id выбранной сущности

Необязательно называть URI так, как я назвал, просто так принято, чтобы люди, использующие ваш код, быстрее в нём могли разобраться. В сущность сама архитектура REST для этого и разработана.

Обработка запросов

Для начала напишем простейший обработчик запросов.

Для этого нам нужно создать специальный класс-контроллер. Для этого создадим пакет controller и в нём класс TasksController.

Можно было бы написать всю логику обработки в нём, но такой подход слишком негибок. Гораздо лучше всю логику работы именно с запросами оставить в нём, а сами действия, которые должны быть выполнены по тому или иному запросу в отдельном классе-сервисе.

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

Пока что у нас будет всего один сервис DefaulTaskService. Он будет расширять интерфейс TaskService - который описывает интерфейс всех сервисов, работающих с задачами. Создадим их в пакете service

Дерево проекта теперь будет выглядеть так:

cmd

package com.example.demo.service;

import com.example.demo.entities.Tasks;
import java.util.List;

/**
* Сервис задач
*/
public interface TasksService {

/**
* Создает новой задачи
* @param task - задача для создания
*/
void create(Tasks task);

/**
* Возвращает список всех имеющихся задач
* @return список задач
*/
List<Tasks> readAll();

/**
* Возвращает задачу по её ID
* @param id - ID задачи
* @return - объект задачи с заданным ID
*/
Tasks read(int id);

/**
* Обновляет задачу с заданным ID,
* в соответствии с переданной задачей
* @param task - задача в соответсвии с которой нужно обновить данные
* @param id - id задачи которого нужно обновить
* @return - true если данные были обновлены, иначе false
*/
boolean update(Tasks task, int id);

/**
* Удаляет задачу с заданным ID
* @param id - id задачи, которую нужно удалить
* @return - true если клиент был удален, иначе false
*/
boolean delete(int id);
}

Пропишем сервис обработки задач по умолчанию. Чтобы сказать spring, что это сервис, добавим в объявление класс аннотацию @Service

Для начал не будем хранить задачи в базе данных. Вместо этого мы создадим словарь Map<Integer, Tasks>, в котором ключ - это id объекта, а значение - задача с этим id.

Так делается для эффективного хранения данных. Id может быть сразу равен довольно большому значению. Если мы будем хранить данные в списке на массиве, например, и сохранять объекты с индексом, равным id. Но тогда нам пришлось бы выделить память под все несуществующие элементы, имеющие индекс, меньший заданного.

Атомарные типы данных

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

Можно было бы сделать бесконечный цикл и проверять раз в заданное время, не получены ли новые данные. Но такой подход неэффективен. Во-первых, мы можем пропустить тот самый момент, когда запрос пришёл, во-вторых, сама по себе практика бесконечных циклов с задержкой порочна.

Довольно давно была разработана концепция многопоточных приложений. Такие приложения состоят из нескольких программ-потоков, которые могут запуститься, когда нам нужно и остановиться. Получается, что в одном приложении у нас как бы одновременно работает несколько программ.

В действительности это не так. Операционная система просто переключается между программами и передаёт его ресурсы то одному, то другому потоку.

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

Поэтому при совместном использовании ресурсов разными потоками эти ресурсы синхронизируют. Синхронизация подразумевает, что мы приостанавливаем выполнение потока, если он обращается к синхронизированному ресурсу, который уже используется другим потоком.

Как только ресурс освободится, ожидающий его освобождения поток запускается и продолжает свою работу. К сожалению такой подход череват взаимной блокировкой(deadlock).

Взаимная блокировка происходит, когда один поток занял ресурс и ждёт, пока осводится второй ресурс, но он занят другим потоком, который в свою очередь ждёт освобождения первого ресурса.

Чтобы избежать этого, старайтесь вместо синхронизации использовать атомарные типы данных. Атомарные типы данных - это классы, каждый метод в которых занимает один такт процессора. Если операция выполняется за один такт, тогда синхронизация не нужна. Ведь на следующем такте процессора, путь он и ывполняет другой поток, ресурс уже будет свободен.

В нашей задаче нужно асинхронно генерировать целочисленный id. Для этого лучше всего подойдёт класс AtomicInteger. Это некий аналог класса-обёртки Integer. Нам будет нужен метод incrementAndGet(). Он сначала увеличивает внутреннее значение на 1, а потом возвращает его. Каждый раз вызывая этот метод из любого потока мы будем получать новое число, большее предыдущего на 1.

package com.example.demo.service;

import com.example.demo.entities.Tasks;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;


/**
* Сервис задачи по умолчанию
*/
@AllArgsConstructor
@Service
public class DefaultTasksService implements TasksService {
/**
* Хранилище задач
*/
private static final Map<Integer, Tasks> TASK_REPOSITORY_MAP = new HashMap<>();

/**
* Генератор id
*/
private static final AtomicInteger TASK_ID_GENERATOR = new AtomicInteger();

/**
* Создает новую задачу
*
* @param task - задача для создания
*/
@Override
public void create(Tasks task) {
final int TaskId = TASK_ID_GENERATOR.incrementAndGet();
task.setId(TaskId);
TASK_REPOSITORY_MAP.put(TaskId, task);
}

/**
* Возвращает список всех имеющихся задач
*
* @return список задач
*/
@Override
public List<Tasks> readAll() {
return new ArrayList<>(TASK_REPOSITORY_MAP.values());
}

/**
* Возвращает задачу по её ID
*
* @param id - ID задачи
* @return - объект задачи с заданным ID
*/
@Override
public Tasks read(int id) {
return TASK_REPOSITORY_MAP.get(id);
}

/**
* Обновляет задачу с заданным ID,
* в соответствии с переданной задачей
*
* @param task - задача в соответсвии с которой нужно обновить данные
* @param id - id задачи которого нужно обновить
* @return - true если данные были обновлены, иначе false
*/
@Override
public boolean update(Tasks task, int id) {
// если в словаре есть указанный `id`
if (TASK_REPOSITORY_MAP.containsKey(id)) {
// задаём задаче этот id
task.setId(id);
// помещаем новую задачу в словарь
TASK_REPOSITORY_MAP.put(id, task);
return true;
}

return false;
}

/**
* Удаляет задачу с заданным ID
*
* @param id - id задачи, которую нужно удалить
* @return - true если клиент был удален, иначе false
*/
@Override
public boolean delete(int id) {
return TASK_REPOSITORY_MAP.remove(id) != null;
}

}

Бэкенд

Теперь пропишем сам контроллер. Он должен использовать функционал, который реализован в сервисе. У нас есть два пути: первый - это наследование, мы могли бы унаследовать контроллер от сервиса, но в spring принято использовать внедрение зависимостей. В этом случае мы просто создаёт поле сервиса, а потом передаём его в качестве аргумента.

Чтобы вручную не прописывать инструкции для spring, как именно следует понимать наше поле, в конструкторе нужно просто приписать аннотацию @Autowired

Чтобы наш контроллер обрабатывал REST-запросы, ему необходимо приписать аннотацию @RestController. Немного выше была описана логика составления URI. Согласно ей все запросы к задачам должны начинаться с tasks/, это мы укажем с помощью аннотации @RequestMapping("/tasks").

Также нам понадобится третья аннотация @Log. Она позволяет вызывать команды добавления в лог без дополнительного кода log.info("текст сообщения"). Переменная логирования создаётся за нас.

У spring есть готовые инструменты логирования. По умолчанию он выводит лог в консоль

cmd

Но можно и перенаправить его вывод, например, в файл.

У лога есть разные уровни: .info() - информация, .warning() - предупреждение. Есть и другие уровни, но здесь мы их рассматривать не будем.

package com.example.demo.controller;

import com.example.demo.entities.Tasks;
import com.example.demo.service.TasksService;
import lombok.extern.java.Log;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
* Контроллер задач
*/
@RestController
@RequestMapping("/tasks")
@Log
public class TasksController {
/**
* Сервис задач
*/
private final TasksService tasksService;

/**
* Конструктор контроллера задач
*
* @param tasksService сервис задач
*/
@Autowired
public TasksController(TasksService tasksService) {
this.tasksService = tasksService;
}

/**
* Создать задачу
*
* @param task - задача
* @return - ответ на REST запрос
*/
@PostMapping(value = "/create")
public ResponseEntity<?> create(@RequestBody Tasks task) {
tasksService.create(task);
log.info("Задача " + task.getTitle() + " создана");
return new ResponseEntity<>(HttpStatus.CREATED);
}

/**
* Получить список всех задач
*
* @return - ответ на REST запрос
*/
@GetMapping(value = "/list")
public ResponseEntity<List<Tasks>> readAll() {
// получаем все задачи
final List<Tasks> tasks = tasksService.readAll();
// выводим в лог, сколько задач найдено
log.info("Найдено " + tasks.size() + " задач");
// если есть хотя бы одная задача
return !tasks.isEmpty()
? new ResponseEntity<>(tasks, HttpStatus.OK) // возвращаем в ответе список задач, статус ответа `OK`
: new ResponseEntity<>(HttpStatus.NOT_FOUND); // иначе статус ответа `NOT_FOUND`
}

/**
* Получить задачу по id
*
* @param id - id задачи
* @return - ответ на REST запрос
*/
@GetMapping(value = "/list/{id}")
public ResponseEntity<Tasks> read(@PathVariable(name = "id") int id) {
// ищем задачу по id
final Tasks task = tasksService.read(id);
// выводим сообщение о том, какая задача запрошена
log.info("Запрошена задача " + task);
// если задача найдена
return task != null
? new ResponseEntity<>(task, HttpStatus.OK) // возвращаем в ответе задачу, статус ответа `OK`
: new ResponseEntity<>(HttpStatus.NOT_FOUND); // иначе статус ответа `NOT_FOUND`
}

/**
* Обновить заданную задачу
*
* @param task новая задача
* @param id - id старой задачи
* @return - ответ на REST запрос
*/
@PostMapping(value = "/update/{id}")
public ResponseEntity<Tasks> update(@RequestBody Tasks task, @PathVariable(name = "id") int id) {
// выводим инофрмацию, какая задача будет модифицирована
log.info("Обновляется задача с id=" + id + " новые данные: " + task);
// если получилось обновить задачу
return tasksService.update(task, id)
? new ResponseEntity<>(task, HttpStatus.OK) // возвращаем в ответе задачу, статус ответа `OK`
: new ResponseEntity<>(HttpStatus.NOT_FOUND); // иначе статус ответа `NOT_FOUND`
}

/**
* Удалить задачу
*
* @param id - id задачи
* @return - ответ на REST запрос
*/
@PostMapping(value = "/delete/{id}")
public ResponseEntity<Tasks> delete(@PathVariable(name = "id") int id) {
// выводим сообщение с информацией о том, какую задачу нужно удалить
log.info("Задача с id=" + id + " удалена");
// если получилось удалить задачу
return tasksService.delete(id)
? new ResponseEntity<>(HttpStatus.OK) // статус ответа `OK`
: new ResponseEntity<>(HttpStatus.NOT_FOUND); // иначе статус ответа `NOT_FOUND`
}

}

Каждый метод в нашем контроллере - это обработчик того или иного запроса. Для того чтобы указать, какой именно запрос обрабатывает тот или иной метод, используется аннотации @PostMapping и @GetMapping. Первая показывает, что метод обрабатывает POST-запросы, вторая - что GET.

На каждый POST или GET запрос обязательно должен быть сформирован ответ. Поэтому у каждого метода возвращаемое значение - это ResponseEntity<>, построенный на основе того или иного объекта. В своей сути ResponseEntity<> - это просто обёртка, которая помимо объекта возвращает статус запроса.

Чтобы вычленить из запроса число, соответсвующее id, в URI используется шаблон {id}, а аргумент метода аннотируется как @PathVariable(). Если метод принимает какие-то объекты, переданные в теле запроса, то необходимо эти аргументы аннотировать как @RequestBody.

Postman

Чтобы проверить работу нашего сервера, нам понадобится инструмент для формирования запросов. Таких инструментов довольно много, но самым популярным является программа Postman.

cmd

Установка

Скачать установщик можно с официального сайта, либо с моего зеркала.

Postmnan установится автоматически

cmd

Теперь вам необходимо создать учётную запись. Для этого нажмите Create Free Account. Если у вас уже есть учётная запись, просто нажмите Sign In. Можно не создавать отдельную учётную запись, а войти с помощью учётной записи Google. Для этого нажмите Sign in

cmd

Программа будет ожидать, пока вы войдёте в учётную запись в браузере

cmd

Чтобы войти через Google, кликните по кнопке Sign in with Google

cmd

Если у вас нет такой учётной записи, можно зарегистрироваться в системе Postman. Для этого в главном окне программы нажмите кнопку Create free account. В новом окне введите свои:

  • почту
  • имя пользователя
  • пароль

Нажмите на кнопку Create free account

cmd

После регистрации браузер предложит вам вернуться к приложению. Нажмите Открыть приложение Postman

cmd

Если браузер сам не вернёт вас к программе, вернитесь в неё вручную

Дождитесь загрузки программы и в новом окне введите своё имя и роль. Я поставил Fullstack Developer. Вы можете выбрать любую другую. Это не имеет значения для дальнейшей работы.

cmd

В следующем окне постман предложит "пригласить друзей". Просто пропустите этот шаг и нажмите на кнопку Continue without team

cmd

Готово. Программа запущена и может отправлять запросы. Чтобы убедиться в этом, нажмите на кнопку Send.

cmd

Ответ сформируется в нижней части окна

cmd

Добавление задачи

Теперь проверим наш бэкенд.

Сначала создадим несколько задач. Я сделал метод создания POST, потому что он позволяет защититься от взлома. Если же нужно просто прочитать, т.е. использовать GET, то даже если кто-то "вклинится" в обмен сообщениями, ничего кроме чтения той же информации, что и пользователь, он не получит.

А вот доступ к POST методу позволит менять данные на сайте. Конечно, помимо чтения и записи, POST и GET запросы используются и в других задачах. Но разделение именно такое. Безопасные запросы реализовывают через GET, а через POST - требующие защиты от злоумышленников.

Запустим сервер, и сформируем запрос. Чтобы передать объект, его необходимо представить в json формате.

По умолчанию выбран режим GET запросов. Для добавления задачи нам нужен метод POST. Кликните по галочке справа от GET

cmd

В выпадающем списке выберите POST

cmd

Теперь необходимо указать URI запроса. В нашем случае - это

http://localhost:8080/tasks/create

cmd

Чтобы добавить объект в тело запроса, откройте вкладку Body

cmd

json-представление первой задачи укажем таким:

{
"title":"task1",
"author":"user",
"text":"test task"
}

Нажмите кнопку Send

Если всё в порядке, то в логе сервера вы увидите строчку с информацией о добавлении

cmd

Если у вас не запущен сервер, то постман выведет такое сообщение об ошибке

cmd

Список задач

Чтобы получить список задач, отправим GET запрос

http://localhost:8080/tasks/list

Получим список из одной задачи.

Теперь добавим ещё одну. Для этого воспользуемся уже отправленным запросом, чтобы не писать всё с нуля. История запросов находится в левом углу программы. Кликните по первому отправленному запросу

cmd

Тело запроса сформируется за нас. Просто поменяем значение заголовка и текста

cmd

{
"title":"task2",
"author":"user",
"text":"test task 2"
}

Таким же способом добавим третью задачу:

{
"title":"task3",
"author":"user",
"text":"test task 3"
}

Теперь вызовем снова метод GET из истории запросов:

cmd

Теперь прочитаем задачу с id равным 1. Запрос будет таким

http://localhost:8080/tasks/list/1

В ответ получим:

{
"id": 1,
"title": "task1",
"author": "user",
"text": "test task"
}

Теперь поменяем заголовок у задачи с id равным 2. Т.к. мы изменяем данные бэкенда, запрос должен быть POST

http://localhost:8080/tasks/update/2

Тело запроса будет таким:

{
"title":"task2",
"author":"user",
"text":"modified test task"
}

В ответ получим:

{
"id": 2,
"title": "task2",
"author": "user",
"text": "modified test task"
}

Удалим теперь задачу с id равным трём. Отправляем POST запрос с пустым телом

http://localhost:8080/tasks/delete/3

И снова выведем список задач:

http://localhost:8080/tasks/list

Получим ответ:

[
{
"id": 1,
"title": "task",
"author": "user",
"text": "test task"
},
{
"id": 2,
"title": "task2",
"author": "user",
"text": "modified test task"
}
]

Подключение БД

Пока что наш сервер хранит все данные в оперативной памяти, поэтому при перезагрузке они будут утеряны. Чтобы данные не зависели от программы, их переносят в базу данных(БД).

Мы уже выполнили всю настройку, теперь осталось только переписать сервис работы с задачами.

Чтобы работать с базами данных в спринг используется подход репозиториев. Чтобы создать репозиторий для работы с PostgreSQL, достаточно просто наследовать интерфейс JpaRepository<>. Вся логика для работы с данными уже прописана за нас.

Создадим пакет repository и добавим в него интерфейс TasksRepository

package com.example.demo.repository;

import com.example.demo.entities.Tasks;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TasksRepository extends JpaRepository<Tasks, Integer> {
}

Теперь перепишем сам сервис:

package com.example.demo.service;

import com.example.demo.entities.Tasks;
import com.example.demo.repository.TasksRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;


/**
* Сервис задач по умолчанию
*/
@Service
public class DefaultTasksService implements TasksService {
/**
* Репозиторий базы данных задач
*/
private final TasksRepository tasksRepository;

/**
* Создает новую задачу
*
* @param task - задача для создания
*/
@Override
public void create(Tasks task) {
tasksRepository.save(task);
}

/**
* Конструктор сервиса задач
*
* @param tasksRepository репозиторий збд задач
*/
@Autowired
public DefaultTasksService(TasksRepository tasksRepository) {
this.tasksRepository = tasksRepository;
}

/**
* Возвращает список всех имеющихся задач
*
* @return список задач
*/
@Override
public List<Tasks> readAll() {
return tasksRepository.findAll();
}

/**
* Возвращает задачу по её ID
*
* @param id - ID задачи
* @return - объект задачи с заданным ID
*/
@Override
public Tasks read(int id) {
return tasksRepository.getById(id);
}

/**
* Обновляет задачу с заданным ID,
* в соответствии с переданной задачей
*
* @param task - задача в соответсвии с которой нужно обновить данные
* @param id - id задачи которого нужно обновить
* @return - true если данные были обновлены, иначе false
*/
@Override
public boolean update(Tasks task, int id) {
// если существует задача с таким id
if (tasksRepository.existsById(id)) {
// меняем id у задачи
task.setId(id);
// при сохранении в базу данных будет найдена запись с таким же
// id, как у сохраняемого, после чего все поля записи будут приведены в
// соответствие с полями переданного объекта
tasksRepository.save(task);
return true;
}

return false;
}

/**
* Удаляет задачу с заданным ID
*
* @param id - id задачи, которую нужно удалить
* @return - true если клиент был удален, иначе false
*/
@Override
public boolean delete(int id) {
// если существует задача с таким id
if (tasksRepository.existsById(id)) {
// удаляем её
tasksRepository.deleteById(id);
return true;
}
return false;
}

}

Снова запустим сервер и отправим ему 4 запроса на создание задач:

http://localhost:8080/tasks/create
{
"title":"task1",
"author":"user",
"text":"test task"
}

Теперь снова загрузим список:

http://localhost:8080/tasks/list

Получим:

[
{
"id": 0,
"title": "task1",
"author": "user",
"text": "test task"
},
{
"id": 2,
"title": "task3",
"author": "user",
"text": "test task3"
},
{
"id": 3,
"title": "task4",
"author": "user",
"text": "test task4"
},
{
"id": 1,
"title": "task2",
"author": "user",
"text": "test task2"
}
]

Теперь откроем редактор таблиц БД в idea. Для этого в правом углу нажмите на вкладку Database и дважды кликните по таблице tasks-table

Сейчас таблица задач выводится так, как хранится. Если вам нужно упорядочить таблицу по тому или иному полю, просто кликните по нему. Упорядочим таблицу по id, для этого кликните по соответствующему заголовку.

cmd

Спустя некоторое время таблица обновится и будет уже упорядоченной

cmd

Если кликнуть ещё раз, то таблица упорядочится в обратном порядке

cmd

Чтобы вручную изменить то или иное значение в таблице, кликните по нему два раза. Появится окно редактирования. Добавим таким образом пробелы в текст задач.

cmd

Все изменённые значения таблицы специально подсвечиваются. После того как мы выгрузим изменения, они снова вернутся к обычному виду. Чтобы применить изменения, нажмите на зелёную стрелочку (не путайте с кнопкой запуска приложения).

cmd

После обработки запроса, таблица будет выглядеть так

cmd

Проверим, что изменения применились. Для этого снова сделаем запрос

http://localhost:8080/tasks/list

Получим

[
{
"id": 0,
"title": "task1",
"author": "user",
"text": "test task"
},
{
"id": 3,
"title": "task4",
"author": "user",
"text": "test task 4"
},
{
"id": 2,
"title": "task3",
"author": "user",
"text": "test task 3"
},
{
"id": 1,
"title": "task2",
"author": "user",
"text": "test task 2"
}
]

Значения изменились, всё работает как надо. Оставшиеся URI проверьте самостоятельно.