Эта статья ориентирована на начинающих Back-end программистов, в частности на Java, т.к. здесь будет рассмотрен процесс планирования и программирования сервера на Java Spring Boot с базой данных PostgreSQL

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

Предметом разбора будет выступать моя командная проектная работа за 2-ой курс ВУЗа:

приложение для создания и просмотра артхаусного кино

Приложение взял на себя мой напарник, таким образом, мне ничего не оставалось кроме как смотреть кино обеспечить его сервером


Планирование

Отобрав основной функционал, рождается MVP, цель которого определить интерес пользователей к подобным фильмам

Для реализации этих требований мы решили показать пользователю следующие экраны:

  • регистрацию/логин

  • страницу аккаунта

  • каталог фильмов

  • страницу с фильмом с плеером

  • чат для обсуждения

И предусмотреть следующее взаимодействие: добавление фильмов в избранное, комментарии и лайки/дизлайки у комментариев

Дальше вместе с напарником обсудили:

  1. сущности фильма и пользователя

  2. необходимые запросы к сущностям

  3. безопасность

Проговорив всё это, не терпелось приступить к создании архитектуры БД. Для этого понадобилось только знание о типах связей между сущностями [1:1, 1:N, M:N]

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

Программирование

Я пойду по своему коду в хронологическом порядке

Структура проекта
Структура проекта

TangoApplication

Точка входа в приложение. В начале тут ничего нет, однако, стоит заметить 1 любопытную деталь: очень многое в Spring Boot делается через аннотации

@SpringBootApplicaion - аннотация, которая создаётся автоматически и конфигурирует настройки по умолчанию

Помимо метода main В этом классе можно определить commandLineRunner, чтобы сделать какие-либо действия при старте сервера, в данном случае там закидываются стартовые значения в базу

@Bean — указывает Spring'у, чтобы он учитывал этот метод у себя под капотом

@Transactional — нужен для методов, содержащих логику работы с базой данных, которая должна быть выполнена от начала и до конца. В случае ошибки, изменения не будут применены


Контроллеры

Это API, к которой будет общаться специалист на Front-end через запросы. Её функционал был определен на этапе планирования

Контроллер на картинке из себя представляет самый обычный класс, со специальной аннотацией @RestController(path="api/common")

Внутри этого класса определяются методы, к которым можно обратиться с определённой целью, на сленге они называются "ручки". Чтобы определить ручку, используется ряд аннотаций для разных типов запросов: @GetMapping,@PostMapping итд

Чтобы использовать их корректно, следует посмотреть такие темы:

  • типы запросов

  • принятие аргументов и параметров запроса

  • получение тела запроса

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

Стоит вспомнить, что специалист с Front-end пока ничего не получил. И это неудивительно, не была создана ни база данных, ни сущности. Тем не менее, для создания функционала с его стороны не обязательны настоящие данные

Фальшивые данные в нужном формате называются Mock'ом. Можно возвращать заранее сгенерированный объект или, если ответ не большой, поля в подобном виде:

Map<String, Object> response = new HashMap<>();
response.put("message", "Test data");
response.put("number", 1)
return ResponseEntity.ok(response);

Не сложно сделать такие заглушки для хотя бы какой-то части API. Развернув сервер, Back-end и Front-end станут менее зависимы друг от друга. Сленг: развёртывание == деплой

Пускай, API известно, однако, фронтендер не станет лезть в код сервера для выяснения вопроса: "Как им пользоваться?"

Нам было удобно держать API в Postman, где я создал коллекцию, в которой содержались запросы со всеми аргументами и их телом

Пример ответа API на запрос, определённый выше по статье, в commonController
Пример ответа API на запрос, определённый выше по статье, в commonController

Models

Переходим к самому главному, сущностям и БД

В первую очередь подключаемся к БД. Определяем нужные поля в application.properties и затем, при помощи вкладки Database справа в IntelliJ к самой базе. В community-версии IntelliJ придётся прибегать к помощи других статей/видео для решения этой проблемы

Здесь всё, что нужно для подключения БД
Здесь всё, что нужно для подключения БД

На картинке ниже представлена сущность пользователя

Часть аннотаций здесь от библиотеки Lombok, которая призвана сократить код объекта, тем самым сделав его более читабельным

@Data - определяет getter и setter к каждой сущности, добавляет методы toString, equals, hashcode

@NoArgsConstructor — создаёт конструктор без аргументов

Время разобраться, что здесь происходит
Время разобраться, что здесь происходит
К сущности:
@Entity — обозначается сущность
@Table — таблица
@Data @NoArgsConstructor — аннотации Lombok. Реализуют некоторые методы
@JsonIncude(JsonInclude.NON_NULL) — при сериализации в JSON попадают все поля, которые не NULL

