Spring boot: маленькое приложение для самых маленьких

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Всем привет! Меня зовут Варвара и я Java Developer в компании “Цифровые привычки”. Я прошла их курс по Java-разработке и по окончании получила  оффер от компании. Сейчас я хочу поделиться материалом с одного из воркшопов, который нам проводил один из лекторов - Алексей Романов, Software Architect и преподаватель Бауманки.

В этой статье мы научимся создавать простые REST приложения. Напишем свое приложение с использованием SpringBoot, создадим свои контроллеры, воспользуемся JPA, подключим PostgreSQL.

Мы будем разрабатывать приложение в 3 этапа:

  1. Создадим и запустим простое REST приложение на SpringBoot

  2. Напишем приложение с сущностями, создадим контроллеры и подключим JPA

    1. Создадим сущности и репозиторий

    2. Добавим контроллеры

    3. Напишем сервисную часть приложения

  3. Запустим и протестируем наше приложение, удивимся, что все работает и порадуемся, что провели время с пользой и узнали что-то новое

1. Создадим и запустим простое REST приложение на SpringBoot

Мы пойдем по простому пути, а точнее зайдем на сайт-стартер проектов на SpringBoot: https://start.spring.io/. Выберем сборку gradle + Java. Запустим и соберем проект локально. Для этого через консоль используем команды, и ждем пока погдрузятся все библиотечки и соберется проект.

./gradlew wrapper - загрузка нужной версии wrapper.

./gradlew clean build bootRun - сборка проекта, прогон unit-тестов, запуск приложения.

Когда мы используем утилиту gradlew (по сути это оболочка, которая использует gradle), нам не нужно иметь заранее установленный Gradle на своем ПК. Эта оболочка может сама скачать и установить нужную версию, разобрать аргументы и выполнить задачи. По сути, используя gradlew, мы можем распространять/делиться проектом со всеми, чтобы использовать одну и ту же версию и функциональность Gradle. Gradlew читает информацию из файла gradle/wrapper/gradle-wrapper.properties.

Мы собрали наше приложение, но пока оно только запускается, но не выполняет никаких других функций. Заходим в файл build.gradle, можно сказать, что это мозг нашего проекта. Здесь хранится вся основная информация для конфигурации и сборки проекта.  Сейчас он выглядит так:

plugins {
  id 'java'
  id 'org.springframework.boot' version '2.5.1'
  id 'io.spring.dependency-management' version '1.0.11.RELEASE'
}

group = 'ru.dhabits.spring_boot_example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
  useJUnitPlatform()
}

Раздел plugins содержит плагины, которые предоставляют необходимые библиотеки и их версии. Например, id 'java' - предоставляет возможность работы с самой java, без нее наше приложение не заработает вообще. Раздел repositories - отвечает за ресурс с которого будут скачаны недостающие библиотеки, по умолчанию это mavenCentral. dependencies - позволяет подключать необходимые нам зависимости, в том числе и стартеры SpringBoot. test - говорит о том, что на этапе прогона тестов будет использован JUnit. Параметры group, version отвечают за группу и версию проекта, а sourceCompatibility - версию java.

Добавим в dependencies следующую зависимости для работы с PEST API:

implementation "org.springframework.boot:spring-boot-starter-web"

Создадим  контроллер - класс с аннотацией @RestController, который умеет что-то выводить на экран. Добавим ему поле и метод, который возвращает значение этого поля.

@RestController
public class controller {

   @Value("${spring.application.name}")
   private String name;

   @GetMapping
   public String getNameApplication() {
       return name;
   }
}

@RestController = @Controller + @ResponseBody. Аннотация @Controller умеет слушать, получать и отвечать на запросы. А @ResponseBody  дает фреймворку понять, что объект, который вы вернули из метода надо прогнать через HttpMessageConverter, чтобы получить готовое к отправке клиенту представление.

@Value("${spring.application.name}") - умеет читать из application.properties файла с помощью конструкции ${}. @GetMapping - сообщает SpringBoot, что это get метод и он умеет что-то возвращать.

В файл application.properties добавим строку: 

spring.application.name=spring_boot_example

Вуаля! Теперь наше приложение не только запускается, но и выводит сообщение "spring_boot_example" по адресу: http://localhost:8080/.

2. Напишем приложение с сущностями, создадим контроллеры и подключим JPA

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

2.1. Создадим сущности и репозиторий

