Введение в Spring Boot с Spring Data Mongo

Автор оригинала: https://dzone.com/users/549881/dinuka.html
  • Перевод
Всем привет. Спешим поздравить студентов с профессиональным праздником и сообщить о том, что уже в феврале у нас стартует курс «Разработчик на Spring Framework»! Этому и будет посвящена сегодняшняя публикация.

Лига Справедливости в опасности, и только Альфред может всех спасти — с новой системой управления с Spring Boot, Spring Data и MongoDB.

Для Лиги Справедливости настали темные времена, когда устрашающий Дарксайд решил поработить человечество. Бэтмен и Чудо-женщина занялись поисками участников Лиги, и не хватает только одного важного момента — надлежащей системы управления членами Лиги Справедливости.



На создание громоздкого проекта с нуля времени не хватит, поэтому Бэтмен передает эту непростую задачу своему дорогому Альфреду (ведь Робин слишком непредсказуемый), который припоминает что-то под названием Spring Boot, что поможет не тратить время на решение мелких нюансов настройки проекта и быстро перейти к написанию кода приложения.
Так наш дорогой Альфред приступает к использованию Spring Boot для быстрого создания системы управления членами Лиги Справедливости. Как минимум, его back-end части, так как Бэтмен работает непосредственно с REST API.

Есть много удобных способов настройки Spring Boot приложения. В этой статье, сфокусируемся на традиционном методе скачивания пакета (Spring CLI) и его настройки с нуля на Ubuntu. Spring также поддерживает запаковку проекта онлайн с помощью их инструмента. Скачать последнюю стабильную версию можно здесь. В этой статье я использую версию 1.3.0.M1.

После распаковки загруженного архива, для начала настроим следующие параметры в профиле:

SPRING_BOOT_HOME=<extracted path>/spring-1.3.0.M1

PATH=$SPRING_BOOT_HOME/bin:$PATH

Затем добавим в файл «bashrc» следующее:

<extracted-path>/spring-1.3.0.M1/shell-completion/bash/spring

Это добавит в командную строку автодополнение при работе с spring-cli для создания Spring Boot приложений. Запомните, что для подтверждения изменений необходимо “source’нуть” профиль и «bashrc» файлы.

В этой статье используется следующий технологический стек:

  • Spring REST;
  • Spring Data;
  • MongoDB.

Начнем с создания шаблона проекта приложения, выполнив следующую команду. Обратите внимание, что образец проекта можно загрузить из моего GitHub репозитория здесь.

spring init -dweb,data-mongodb,flapdoodle-mongo  --groupId com.justiceleague --artifactId justiceleaguemodule --build maven justiceleaguesystem

Это сгенерирует maven-проект с Spring MVC и Spring Data со встроенным MongoDB.
По умолчанию, spring-cli создает проект с названием “Demo”. Поэтому нам потребуется переименовать соответствующий сгенерированный класс приложения. Если вы воспользовались исходниками из моего GitHub репозитория, упомянутого выше, то этот шаг можно пропустить.
При использовании Spring Boot запуск приложения также прост, как и запуск JAR-файла, созданного проектом. Который просто вызывает класс приложения, аннотированный @SpringBootApplication для загрузки Spring. Давайте посмотрим, как это выглядит:

package com.justiceleague.justiceleaguemodule;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Основное приложение spring boot, которое запустит веб-контейнер и подключит все 
 * необходимые компоненты
 * 
 * @author dinuka
 *
 */
@SpringBootApplication
public class JusticeLeagueManagementApplication {

    public static void main(String[] args) {
        SpringApplication.run(JusticeLeagueManagementApplication.class, args);
    }
}

Затем переходим к классам домена, где Spring Data вместе с MongoDB используется для определения уровня данных. Класс домена выглядит следующим образом:

package com.justiceleague.justiceleaguemodule.domain;

import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;

/**
 * В этом классе содержатся подробности о членах Лиги Справедливости, которые 
 * будут храниться в MongoDB
 * 
 * @author dinuka
 *
 */
@Document(collection = "justiceLeagueMembers")
public class JusticeLeagueMemberDetail {

    @Id
    private ObjectId id;

    @Indexed
    private String name;

    private String superPower;

    private String location;

    public JusticeLeagueMemberDetail(String name, String superPower, String location) {
        this.name = name;
        this.superPower = superPower;
        this.location = location;
    }

    public String getId() {
        return id.toString();
    }

    public void setId(String id) {
        this.id = new ObjectId(id);
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getSuperPower() {
        return superPower;
    }

    public void setSuperPower(String superPower) {
        this.superPower = superPower;
    }

    public String getLocation() {
        return location;
    }

    public void setLocation(String location) {
        this.location = location;
    }

}

Spring Data интуитивно понятен, особенно если у вас есть опыт в JPA/Hibernate. Аннотации очень похожи. Единственная новая вещь — аннотация

@Document

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

Spring Data привнесла функционал простого определения репозитория, из коробки поддерживающего обычные операции CRUD и некоторые операции чтения. Таким образом, в нашем приложении мы используем возможности репозиториев Spring Data, а также класс репозитория следующим образом:

package com.justiceleague.justiceleaguemodule.dao;

import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;

import com.justiceleague.justiceleaguemodule.domain.JusticeLeagueMemberDetail;

public interface JusticeLeagueRepository extends MongoRepository < JusticeLeagueMemberDetail, String > {

    /**
     * Этот метод извлекает подробности об участнике лиги справедливости, связанным с .
     * переданным именем.
     * 
     * @param superHeroName
     *            имя участника лиги справедливости для поиска и извлечения.
     * @return возвращает инстанс {@link JusticeLeagueMemberDetail} с подробностями 
     *         об участнике.
     */
    @Query("{ 'name' : {$regex: ?0, $options: 'i' }}")
    JusticeLeagueMemberDetail findBySuperHeroName(final String superHeroName);
}

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

Как видите, определен только один метод. С аннотацией @Query мы ищем супергероя с помощью регулярных выражений. Опция “i” означает, что нужно игнорировать регистр при попытке найти соответствие в MongoDB.

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



package com.justiceleague.justiceleaguemodule.service.impl;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.justiceleague.justiceleaguemodule.constants.MessageConstants.ErrorMessages;
import com.justiceleague.justiceleaguemodule.dao.JusticeLeagueRepository;
import com.justiceleague.justiceleaguemodule.domain.JusticeLeagueMemberDetail;
import com.justiceleague.justiceleaguemodule.exception.JusticeLeagueManagementException;
import com.justiceleague.justiceleaguemodule.service.JusticeLeagueMemberService;
import com.justiceleague.justiceleaguemodule.web.dto.JusticeLeagueMemberDTO;
import com.justiceleague.justiceleaguemodule.web.transformer.DTOToDomainTransformer;

/**
 * Этот класс сервиса реализует {@link JusticeLeagueMemberService} для обеспечения 
 * функциональности, необходимой системе лиги справедливости
 * 
 * @author dinuka
 *
 */
@Service
public class JusticeLeagueMemberServiceImpl implements JusticeLeagueMemberService {

    @Autowired
    private JusticeLeagueRepository justiceLeagueRepo;