К полю:
@JsonProperty(value="название") — при сериализации будет указывать тебе определённое имя 
@Column(name = "колонка") — нужно, чтобы задать название для колонки в базе данных
@Transient — означает, что это поле будет вычисляться во время запроса

К ID:
@Id, @SequenceGenerator, @GeneratedValue — нужно для того, чтобы создать ID

Отношения между сущностями:
@OneToOne, @ManyToOne, @OneToMany, @ManyToMany, 
@JsonBackReference, @JsonManagedReference 

Про@JoinTable и отношения между сущностями лучше прочитать на Baeldung


А как писать запросы к БД PostgreSQL без SQL?

→ Современный подход к построению запросов к базе в Spring Boot реализован через специальные интерфейсы - репозитории

@Repository — указывает на то, что это репозиторий

@JpaRepository<сущность, тип_её_ID> — включает все заготовленные запросы, чтобы обращаться к определённой сущности

Чтобы создавать авторские запросы, нужно в специальном формате называть функции, либо же с помощью аннотации @Query писать SQL

Чтобы сделать пагинацию, нужно использовать Page<сущность> — контейнер для нескольких экземпляров сущности и добавлять в аргументы функции Pageable pageable


DTO

→ Data Transfer Object — нужны, чтобы перекидывать уменьшенные по полям сущностями внутри сервера. Например, оперировать ими в запросах API

Их использование обусловлено тем, что язык типизированный и нельзя налету убрать свойство из объектов

// Пример DTO: Тело запроса на регистрацию
@Data
public class SignupRequestDTO {
    private String email;
    private String username;
    private String password;
    private LocalDate date_of_birth;
    private LocalDate sub_deadline;
    private Set<String> roles;
    private String avatar;

    public User fromWithoutRoles() {
        return new User(email, username, password, date_of_birth, sub_deadline);
    }
}

Сервисы

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

Хорошей практикой считается сделать интерфейс с нужными методами рядом с сущностью, а затем написать класс, реализующий этот интерфейс, с постфиксом Impl

@Autowired — это одна из ключевых концепций Spring «Dependency Injection»: внутри Spring регистрируются все эти зависимости и затем их можно куда-то вставить через аннотацию

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


Тесты

В 1 момент я почувствовал, что есть функционал, который я не хочу проверять через запросы. Эта мысль стала отправной точкой к написанию тестов

Для запуска из IntelliJ нужно нажать на тест ПКМ
Для запуска из IntelliJ нужно нажать на тест ПКМ

Безопасность

Тема безопасности невероятно обширна и начать стоит с вопроса: "Какие вообще возможности аутентификации бывают?"

В github репозитории лежит мой вариант имплементации работы с JWT, основанный на добавлении MiddleWare для авторизации

MiddleWare — это функция, в которую попадают запросы, прежде чем оказаться в контроллерах, таким образом, туда удобно положить проверку на JWT-токен


Utils

→ Логика, которая используется в разных местах, но не связана напрямую с сущностями

В проекте я вынес внутреннюю логику зашифровки/расшифровки JWT-токена, функции для загрузки картинок на Imgur


Где хранить секреты?

→ В файлике application.properties

Там определяются константы, к которым можно потом обратиться через аннотацию @Value

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

// Где-либо
@Value("${jwt.jwtSecret}")
private String jwtSecret;

@Value("${jwt.jwtExpirationMs}")
private int jwtExpirationMs;

// В application.properties
jwt.jwtSecret=секрет
jwt.jwtExpirationMs=86400000

Заключение

В github-репозитории остался код для некоторого количества незатронутых тем:

  1. Создание сайта чатика на Front-end при помощи JS, CSS и HTML

  2. Чатик на WebSocket со стороны браузера, который правда, мне сейчас не очень нравится

  3. Безопасность с JWT

  4. Свой класс ошибки

Это был общий взгляд на создание небольшого проекта со стороны Back-end.