Для начала, чтоб все заработало - нам необходимо несколько зависимостей, по этому пропишем несколько строк в build.gradle. Data-jpa и postgresql - предоставляют набор библиотек для работы с БД, а конкретно с Postgre. Lombok и lang3 упрощают работу с POJO (Plain Old Java Object — «старый добрый Java-объект»), генерируя простой код.

 Аннотируем классы следующим образом:

@Getter
@Setter
@Accessors(chain = true)
@Entity
@Table(name = "address")

  Аннотации: @Getter и @Setter автоматически сгенерируют get и set методы для каждого поля. @Accessors(chain = true) - говорит что сеттер возвращает значение засеченного поля. @Entity - говорит о том, что этот объект - это POJO и на основе этого класса JPA создаст табличку с именем из @Table. Для каждого поля стоит прописать имя столбца в таблице с помощью аннотации @Column. Для таблицы User поле login - уникально и не должно быть null, поэтому для него пропишем параметры nullable = false, unique = true.

  Поле Address в классе User выглядит следующим образом:

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "address_id", foreignKey = @ForeignKey(name = "fk_users_address_id"))
private Address address;

Аннотация @OneToOne - отвечает за связь таблиц один к одному, а fetch = FetchType.LAZY говорит, что это ленивая инициализация. То есть данные из таблицы address будут загружаться по этому ключу только в том случае, когда к ним обратятся. @JoinColumn - говорит о том, как правильно подключиться к таблице address. name = "address_id" - названия первичного ключа, а foreignKey = @ForeignKey(name = "fk_users_address_id") - внешнего. Писать имена для внешнего ключа таким образом (fk_users_address_id) удобно, так как видно какие конкретно таблицы соединяются и как.

  Осталось сгенерировать методы hashCode, equals и toString. Для User мы генерируем hashCode и equals только по полю login, этого достаточно, так как мы сделали это поле уникальным и отличным от null. Так же для User при переопределении toString мы не используем поле address. Ранее упоминалось, что инициализация ленивая, и значение для этого поля не подтягивается сразу, а если мы попробуем обратиться к hibernate и попросить достать сущность без @Transactional, то упадем с ошибкой.

   Полный код сущностей:

  Сущность User:

@Getter
@Setter
@Accessors(chain = true)
@Entity
@Table(name = "users")
public class User {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Integer id;

   @Column(name = "login",nullable = false, unique = true)
   private String login;

   @Column(name = "firstName")
   private String firstName;

   @Column(name = "middleName")
   private String middleName;

   @Column(name = "lastName")
   private String lastName;

   @Column(name = "age")
   private Integer age;

   @OneToOne(fetch = FetchType.LAZY)
   @JoinColumn(name = "address_id", foreignKey = @ForeignKey(name = "fk_users_address_id"))
   private Address address;

   @Override
   public boolean equals(Object o) {
       if (this == o) return true;
       if (o == null || getClass() != o.getClass()) return false;
       User user = (User) o;
       return login.equals(user.login);
   }

   @Override
   public int hashCode() {
       return Objects.hash(login);
   }

   @Override
   public String toString() {
       return "User{" +
               "id=" + id +
               ", login='" + login + '\'' +
               ", firstName='" + firstName + '\'' +
               ", middleName='" + middleName + '\'' +
               ", lastName='" + lastName + '\'' +
               ", age=" + age +
               '}';
   }
}

Сущность Address:

	
@Getter
@Setter
@Accessors(chain = true)
@Entity
@Table(name = "address")
public class Address {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Integer id;

   @Column(name = "street")
   private String street;

   @Column(name = "city")
   private String city;

   @Column(name = "building")
   private String building;

   @Override
   public boolean equals(Object o) {
       if (this == o) return true;
       if (o == null || getClass() != o.getClass()) return false;
       Address address = (Address) o;
       return street.equals(address.street) && city.equals(address.city) && building.equals(address.building);
   }

   @Override
   public int hashCode() {
       return Objects.hash(street, city, building);
   }

   @Override
   public String toString() {
       return "Address{" +
               "id=" + id +
               ", street='" + street + '\'' +
               ", city='" + city + '\'' +
               ", building='" + building + '\'' +
               '}';
   }
}

Добавим к приложению репозиторий – класс, который умеет работать с базой данных. Реализация очень проста, просто создадим свой интерфейс и унаследуем его от JpaRepository. Все. SpringBoot сам сгенерирует класс, имплементрирующий этот интерфейс и подставит там, где это необходимо.