    /**
     * {@inheritDoc}
     */
    public void addMember(JusticeLeagueMemberDTO justiceLeagueMember) {
        JusticeLeagueMemberDetail dbMember =
        justiceLeagueRepo.findBySuperHeroName(justiceLeagueMember.getName());

        if (dbMember != null) {
            throw new JusticeLeagueManagementException(ErrorMessages.MEMBER_ALREDY_EXISTS);
        }
        JusticeLeagueMemberDetail memberToPersist = 
        DTOToDomainTransformer.transform(justiceLeagueMember);
        justiceLeagueRepo.insert(memberToPersist);
    }
}

Опять же довольно просто — если участник уже существует, то выдаем ошибку. Иначе, добавляем участника. Обратите внимание, что здесь используется уже реализованный метод Spring Data репозитория insert, который мы определили ранее.

Наконец, Альфред готов показать новый, разработанный им функционал через REST API с помощью Spring REST, чтобы Бэтмен начал рассылать детали по HTTP — ведь он постоянно в разъездах:

package com.justiceleague.justiceleaguemodule.web.rest.controller;

import javax.validation.Valid;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import com.justiceleague.justiceleaguemodule.constants.MessageConstants;
import com.justiceleague.justiceleaguemodule.service.JusticeLeagueMemberService;
import com.justiceleague.justiceleaguemodule.web.dto.JusticeLeagueMemberDTO;
import com.justiceleague.justiceleaguemodule.web.dto.ResponseDTO;

/**
 *  Этот класс открывает REST API системе.
 * 
 * @author dinuka
 *
 */
@RestController
@RequestMapping("/justiceleague")
public class JusticeLeagueManagementController {
    @Autowired
    private JusticeLeagueMemberService memberService;

    /**
     * Этот метод будет использоваться для добавления новых участников лиги справедливости в систему
     * 
     * @param justiceLeagueMember
     *            участник лиги для добавления.
     * @return an instance of {@link ResponseDTO} который уведомит об успешности
     *         добавления нового члена.
     */
    @ResponseBody
    @ResponseStatus(value = HttpStatus.CREATED)
    @RequestMapping(method = RequestMethod.POST, path = "/addMember", produces = {
        MediaType.APPLICATION_JSON_VALUE
    }, consumes = {
        MediaType.APPLICATION_JSON_VALUE
    })
    public ResponseDTO addJusticeLeagueMember(@Valid @RequestBody 
    JusticeLeagueMemberDTO justiceLeagueMember) {
        ResponseDTO responseDTO = new ResponseDTO(ResponseDTO.Status.SUCCESS,
            MessageConstants.MEMBER_ADDED_SUCCESSFULLY);
        try {
            memberService.addMember(justiceLeagueMember);
        } catch (Exception e) {
            responseDTO.setStatus(ResponseDTO.Status.FAIL);
            responseDTO.setMessage(e.getMessage());
        }
        return responseDTO;
    }
}

Бэтмену все мало, поэтому мы предоставляем функционал в виде полезной нагрузки JSON, хоть Альфред старомоден и предпочел бы XML.

Наш старый друг Альфред — приспешник TDD (test-driven development, в переводе “разработка через тестирование”), поэтому хочет протестировать функционал. И вот мы смотрим на интеграционные тесты, написанные Альфредом, чтобы убедиться в корректности и предсказуемости работы начальной версии системы управления Лигой Справедливости. Обратите внимание, что тут мы показываем только тесты REST API. Альфред охватил больший объем, с которым можно ознакомиться в репозитории GitHub.

package com.justiceleague.justiceleaguemodule.test.util;

import java.io.IOException;
import java.net.UnknownHostException;

import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.justiceleague.justiceleaguemodule.domain.JusticeLeagueMemberDetail;

import de.flapdoodle.embed.mongo.MongodExecutable;
import de.flapdoodle.embed.mongo.MongodStarter;
import de.flapdoodle.embed.mongo.config.IMongodConfig;
import de.flapdoodle.embed.mongo.config.MongodConfigBuilder;
import de.flapdoodle.embed.mongo.config.Net;
import de.flapdoodle.embed.mongo.distribution.Version;

/**
 * В этом классе будет функциональность, необходимая для запуска интеграционных тестов, 
 * чтобы не реализовывать одно и то же несколько раз в индивидуальных классах.
 * 
 * @author dinuka
 *
 */
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public abstract class BaseIntegrationTest {

    @Autowired
    protected MockMvc mockMvc;

    protected ObjectMapper mapper;

    private static MongodExecutable mongodExecutable;

    @Autowired
    protected MongoTemplate mongoTemplate;

    @Before
    public void setUp() {
        mapper = new ObjectMapper();
    }

    @After
    public void after() {
        mongoTemplate.dropCollection(JusticeLeagueMemberDetail.class);
    }

    /**
     * Здесь мы настраиваем встроенный инстанс mongodb для запуска с нашими 
     * интеграционными тестами.
     * 
     * @throws UnknownHostException
     * @throws IOException
     */
    @BeforeClass
    public static void beforeClass() throws UnknownHostException, IOException {

        MongodStarter starter = MongodStarter.getDefaultInstance();

        IMongodConfig mongoConfig = new MongodConfigBuilder().version(Version.Main.PRODUCTION)
            .net(new Net(27017, false)).build();

        mongodExecutable = starter.prepare(mongoConfig);

        try {
            mongodExecutable.start();
        } catch (Exception e) {
            closeMongoExecutable();
        }
    }

    @AfterClass
    public static void afterClass() {
        closeMongoExecutable();
    }

    private static void closeMongoExecutable() {
        if (mongodExecutable != null) {
            mongodExecutable.stop();
        }
    }
}

package com.justiceleague.justiceleaguemodule.web.rest.controller;

import org.hamcrest.beans.SamePropertyValuesAs;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

import com.justiceleague.justiceleaguemodule.constants.MessageConstants;
import com.justiceleague.justiceleaguemodule.constants.MessageConstants.ErrorMessages;
import com.justiceleague.justiceleaguemodule.domain.JusticeLeagueMemberDetail;
import com.justiceleague.justiceleaguemodule.test.util.BaseIntegrationTest;
import com.justiceleague.justiceleaguemodule.web.dto.JusticeLeagueMemberDTO;
import com.justiceleague.justiceleaguemodule.web.dto.ResponseDTO;
import com.justiceleague.justiceleaguemodule.web.dto.ResponseDTO.Status;

/**
 * Этот класс протестирует работу уровня REST-контроллера, встроенного
 * {@link JusticeLeagueManagementController}
 * 
 * @author dinuka
 *
 */
public class JusticeLeagueManagementControllerTest extends BaseIntegrationTest {

