Всем привет! Продолжаем работу над задачей, которую мы начали на прошлой неделе.
Реализуем JRTB-5
Теперь нам нужно добавить команду, чтобы мы могли подписаться на какую-то группу статей из JavaRush. Как это сделать? Будем идти по наиболее простому сценарию, который я придумал. Так как доступ у нас по ID группы, нам нужно, чтобы пользователь его передал. Для этого пользователь будет вводить команду /addGroupSub GROUP_ID, которая будет работать по одному из двух вариантов: если приходит только сама команда: /addGroupSub, в ответ передается список всех групп и их ID-шники. Тогда пользователь сможет выбрать нужный ему ID группы и составить второй вариант запроса в этой команде: /addGroupSub GROUP_ID — и тогда уже будет запись этой группы с данным пользователем. Думаю, в будущем можно будет сделать и лучше. Наша цель — показать именно разработку, а не супер крутой user experience (стыдно сказать, но я не знаю термина на русском, который бы обозначал это). Чтобы правильно добавить функциональность, которая идет сквозь все приложение (в нашем случае — от клиента телеграмм-бота до базы данных), нужно начинать с какого-то конца. Будем делать это со стороны БД.Добавляем новую миграцию в БД
Первое, что нужно сделать — добавить новую миграцию базы данных и возможность сохранять данные о подписке пользователей на группы в JR. Чтобы вспомнить, как это должно быть, вернитесь к статье “Планирование проекта: семь раз отмерь”. Там на втором фото нарисована приблизительная схема БД. Нам нужно добавить таблицу для сохранения информации группы:- ID группы в JavaRush будет и нашим ID. Мы доверяем им и считаем, что эти ID уникальны;
- title — в наших картинках это было name — неформальное название группы; то есть то, что мы видим на сайте JavaRush;
- last_article_id — а это интересное поле. Оно будет хранить последний ID-шник статьи в этой группе, которую бот уже отослал своим подписчикам. При помощи этого поля будет работать механизм поиска новых статей. Новым подписчикам не будут приходить статьи, опубликованные до того, как пользователь подписался: только те, которые вышли после подписки на группу.
V00002__created_groupsub_many_to_many.sql:
-- add PRIMARY KEY FOR tg_user
ALTER TABLE tg_user ADD PRIMARY KEY (chat_id);
-- ensure that the tables with these names are removed before creating a new one.
DROP TABLE IF EXISTS group_sub;
DROP TABLE IF EXISTS group_x_user;
CREATE TABLE group_sub (
id INT,
title VARCHAR(100),
last_article_id INT,
PRIMARY KEY (id)
);
CREATE TABLE group_x_user (
group_sub_id INT NOT NULL,
user_id VARCHAR(100) NOT NULL,
FOREIGN KEY (user_id) REFERENCES tg_user(chat_id),
FOREIGN KEY (group_sub_id) REFERENCES group_sub(id),
UNIQUE(user_id, group_sub_id)
);
Важно отметить, что вначале я изменяю старую таблицу — добавляю ей первичный ключ. Я как-то это упустил из виду в тот раз, но теперь мне MySQL не давал возможность добавить FOREIGN KEY для таблицы gorup_x_user, и я в рамках этой миграции обновил базу данных.
Обратите внимание на важный аспект. Изменение БД нужно делать именно так — в новой миграции все то, что нужно, но никак не путем обновления уже выпущенной миграции. Да, в нашем случае ничего бы не случилось, так как это тестовый проект и мы знаем, что он разворачивается только в одном месте, но это был бы неправильный подход. А ведь мы хотим, чтобы все было правильно.
Далее идет удаление таблиц перед их созданием. Зачем это? Чтобы если по какой-то случайности таблицы с такими именами были в БД, миграция не упала бы и отработала именно так как и ожидается.
И далее добавляем две таблицы. Все как и хотели.
Теперь нужно запустить наше приложение. Если все запустится и не сломается, значит, миграция записана. А чтобы это перепроверить, идем в базу данных удостовериться, что:
а) такие таблицы появились;
б) есть новая запись в технической таблице flyway.
На этом работа с миграцией закончена, переходим к репозиториям.Добавляем слой репозитория
Благодаря Spring Boot Data здесь все очень просто: нам нужно добавить сущность GroupSub, несколько обновить TelegramUser и добавить почти пустой GroupSubRepository: GroupSub entity добавляем в тот же пакет, что и TelegramUser:
package com.github.javarushcommunity.jrtb.repository.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
import static java.util.Objects.isNull;
@Data
@Entity
@Table(name = "group_sub")
@EqualsAndHashCode
public class GroupSub {
@Id
private Integer id;
@Column(name = "title")
private String title;
@Column(name = "last_article_id")
private Integer lastArticleId;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "group_x_user",
joinColumns = @JoinColumn(name = "group_sub_id"),
inverseJoinColumns = @JoinColumn(name = "user_id")
)
private List<TelegramUser> users;
public void addUser(TelegramUser telegramUser) {
if (isNull(users)) {
users = new ArrayList<>();
}
users.add(telegramUser);
}
}
Из того, что стоит отметить — у нас есть дополнительное поле users, которое будет содержать коллекцию всех пользователей, подписанных на группу. И две аннотации — ManyToMany и JoinTable — как раз для этого нам и нужны.
Такое по смыслу поле нужно добавить и для TelegramUser:
@ManyToMany(mappedBy = "users", fetch = FetchType.EAGER)
private List<GroupSub> groupSubs;
Это поле использует джоины, написанные в GroupSub сущности.
И, собственно, наш класс репозиторий для GroupSub — GroupSubRepository:
package com.github.javarushcommunity.jrtb.repository;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* {@link Repository} for {@link GroupSub} entity.
*/
@Repository
public interface GroupSubRepository extends JpaRepository<GroupSub, Integer> {
}
На данном этапе нам не нужны дополнительные методы: тех, что реализованы в предке JpaRepository, нам хватает. Напишем тест в TelegramUserRepositoryIT, который будет проверять, что наша many-to-many работает.
Идея теста заключается в том, что мы добавим в базу данных 5 групп подписок на одного пользователя через sql скрипт, получим этого пользователя по его ID и проверим, что нам пришли именно те группы и именно с такими значениями. Как это сделать? В данные можно зашить счетчик, по которому потом пройдем и проверим.
Вот скрипт fiveGroupSubsForUser.sql:
INSERT INTO tg_user VALUES (1, 1);
INSERT INTO group_sub VALUES
(1, 'g1', 1),
(2, 'g2', 2),
(3, 'g3', 3),
(4, 'g4', 4),
(5, 'g5', 5);
INSERT INTO group_x_user VALUES
(1, 1),
(2, 1),
(3, 1),
(4, 1),
(5, 1);
И сам тест:
@Sql(scripts = {"/sql/clearDbs.sql", "/sql/fiveGroupSubsForUser.sql"})
@Test
public void shouldProperlyGetAllGroupSubsForUser() {
//when
Optional<TelegramUser> userFromDB = telegramUserRepository.findById("1");
//then
Assertions.assertTrue(userFromDB.isPresent());
List<GroupSub> groupSubs = userFromDB.get().getGroupSubs();
for (int i = 0; i < groupSubs.size(); i++) {
Assertions.assertEquals(String.format("g%s", (i + 1)), groupSubs.get(i).getTitle());
Assertions.assertEquals(i + 1, groupSubs.get(i).getId());
Assertions.assertEquals(i + 1, groupSubs.get(i).getLastArticleId());
}
}
Теперь добавим такой же по смыслу тест для GroupSub сущности. Дл этого создадим тестовый класс groupSubRepositoryIT в том же пакете, что и groupSubRepositoryIT:
package com.github.javarushcommunity.jrtb.repository;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
import java.util.List;
import java.util.Optional;
import static org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace.NONE;
/**
* Integration-level testing for {@link GroupSubRepository}.
*/
@ActiveProfiles("test")
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
public class GroupSubRepositoryIT {
@Autowired
private GroupSubRepository groupSubRepository;
@Sql(scripts = {"/sql/clearDbs.sql", "/sql/fiveUsersForGroupSub.sql"})
@Test
public void shouldProperlyGetAllUsersForGroupSub() {
//when
Optional<GroupSub> groupSubFromDB = groupSubRepository.findById(1);
//then
Assertions.assertTrue(groupSubFromDB.isPresent());
Assertions.assertEquals(1, groupSubFromDB.get().getId());
List<TelegramUser> users = groupSubFromDB.get().getUsers();
for(int i=0; i<users.size(); i++) {
Assertions.assertEquals(String.valueOf(i + 1), users.get(i).getChatId());
Assertions.assertTrue(users.get(i).isActive());
}
}
}
И недостающий скрипт fiveUsersForGroupSub.sql:
INSERT INTO tg_user VALUES
(1, 1),
(2, 1),
(3, 1),
(4, 1),
(5, 1);
INSERT INTO group_sub VALUES (1, 'g1', 1);
INSERT INTO group_x_user VALUES
(1, 1),
(1, 2),
(1, 3),
(1, 4),
(1, 5);
На этом часть работы с репозиторием можно считать оконченной. Теперь напишем слой сервисов.Пишем GroupSubService
На данном этапе для работы с группами подписок нам нужно только уметь сохранять их, поэтому никаких проблем: создаем сервис GroupSubService и его реализацию GroupSubServiceImpl в пакете, где есть и другие сервисы — service:
package com.github.javarushcommunity.jrtb.service;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
/**
* Service for manipulating with {@link GroupSub}.
*/
public interface GroupSubService {
GroupSub save(String chatId, GroupDiscussionInfo groupDiscussionInfo);
}
И его реализацию:
package com.github.javarushcommunity.jrtb.service;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.repository.GroupSubRepository;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.ws.rs.NotFoundException;
import java.util.Optional;
@Service
public class GroupSubServiceImpl implements GroupSubService {
private final GroupSubRepository groupSubRepository;
private final TelegramUserService telegramUserService;
@Autowired
public GroupSubServiceImpl(GroupSubRepository groupSubRepository, TelegramUserService telegramUserService) {
this.groupSubRepository = groupSubRepository;
this.telegramUserService = telegramUserService;
}
@Override
public GroupSub save(String chatId, GroupDiscussionInfo groupDiscussionInfo) {
TelegramUser telegramUser = telegramUserService.findByChatId(chatId).orElseThrow(NotFoundException::new);
//TODO add exception handling
GroupSub groupSub;
Optional<GroupSub> groupSubFromDB = groupSubRepository.findById(groupDiscussionInfo.getId());
if(groupSubFromDB.isPresent()) {
groupSub = groupSubFromDB.get();
Optional<TelegramUser> first = groupSub.getUsers().stream()
.filter(it -> it.getChatId().equalsIgnoreCase(chatId))
.findFirst();
if(first.isEmpty()) {
groupSub.addUser(telegramUser);
}
} else {
groupSub = new GroupSub();
groupSub.addUser(telegramUser);
groupSub.setId(groupDiscussionInfo.getId());
groupSub.setTitle(groupDiscussionInfo.getTitle());
}
return groupSubRepository.save(groupSub);
}
}
Чтобы Spring Data заработала правильно и создалась запись many-to-many, нам нужно для группы подписки, которую мы создаем, достать из нашей БД юзера и добавить его в объект GroupSub. Тем самым, когда мы передадим на сохранение эту подписку, будет создана еще и связь через таблицу group_x_user.
Может быть ситуация, когда уже создана такая группа подписки и нужно просто добавить к ней еще одного юзера. Для этого мы сперва получаем по ID группы из БД, и если запись есть, работаем с ней, если нет — создаем новую.
Важно отметить, что для работы с TelegramUser мы используем TelegramUserService, чтобы следовать последнему из принципов SOLID.
На данный момент, если мы не находим запись по ID, я просто выбрасываю исключение. Оно никак сейчас не обрабатывается: мы это сделаем уже в самом конце, перед MVP.
Напишем два юнит-теста на класс GroupSubServiceTest. Какие нам нужны? Я хочу быть уверенным, что в GroupSubRepository вызовется метод save и будет передана GroupSub сущность с одним единственным пользователем — тем, что вернет нам TelegramUserService по предоставленному ID. И второй вариант, когда группа с таким ID уже есть в БД и уже у этой группы есть один пользователь, и нужно проверить, что к этой группе будет добавлен еще один пользователь и этот объект сохранен. Вот реализация:
package com.github.javarushcommunity.jrtb.service;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.repository.GroupSubRepository;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.Optional;
@DisplayName("Unit-level testing for GroupSubService")
public class GroupSubServiceTest {
private GroupSubService groupSubService;
private GroupSubRepository groupSubRepository;
private TelegramUser newUser;
private final static String CHAT_ID = "1";
@BeforeEach
public void init() {
TelegramUserService telegramUserService = Mockito.mock(TelegramUserService.class);
groupSubRepository = Mockito.mock(GroupSubRepository.class);
groupSubService = new GroupSubServiceImpl(groupSubRepository, telegramUserService);
newUser = new TelegramUser();
newUser.setActive(true);
newUser.setChatId(CHAT_ID);
Mockito.when(telegramUserService.findByChatId(CHAT_ID)).thenReturn(Optional.of(newUser));
}
@Test
public void shouldProperlySaveGroup() {
//given
GroupDiscussionInfo groupDiscussionInfo = new GroupDiscussionInfo();
groupDiscussionInfo.setId(1);
groupDiscussionInfo.setTitle("g1");
GroupSub expectedGroupSub = new GroupSub();
expectedGroupSub.setId(groupDiscussionInfo.getId());
expectedGroupSub.setTitle(groupDiscussionInfo.getTitle());
expectedGroupSub.addUser(newUser);
//when
groupSubService.save(CHAT_ID, groupDiscussionInfo);
//then
Mockito.verify(groupSubRepository).save(expectedGroupSub);
}
@Test
public void shouldProperlyAddUserToExistingGroup() {
//given
TelegramUser oldTelegramUser = new TelegramUser();
oldTelegramUser.setChatId("2");
oldTelegramUser.setActive(true);
GroupDiscussionInfo groupDiscussionInfo = new GroupDiscussionInfo();
groupDiscussionInfo.setId(1);
groupDiscussionInfo.setTitle("g1");
GroupSub groupFromDB = new GroupSub();
groupFromDB.setId(groupDiscussionInfo.getId());
groupFromDB.setTitle(groupDiscussionInfo.getTitle());
groupFromDB.addUser(oldTelegramUser);
Mockito.when(groupSubRepository.findById(groupDiscussionInfo.getId())).thenReturn(Optional.of(groupFromDB));
GroupSub expectedGroupSub = new GroupSub();
expectedGroupSub.setId(groupDiscussionInfo.getId());
expectedGroupSub.setTitle(groupDiscussionInfo.getTitle());
expectedGroupSub.addUser(oldTelegramUser);
expectedGroupSub.addUser(newUser);
//when
groupSubService.save(CHAT_ID, groupDiscussionInfo);
//then
Mockito.verify(groupSubRepository).findById(groupDiscussionInfo.getId());
Mockito.verify(groupSubRepository).save(expectedGroupSub);
}
}
Добавил здесь еще метод init() с аннотацией BeforeEach. Таким образом обычно создают метод, который будет выполняться перед запуском каждого теста, и в него можно вынести общую логику для всех тестов.
В нашем случае — замокать TelegramUserService нужно одинаково для всех тестов этого класса, поэтому разумно перенести эту логику в общий метод.
Здесь используется две конструкции из мокито:Mockito.when(o1.m1(a1)).thenReturn(o2) — в ней говорим, что когда в объекте o1 будет вызван метод m1 с аргументом a1, метод вернет объект o2. Это чуть ли не самая главная функциональность мокито — заставить моковый объект возвращать именно то, что нам нужно;
Mockito.verify(o1).m1(a1) — который проверяет, что в объекте o1 был вызван метод m1 с аргументом a1. Можно было, конечно, воспользоваться возвращаемым объектом метода save, но я решил сделать немного сложнее, показав еще один из возможных способов. Когда он может быть полезен? В случаях когда методы моковых классов возвращают void. Тогда без Mockito.verify дела не будет)))
Создаем команду /addGroupSub
Здесь нужно выполнить следующую логику: если нам приходит просто команда, без какого-либо контекста, мы помогаем пользователю и передаем ему список всех групп с их ID-шниками, чтобы он смог передать боту нужную информацию. А если пользователь передает боту команду с еще каким-то словом (словами) — найти группу с таким ID или написать, что такой группы нет. Добавим новое значение в нашем енаме — CommandName:
ADD_GROUP_SUB("/addgroupsub")
Двигаемся дальше от БД до телеграм-бота — создаем класс AddGroupSubCommand в пакете command:
package com.github.javarushcommunity.jrtb.command;
import com.github.javarushcommunity.jrtb.javarushclient.JavaRushGroupClient;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupRequestArgs;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.service.GroupSubService;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import org.telegram.telegrambots.meta.api.objects.Update;
import java.util.stream.Collectors;
import static com.github.javarushcommunity.jrtb.command.CommandName.ADD_GROUP_SUB;
import static com.github.javarushcommunity.jrtb.command.CommandUtils.getChatId;
import static com.github.javarushcommunity.jrtb.command.CommandUtils.getMessage;
import static java.util.Objects.isNull;
import static org.apache.commons.lang3.StringUtils.SPACE;
import static org.apache.commons.lang3.StringUtils.isNumeric;
/**
* Add Group subscription {@link Command}.
*/
public class AddGroupSubCommand implements Command {
private final SendBotMessageService sendBotMessageService;
private final JavaRushGroupClient javaRushGroupClient;
private final GroupSubService groupSubService;
public AddGroupSubCommand(SendBotMessageService sendBotMessageService, JavaRushGroupClient javaRushGroupClient,
GroupSubService groupSubService) {
this.sendBotMessageService = sendBotMessageService;
this.javaRushGroupClient = javaRushGroupClient;
this.groupSubService = groupSubService;
}
@Override
public void execute(Update update) {
if (getMessage(update).equalsIgnoreCase(ADD_GROUP_SUB.getCommandName())) {
sendGroupIdList(getChatId(update));
return;
}
String groupId = getMessage(update).split(SPACE)[1];
String chatId = getChatId(update);
if (isNumeric(groupId)) {
GroupDiscussionInfo groupById = javaRushGroupClient.getGroupById(Integer.parseInt(groupId));
if (isNull(groupById.getId())) {
sendGroupNotFound(chatId, groupId);
}
GroupSub savedGroupSub = groupSubService.save(chatId, groupById);
sendBotMessageService.sendMessage(chatId, "Подписал на группу " + savedGroupSub.getTitle());
} else {
sendGroupNotFound(chatId, groupId);
}
}
private void sendGroupNotFound(String chatId, String groupId) {
String groupNotFoundMessage = "Нет группы с ID = \"%s\"";
sendBotMessageService.sendMessage(chatId, String.format(groupNotFoundMessage, groupId));
}
private void sendGroupIdList(String chatId) {
String groupIds = javaRushGroupClient.getGroupList(GroupRequestArgs.builder().build()).stream()
.map(group -> String.format("%s - %s \n", group.getTitle(), group.getId()))
.collect(Collectors.joining());
String message = "Чтобы подписаться на группу - передай комадну вместе с ID группы. \n" +
"Например: /addGroupSub 16. \n\n" +
"я подготовил список всех групп - выберай какую хочешь :) \n\n" +
"имя группы - ID группы \n\n" +
"%s";
sendBotMessageService.sendMessage(chatId, String.format(message, groupIds));
}
}
В это классе используется метод isNumeric из apache-commons библиотеки, поэтому добавим ее в наш помник:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${apache.commons.version}</version>
</dependency>
И в блок properties:
<apache.commons.version>3.11</apache.commons.version>
Вся эта логика находится в классе. Прочитайте ее внимательно. Будут вопросы/предложения — пишите в комментариях.
После этого нужно добавить команду в CommandContainer в нашу мапу команд:
.put(ADD_GROUP_SUB.getCommandName(), new AddGroupSubCommand(sendBotMessageService, javaRushGroupClient, groupSubService))
И все для этой команды. Хочется как-то проверить эту функциональность, но пока что реально можно посмотреть только в БД. В третьей части я добавлю изменения из JRTB-6, чтобы мы могли просмотреть список групп, на которые подписан пользователь.
Теперь хорошо бы это все проверить. Для этого выполним все действия в Телеграме и проверить в базе данных. Так как у нас написаны тесты, все должно быть отлично.
Статья уже и так вышла немалая, поэтому тест для AddGroupSubCommand напишем позже, а в коде добавим TODO, чтобы не забыть.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