public interface UserRepository extends JpaRepository<User, Integer> {}

JpaRepository – это интерфейс фреймворка Spring Data предоставляющий набор стандартных методов JPA для работы с БД.

Когда мы работаем с MVC моделью логика приложения разделяется на 2 части. Контроллер, который умеет обрабатывать входные данные и отправлять результат работы приложения. А также сервис, который отвечает за весь остальной функционал. В нашем приложении есть третий уровень - репозиторий, который, непосредственно, работает с БД. Рассмотрим по очереди каждый из модулей.

2.2. Добавим контроллеры.

Для начала создадим 4 модели (UserResponse, AddressResponse, CreateAddressRequest и CreateUserRequest) - классы объектов которые будут получать и отправлять наши контроллеры. Можно обойтись и двумя, но мы делаем все по правилам. Отдавать доменные модели наружу считается плохим тоном, так как мы таким образом откроем информацию о БД, к тому же свои модели проще расширять и передавать через них любой объем информации. 

@Data
@Accessors(chain = true)
public class UserResponse {
   private Integer id;
   private String login;
   private String firstName;
   private String middleName;
   private String lastName;
   private Integer age;
   private AddressResponse address;
}

@Data
@Accessors(chain = true)
public class AddressResponse {
   private String street;
   private String city;
   private String building;
}

@Data
@Accessors(chain = true)
public class CreateUserRequest {
   private Integer id;
   private String login;
   private String firstName;
   private String middleName;
   private String lastName;
   private Integer age;
   private CreateAddressRequest address;
}

@Data
@Accessors(chain = true)
public class CreateAddressRequest {
   private String street;
   private String city;
   private String building;
}

Аннотация @Data добавляет get, set, toString, equals, hashCode, конструктор по всем полям, т.е. практически полностью генерирует POJO класс. 

Теперь можно создать наш полноценный контроллер. Помечаем класс аннотациями @RestController и @RequestMapping("/api/v1/users").  

@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
   private UserService userService;
}

@RequestMapping говорит, по какому URL будут доступны наши контроллеры. @RequiredArgsConstructor - генерирует конструктор со всеми параметрами.

У нашего приложения будет 5 контроллеров. Два на получение данных: всех пользователей и по id, на создание, изменение и удаление данных о пользователе.

Получаем список пользователей:

@GetMapping(produces = APPLICATION_JSON_VALUE)
public List<UserResponse> findAll() {
   return userService.findAll();
}

На аннотацию @GetMapping мы уже смотрели ранее. Свойство produces = APPLICATION_JSON_VALUE говорит о том, что данные возвращаются в формате json. В данном методе мы возвращаем лист с данными UserResponse.

Получаем пользователя по id:

@GetMapping(value = "/{userId}", produces = APPLICATION_JSON_VALUE)
public UserResponse findById(@PathVariable Integer userId) {
   return userService.findById(userId);
}

Этот метод аналогичен предыдущему, за исключением того, что мы также получаем id пользователя. Аннотация @PathVariable говорит о том что информация извлекается из адреса и передается в переменную указанную в {}

Создаем пользователя:

@PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public UserResponse create(@RequestBody CreateUserRequest request) {
   return userService.createUser(request);
}

@PostMapping сообщает, что это post метод и он создает новей записи в БД. consumes = APPLICATION_JSON_VALUE - сообщает о том, что возвращаемое значение будет в формате json.

Обновляем пользователя по id:

@PatchMapping(value = "/{userId}", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public UserResponse update(@PathVariable Integer userId, @RequestBody CreateUserRequest request) {
   return userService.update(userId, request);
}

@PatchMapping - patch метод вносит изменения в существующие записи

Удаляем пользователя по id:

@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping(value = "/{userId}", produces = APPLICATION_JSON_VALUE)
public void delete(@PathVariable Integer userId) {
   userService.delete(userId);
}

@DeleteMapping - delete метод удаляет записи. @ResponseStatus(HttpStatus.NO_CONTENT) возвращает статус, указанный в скобках вместо объекта. 

2.3. Напишем сервисную часть приложения.

Теперь посмотрим на логику сервиса. Для начала создадим интерфейс с пятью методами, а затем унаследуемся от него. Наш интерфейс выглядит так:

public interface UserService {

   @NotNull
   List<UserResponse> findAll();

   @NotNull
   UserResponse findById(@NotNull Integer userId);

   @NotNull
   UserResponse createUser(@NotNull CreateUserRequest request);

   @NotNull
   UserResponse update(@NotNull Integer userId, @NotNull CreateUserRequest request);

   void delete(@NotNull Integer userId);
}

Аннотация @NotNull используется в двух случаях. В первом - над методом, во втором - с получаемой переменной, и обозначает, что возвращаемое и получаемые значения не могут быть null. 

Создадим новый класс имплементирующий созданный выше интерфейс:

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
   private static UserRepository userRepository;

   @NotNull
   @Override
   @Transactional(readOnly = true)
   public List<UserResponse> findAll() {
       return null;
   }

   @NotNull
   @Override
   @Transactional(readOnly = true)
   public UserResponse findById(@NotNull Integer userId) {
       return null;
   }

   @NotNull
   @Override
   @Transactional
   public UserResponse createUser(@NotNull CreateUserRequest request) {
       return null;
   }

   @NotNull
   @Override
   @Transactional
   public UserResponse update(@NotNull Integer userId, @NotNull CreateUserRequest request) {
       return null;
   }

   @Override
   @Transactional
   public void delete(@NotNull Integer userId) {
       return null;
   }

Сам класс аннотирован @Service. Над методами добавим @Transactional - идентификатор данного метода как критической секции. Для методов, которые только читают данные, поставим флажок readOnly = true.

 Теперь добавим логику в каждый метод.

Получаем список пользователей:

public List<UserResponse> findAll() {
   return userRepository.findAll()
           .stream()
           .map(this::buildUserResponse)
           .collect(Collectors.toList());
}

@NotNull
private UserResponse buildUserResponse(@NotNull User user) {
   return new UserResponse()
           .setId(user.getId())
           .setLogin(user.getLogin())
           .setAge(user.getAge())
           .setFirstName(user.getFirstName())
           .setMiddleName(user.getMiddleName())
           .setLastName(user.getLastName())
           .setAddress(new AddressResponse()
                   .setCity(user.getAddress().getCity())
                   .setBuilding(user.getAddress().getBuilding())
                   .setStreet(user.getAddress().getStreet()));
}

userRepository.findAll - базовый метод jpa, возвращает, список сущностей типа user, описанные нами ранее. Для этого метода мы просто добавим build метод, который будет конвертировать один объект в другой.

Хочу обратить внимание, что раньше мы говорили, что если мы обращаемся к сущности address вне @Transactional метода, то упадем с ошибкой. Так вот, тут такое не произойдет, т.к. метод как раз имеет эту аннотацию, hibernate ее видит и поднимает это поле из БД.

 Получаем пользователя по id:

public UserResponse findById(@NotNull Integer userId) {
   return userRepository.findById(userId)
           .map(this::buildUserResponse)
           .orElseThrow(() -> new EntityNotFoundException("User " + userId + " is not found"));
}

userRepository.findById - аналогичен findAll, но возвращает 1 объект, а не список. Т.к. из БД нам приходит Optional, то сразу обработаем вариант отсутствия объекта и выбросим ошибку EntityNotFoundException. (дальше напишем свой обработчик для этой ошибки) 

Создаем пользователя:

public UserResponse createUser(@NotNull CreateUserRequest request) {
   User user = buildUserRequest(request);
   return buildUserResponse(userRepository.save(user));
}

@NotNull
private User buildUserRequest(@NotNull CreateUserRequest request) {
   return new User()
           .setLogin(request.getLogin())
           .setAge(request.getAge())
           .setFirstName(request.getFirstName())
           .setMiddleName(request.getMiddleName())
           .setLastName(request.getLastName())
           .setAddress(new Address()
                   .setCity(request.getAddress().getCity())
                   .setBuilding(request.getAddress().getBuilding())
                   .setStreet(request.getAddress().getStreet()));
}

По аналогии с buildUserResponse создадим дополнительный метод buildUserRequest

Тут появляется небольшая проблема, дело в том, что у сущности User есть дополнительная сущность address. В данном случае address не сохраниться, более того вылетит ошибка о том, что операция сохранения не распространяется на подчиненные сущности. Для того, чтоб это исправить в аннотацию @OneToOne, для поля address нужно добавить cascade = CascadeType.ALL. CascadeType.ALL - этот модификатор говорит о том, что все модификации сущности User будут распространяться и на сущность Address.

@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "address_id", foreignKey = @ForeignKey(name = "fk_users_address_id"))
private Address address;

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