Комментарии (10)


  1. p-oleg
    28.10.2021 23:48

    Безопасность

    А почему не Spring Security?

    А, посмотрел код, есть.


  1. LeshaRB
    28.10.2021 23:54
    +4

    Честно не годится даже для учебного материала

    Вы описываете модель , для работой с бд. Далее у вас

    Data Transfer Object — нужны, чтобы перекидывать уменьшенные по полям сущностями внутри сервера: оперировать ими в запросах / получать из БД и тому подобное

    Так что у вас с БД работает? Модели или дто?

    @Data @NoArgsConstructor — аннотации Lombok. Реализуют некоторые методы. Какой метод реализует NoArgConstructor? И зачем данная аннотация вам?

    Для чего вы используете вместе @Entity и Json аннотации?

    ДИ лучше делать через конструктор

    И многое другое....


    1. mskKote Автор
      29.10.2021 00:25

      Непосредственно с базой работают Entity и Repository. В моём понимании, DTO нужны для удобства представления данных. Не всегда хочется прокидывать сущность со всеми полями или в случае, когда для запроса понадобилась определённая структура. Я ошибаюсь в этом? UPD: осмотрел проект, в Repository я не использую DTO. Действительно, этот момент был глупостью, вы правы. Изменил этот кусок в статье, спасибо)

      NoArgConstructor реализует конструктор без параметров. Требуется, соответственно, для создания экземпляра этого класса

      Json аннотации нужны для сереализации, Entity для базы. На ваш взгляд, для каждой сущности стоит выделять отдельную DTO для API? Или каково ваше представление?

      ДИ лучше делать через конструктор

      Я и так делаю Dependency Injection через конструктор. Насколько я знаю, можно к полю в классе задать аннотацию Autowired, однако, в этом проекте я это вроде не использовал / что, впрочем, не возбраняется


      1. hello_my_name_is_dany
        29.10.2021 05:06

        Так в конструкторе можно и не указывать autowired, как у вас на фото UserServiceImpl


        1. mskKote Автор
          29.10.2021 07:43

              final PasswordEncoder passwordEncoder;
              final UserRepository userRepository;
              final ChatUserService chatUserService;
          
              public UserServiceImpl(@Autowired PasswordEncoder passwordEncoder,
                                     @Autowired UserRepository userRepository,
                                     @Autowired ChatUserService chatUserService) {
                  this.passwordEncoder = passwordEncoder;
                  this.userRepository = userRepository;
                  this.chatUserService = chatUserService;
              }

          Я не совсем корректно вас понял, в данном конструкторе Autowired указан. Вы имеете ввиду, что его необязательно указывать вовсе? Или что-то иное?


          1. shalomman
            29.10.2021 08:07
            +1

            Не обязательно, спринг сам разрулит


  1. lastrix
    29.10.2021 07:53
    +2

    Использовать Page нежелательно, потому что это всегда 2 запроса к БД - 1 выбор данных, 2 подсчет числа доступных элементов. В вашем случае нет фильтров, но в если они будут, то можно легко повесить БД под нагрузкой таким образом.

    Есть в спринге класс Slice - это тот же Page, но без вычисления количества страниц и элементов, желательно использовать именно его. Работать будет быстрее, а если нужно вычислять количество подходящих страниц и элементов, то фронту лучше делать 1 запрос в специальный эндпоинт для этого.

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

    @NoRepositoryBean
    public class JpaSpecificationRepositoryWithSliceImpl<T, ID extends Serializable>
            extends SimpleJpaRepository<T, ID>
            implements JpaSpecificationRepositoryWithSlice<T, ID> {
        private final EntityManager em;
    
        public JpaSpecificationRepositoryWithSliceImpl(JpaEntityInformation<T, ?> entityInformation, EntityManager em) {
            super(entityInformation, em);
            this.em = em;
        }
    
        @Transactional(readOnly = true)
        @Override
        public Slice<T> sliceAll(Specification<T> spec, Pageable pageable) {
            var list = this.getQuery(spec, pageable)
                    .setFirstResult((int) pageable.getOffset())
                    .setMaxResults(pageable.getPageSize())
                    .getResultList();
            return new SliceImpl<>(list);
        }
    
        @Transactional(readOnly = true)
        @Override
        public Slice<T> sliceAll(Pageable pageable) {
            var cb = em.getCriteriaBuilder();
            var q = cb.createQuery(getDomainClass());
            var f = q.from(getDomainClass());
            q.select(f);
            q.orderBy(QueryUtils.toOrders(pageable.getSort(), f, cb));
            var list = em.createQuery(q)
                    .setFirstResult((int) pageable.getOffset())
                    .setMaxResults(pageable.getPageSize())
                    .getResultList();
            return new SliceImpl<>(list);
        }
    }

    Разница между Page и Slice на 1 000 000 элементов даже без фильтров будет весьма ощутимой уже на глаз. В первом случае вся БД всегда читается, во втором только кусочек ограниченный размером страницы.


  1. Capri
    30.10.2021 18:31

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

    *Какой интерфейс имплементирует данный класс?

    *Почему поля класса package-private?

    * Зачем нужна аннотация @Autowired в параметрах конструктора?


    1. mskKote Автор
      30.10.2021 22:40

      Спасибо за code review! Вы указали на ошибки, на которые я бы, вероятно, не обратил внимание в дальнейшем

      UserServiceImpl не имплементирует никакой интерфейс/не наследует никакой класс. Под конец работы понял, что это не есть хорошо

      Это мой плохой код, нужно добавлять private

      В комментариях выше писали о том, что аннотация Autowired необязательна


  1. furic
    03.11.2021 01:03
    +1

    К сожалению Вам ещё рано писать обучающие статьи...