    /**
     * Этот метод протестирует успешность добавления участника лиги справедливости при 
     * передаче корректных данных.
     * 
     * @throws Exception
     */
    @Test
    public void testAddJusticeLeagueMember() throws Exception {

        JusticeLeagueMemberDTO flash = new JusticeLeagueMemberDTO("Barry Allen", "super speed", 
        "Central City");
        String jsonContent = mapper.writeValueAsString(flash);
        String response = mockMvc
            .perform(MockMvcRequestBuilders.post("/justiceleague/addMember")
            .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON).content(jsonContent))
            .andExpect(MockMvcResultMatchers.status().isCreated()).andReturn().getResponse().getContentAsString();

        ResponseDTO expected = new ResponseDTO(Status.SUCCESS,
        MessageConstants.MEMBER_ADDED_SUCCESSFULLY);
        ResponseDTO receivedResponse = mapper.readValue(response, ResponseDTO.class);

        Assert.assertThat(receivedResponse, SamePropertyValuesAs.samePropertyValuesAs(expected));
    }

/**
     * Этот метод проверит, будет ли получен ответ об ошибке 
     * при попытке добавить члена, который уже существует в системе.
     * 
     * @throws Exception
     */
    @Test
    public void testAddJusticeLeagueMemberWhenMemberAlreadyExists() throws Exception {
        JusticeLeagueMemberDetail flashDetail = new JusticeLeagueMemberDetail("Barry Allen", 
        "super speed","Central City");
        mongoTemplate.save(flashDetail);

        JusticeLeagueMemberDTO flash = new JusticeLeagueMemberDTO("Barry Allen", "super speed",
        "Central City");
        String jsonContent = mapper.writeValueAsString(flash);
        String response = mockMvc
            .perform(MockMvcRequestBuilders.post("/justiceleague/addMember").
            accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON).content(jsonContent))
            .andExpect(MockMvcResultMatchers.status().isCreated()).andReturn().getResponse()
            .getContentAsString();