Обновляем пользователя по id:

public UserResponse update(@NotNull Integer userId, @NotNull CreateUserRequest request) {
   User user =  userRepository.findById(userId)
           .orElseThrow(() -> new EntityNotFoundException("User " + userId + " is not found"));
   userUpdate(user, request);
   return buildUserResponse(userRepository.save(user));
}

В этом методе мы находим пользователя, по аналогии с методом findById, если такой объект нашелся, то сетим ему поля в методе userUpdate.

private void userUpdate(@NotNull User user, @NotNull CreateUserRequest request) {
   ofNullable(request.getLogin()).map(user::setLogin);
   ofNullable(request.getFirstName()).map(user::setFirstName);
   ofNullable(request.getMiddleName()).map(user::setMiddleName);
   ofNullable(request.getLastName()).map(user::setLastName);
   ofNullable(request.getAge()).map(user::setAge);

   CreateAddressRequest addressRequest = request.getAddress();
   if (addressRequest != null) {
       ofNullable(addressRequest.getBuilding()).map(user.getAddress()::setBuilding);
       ofNullable(addressRequest.getStreet()).map(user.getAddress()::setStreet);
       ofNullable(addressRequest.getCity()).map(user.getAddress()::setCity);
   }
}

Удаляем пользователя по id:

public void delete(@NotNull Integer userId) {
   userRepository.deleteById(userId);
}

userRepository.deleteById - просто удалит пользователя из БД.

Теперь добавим свой обработчик ошибок. Spring умеет перехватывать ошибки и возвращать вместо них то, что мы захотим. Для этого создадим объект ExceptionResponse, который будет возвращать только сообщение из ошибки.

@Data
public class ExceptionResponse {
   private final String massage;
}

А дальше добавим контроллер - обработчик ошибок. 

@RestControllerAdvice
public class ExceptionController {

   @ResponseStatus(HttpStatus.NOT_FOUND)
   @ExceptionHandler(EntityNotFoundException.class)
   private ExceptionResponse notFound(EntityNotFoundException ex) {
       return new ExceptionResponse(ex.getMessage());
   }

   @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
   @ExceptionHandler(RuntimeException.class)
   private ExceptionResponse error(RuntimeException ex) {
       return new ExceptionResponse(ex.getMessage());
   }
}

Аннотация @ExceptionHandler перехватывает исключения, указанные в скобках. @ResponseStatus будет прикладывать к сообщению соответствующий код ответа. Например: HttpStatus.NOT_FOUND - 404, а HttpStatus.INTERNAL_SERVER_ERROR - 500.

3. Запустим и протестируем наше приложение, удивимся что все работает и порадуемся, что провели время с пользой и узнали что-то новое.

Добавим креды для подключения к БД в application.properties:

spring.datasource.url=jdbc:postgresql://localhost:5432/spring_demo
spring.datasource.username=program
spring.datasource.password=test
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.generate-ddl=true

Теперь поднимем базу (для этого я использую докер) и добавляем таблички в БД.

CREATE DATABASE spring_demo;
CREATE ROLE program WITH PASSWORD 'test';
GRANT ALL PRIVILEGES ON DATABASE spring_demo TO program;
ALTER ROLE program WITH LOGIN;

Ура! Наше приложение написано и полностью работает, теперь его можно тестировать.

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

Репозиторий с кодом

Источник: https://habr.com/ru/post/565242/


Интересные статьи

Интересные статьи

Всем привет! Меня зовут Виталий, я разработчик в компании Web3Tech. В этом посте я представлю основные концепции и конструкции платформы Spring Cloud Stream для поддержки...
Автор нашей новой переводной статьи утверждает, что Knative — лучшее, что только могли придумать во Вселенной! Вы согласны? Если вы уже используете Kubernetes, то, вероятно, слышали о бе...
Уважаемое сообщество, эта статья будет посвящена эффективному хранению и выдаче сотен миллионов маленьких файлов. На данном этапе предлагается конечное решение для POSIX совместимых файловых ...
Сегодня хотел бы довести крайне интересный, но часто покрытый тайнами для обычных смертных программистов раздел базы данных (БД) — уровни изолированности транзакций. Как показывает практика, ...
Мы публикуем видео с прошедшего мероприятия. Приятного просмотра.