Всем привет. Спешим поздравить студентов с профессиональным праздником и сообщить о том, что уже в феврале у нас стартует курс «Разработчик на 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 февраля проведет кандидат физико-математических наук — Юрий Дворжецкий.
Автор: MaxRokatansky