        ResponseDTO expected = new ResponseDTO(Status.FAIL,
        ErrorMessages.MEMBER_ALREDY_EXISTS);
        ResponseDTO receivedResponse = mapper.readValue(response, ResponseDTO.class);
        Assert.assertThat(receivedResponse, SamePropertyValuesAs.samePropertyValuesAs(expected));
    }

    /**
     * Этот метод проверит, будет ли получена валидная ошибка клиента,  
     * если не будут переданы необходимые данные с полезной нагрузкой JSON запроса.
     * В нашем случае - имя супергероя.
     * 
     * @throws Exception
     */
    @Test
    public void testAddJusticeLeagueMemberWhenNameNotPassedIn() throws Exception {
        // Здесь передается пустое имя супергероя, чтобы проверить 
        // начинается ли обработка ошибки валидации.
        JusticeLeagueMemberDTO flash = new JusticeLeagueMemberDTO
        (null, "super speed", "Central City");
        String jsonContent = mapper.writeValueAsString(flash);
        mockMvc.perform(MockMvcRequestBuilders.post("/justiceleague/addMember")
        .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON).content(jsonContent))
            .andExpect(MockMvcResultMatchers.status().is4xxClientError());

    }

}

Вот, пожалуй, и все. С помощью Spring Boot Альфреду в кратчайшие сроки удалось сделать минимально функционирующую систему управления Лиги Справедливости с REST API. Со временем мы расширим функциональность этого приложения и посмотрим, как Альфред найдет подход к развертыванию приложения через Docker на Amazon AWS инстанс, управляемый Kubernetes. Нас ждут захватывающие времена, так что подключайтесь!

Традиционно ждём ваших комментариев и приглашаем на открытый вебинар, который уже 6 февраля проведет кандидат физико-математических наук — Юрий Дворжецкий.
OTUS. Онлайн-образование
384,34
Цифровые навыки от ведущих экспертов
Поделиться публикацией

Комментарии 12

    0
    В этой статье я использую версию 1.3.0.M1.

    Что это за древность? Уже есть релиз — 2.1.2.RELEASE
      +2

      Возможно, я старомоден, но мне кажется спорным использование генерации приложения через spring init, ровно как и использование спринговой (а если быть точнее, то spring-data) магии вроде интерфейсов репозиториев, где методы магическим образом реализуются сами на основе имени, без понимания того, как реально это работает внутри.

        0
        Флаг в руки искать годный скелет pom/build файла, самому добавлять зависимости и прочее.
        Если это у вас на потоке и заготовлено, то оно конечно не надо.
        Я вот редко создаю новые проекты, мне быстрее вспомнить, что есть spring initializr и ткнуть пару необходимых галок, чем делать это все самому, оно само добавит все стартеры и поставит нужную версию.
          +1

          Совершенно согласен, коллега. Силами пиарщиков у программеров создается стойкое впечатление, что любой проект на Java — это обязательно Spring. На самом деле Spring (Boot) — это про то, как максимально быстро и кратко написать приложение "Hello world" с кучей подключенных технологий. Когда же дело доходит до реальной бизнес-логики, вся эта подковерная магия начинает сильно мешать, а программирование превращается в рыскание по stackoverflow в поисках магических рецептов и заклинаний. В частности, вместо магии SpringData гораздо удобнее испльзовать type-safe обертки типа Morphia + QueryDSL или Hibernate OGM, нежели репозитории с магическими findBy.

            +1
            Я раньше так же бесился со Spring Data JPA, потому что оно не type safe, итп. А потом пришлось писать свой генератор SQL бойлерплейта и я внезапно понял, что SD гениальна! Там есть минимально всё что нужно, и почти нет мусора. Если писать ту же задачу, получится плюс-минус то же самое, что под капотом у SD, с точностью до названия методов :)

            На самом деле «магия» SD JPA состоит из десятка простейших регулярок, и всё. Никаких слишком умных компиляторов методов-в-SQL, ничего. Если знаешь, как отрабатывают регулярки под капотом, процесс написания совершенно прозрачный. Если оно чего-то не умеет, то просто не умеет и всё, можно даже не копаться на Stackoverflow в тщетных попытках.

            Наверное, потом надо запилить про это статью, а то у людей постоянно баттхерт пылает.
              +1

              Это все понятно, но я не понимаю, если брать например JPA, чем EntityRepository.findByXXX() концептуально лучше EntityManager.createQuery(...).getResultList()? Когда количество сущностей в модели данных близится к нескольким десяткам, а запросы становятся не такими тривиальными, SD уже начинает мешать, а потом сильно мешать. Количество объектов-репозиториев стремится также к нескольким десяткам, а некоторые из них быстро становятся общей помойкой всевозможных методов из совершенно разных бизнес-задач.


              Уж простите, никакой гениальности в SD я не вижу. Это не ORM, ни даже полноценный генератор запросов а некий конструктор фильтров. И у разработчиков отнюдь не стояла задача сделать его удобным для большого круга задач. Им нужен был минимальный леер, чтобы на базе него интегрировать как можно больше технологий хранения и поставить галочки.

                0
                Это для тех, у кого простая структура БД и мало нетривиальных запросов. У кого-то вообще нет никакой SQL базы данных. Какой-нибудь электронный магазин на 1С Битрикс с сотней товаров, только на Java с лайфреем. Или блог на Wordpress, только на Java. Не нужны все эти архитектурные изыски. Нужен список простейших SQL запросов вида «select *» с фильтрами, чтобы их можно было искать автодополнением в IDE, и чтобы это достаточно чисто выглядело для поддержки двумя студентами из Воронежа за три копеечки. Раз в два месяца допилить новый фильтр на странице поиска товара. Потребности обычного магазина чёрных водолазок отличаются от потребностей супер мега банка, чего их грести под одну гребёнку то.
                  –1
                  Или всё наоборот, представим что ты из мегакрутого финтех проекта с алкотрейдингом, где вся логика лежит в хранимках в базе с row level security и экстеншенами на C++ с ускорением на GPU. Зачем тебе какой-то ORM? У тебя запросов-то никаких на стороне Java нету. Но список хранимок в красивом лаконичном виде со стороны Java иметь всё же хочется. Возможно, этот список (класс репозитория) вообще надо генерить из структуры базы данных, это такая программа-максимум.
                    +1

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


                    Во-втором случае все наоборот — кейс настолько нетипичен, что SD будет только мешать. В частности, SD требует создавать DAO-репозиторий на сущность, тогда как в вашем случае хочется иметь в одном репозитории все методы, относящиеся к конкретной завершенной бизнес-задаче. Более того, многие агрегированные запросы не относятся вообще ни к какой конкретной сущности, поэтому не совсем ясно в какой репозиторий их поместить. Ну и, наконец, для этого есть более подходящие технологии, например JDBI.

              +1
              В spring init нет никакой магии, правда я пользовался веб-версией, он просто позволяет без лишних движений собрать все зависимости в pom.xml/gradle.build.
              +2
              Используя @RestController не нужно добавилять аннотицию @ResponseBody на метод.
              docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/RestController.html
                +2
                JusticeLeagueMemberServiceImpl написан так, что в реальном многопоточном проекте время от времени будет возникать неожиданное необъяснимое поведение. Но это ведь не главное — главное — «профессиональные курсы»

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

                Самое читаемое