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

от автора

Всем привет! Меня зовут Варвара и я 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *