Всем привет, дорогие друзья. В прошлом посте мы добавляли админские команды, а сегодня поговорим о том, как расширить нашу статистику. Также, поскольку система уже по сути готова к MVP, проведем небольшой рефакторинг.
лайк - подписка - колокольчик ставь звезду нашему проекту, пиши комментарии и оценивай статью!
Предпоследний раз в этой серии статей говорю вам до встречи!
Хотите сразу узнавать, когда выйдет новый код проекту? Когда выходит новая статья? Присоединяйтесь к моему телеграм-каналу. Там я собираю свои статьи, свои мысли, свою open-source разработку воедино. |
На кого рассчитана эта статья?
Статья рассчитана на тех, кто уже прочел всю серию до этого. Тем, кто здесь впервые — вот начало. Кто может осилить эту серию? Да практически любой человек, который разбирается с Java Core. Все остальное (как мне кажется) я даю и так в серии статей. Тем, кто ждал написание всего проекта, чтобы начать разбираться уже сразу и не ждать новые статьи — уже можно начинать, все практически сделано.Расширяем статистику для админа
Этот этап состоит из двух шагов: планирования и реализации. Приступим.Планируем обновленную статистику бота
Прежде чем взяться за функционал, стоит подумать, что мы хотим. Какая статистика была бы интересна для админа, чтобы отслеживать работу бота? Вначале, чтобы узнать мнение людей, я создал пост в телеграм-канале. Предложений не поступило: единственное что предложили оставить все, как есть, потому как идея показана, а как ее реализовать — каждый решает сам. Согласен, и свое мнение надо иметь, поэтому решил выделить те данные, которые мне интересны:- нужно знать количество активных пользователей бота (это уже сделано);
- нужно знать количество неактивных пользователей — добавим это. Думаю, это очень полезно, потому как будет понятно, какова доля пользователей, которые отказываются пользоваться ботом, сред всех пользователей;
- количество активных пользователей в каждой активной группе;
- среднее количество подписок на одного пользователя.
Реализуем выбранную статистику
Не думаю, что в рамках этой статьи мы реально успеем всё реализовать, но точно уверен, что она нам понадобится вся. На основе идей выше сделаем DTO класс с полями, которые мы хотим получать:
package com.github.javarushcommunity.jrtb.dto;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* DTO for getting bot statistics.
*/
@Data
@EqualsAndHashCode
public class StatisticDTO {
private final int activeUserCount;
private final int inactiveUserCount;
private final List<GroupStatDTO> groupStatDTOs;
private final double averageGroupCountByUser;
}
и GroupStatDto
package com.github.javarushcommunity.jrtb.dto;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* DTO for showing group id and title without data
*/
@Data
@EqualsAndHashCode(exclude = {"title", "activeUserCount"})
public class GroupStatDTO {
private final Integer id;
private final String title;
private final Integer activeUserCount;
}
Его мы создали в в пакете dto рядом с service, bot, command и другими.
Этими DTO мы будем пользоваться, чтобы передать данные StatisticService (мы его сейчас создадим) и StatCommand.
Напишем StatisticService:
package com.github.javarushcommunity.jrtb.service;
import com.github.javarushcommunity.jrtb.dto.StatisticDTO;
/**
* Service for getting bot statistics.
*/
public interface StatisticsService {
StatisticDTO countBotStatistic();
}
И его реализацию:
package com.github.javarushcommunity.jrtb.service;
import com.github.javarushcommunity.jrtb.dto.GroupStatDTO;
import com.github.javarushcommunity.jrtb.dto.StatisticDTO;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
import static org.springframework.util.CollectionUtils.isEmpty;
@Service
public class StatisticsServiceImpl implements StatisticsService {
private final GroupSubService groupSubService;
private final TelegramUserService telegramUserService;
public StatisticsServiceImpl(GroupSubService groupSubService, TelegramUserService telegramUserService) {
this.groupSubService = groupSubService;
this.telegramUserService = telegramUserService;
}
@Override
public StatisticDTO countBotStatistic() {
List<GroupStatDTO> groupStatDTOS = groupSubService.findAll().stream()
.filter(it -> !isEmpty(it.getUsers()))
.map(groupSub -> new GroupStatDTO(groupSub.getId(), groupSub.getTitle(), groupSub.getUsers().size()))
.collect(Collectors.toList());
List<TelegramUser> allInActiveUsers = telegramUserService.findAllInActiveUsers();
List<TelegramUser> allActiveUsers = telegramUserService.findAllActiveUsers();
double groupsPerUser = getGroupsPerUser(allActiveUsers);
return new StatisticDTO(allActiveUsers.size(), allInActiveUsers.size(), groupStatDTOS, groupsPerUser);
}
private double getGroupsPerUser(List<TelegramUser> allActiveUsers) {
return (double) allActiveUsers.stream().mapToInt(it -> it.getGroupSubs().size()).sum() / allActiveUsers.size();
}
}
Обратите внимание, что следуя SOLID’у на уровне сервисов, мы используем только сервисы других сущностей (GroupSubService, TelgeramUserService), а не их репозитории. На первый взгляд это может выглядеть избыточно, но нет. Таким образом мы избегаем проблем с зависимостями объектов друг с другом.
В TelegramUserService не было метода findAllInactiveUsers, поэтому создадим его в TelegramUserService:
/**
* Retrieve all inactive {@link TelegramUser}
*
* @return the collection of the inactive {@link TelegramUser} objects.
*/
List<TelegramUser> findAllInActiveUsers();
В TelegramUserServiceImple:
@Override
public List<TelegramUser> findAllInActiveUsers() {
return telegramUserRepository.findAllByActiveFalse();
}
Такого метода у репозитория нет, поэтому добавим его в TelegramUserRepository:
List<TelegramUser> findAllByActiveFalse();
Это Spring Data, поэтому реализовывать этот метод нам не нужно.
Сервис написали, пришло время сделать тест на это дело. Создаем StatisticServiceTest. Здесь мы мокаем данные из других сервисов и проверяем, а на основе замоканных данных нам собирают правильный GroupStatDTO объект:
package com.github.javarushcommunity.jrtb.service;
import com.github.javarushcommunity.jrtb.dto.GroupStatDTO;
import com.github.javarushcommunity.jrtb.dto.StatisticDTO;
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.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static java.util.Collections.singletonList;
@DisplayName("Unit-level testing for StatisticsService")
class StatisticsServiceTest {
private GroupSubService groupSubService;
private TelegramUserService telegramUserService;
private StatisticsService statisticsService;
@BeforeEach
public void init() {
groupSubService = Mockito.mock(GroupSubService.class);
telegramUserService = Mockito.mock(TelegramUserService.class);
statisticsService = new StatisticsServiceImpl(groupSubService, telegramUserService);
}
@Test
public void shouldProperlySendStatDTO() {
//given
Mockito.when(telegramUserService.findAllInActiveUsers()).thenReturn(singletonList(new TelegramUser()));
TelegramUser activeUser = new TelegramUser();
activeUser.setGroupSubs(singletonList(new GroupSub()));
Mockito.when(telegramUserService.findAllActiveUsers()).thenReturn(singletonList(activeUser));
GroupSub groupSub = new GroupSub();
groupSub.setTitle("group");
groupSub.setId(1);
groupSub.setUsers(singletonList(new TelegramUser()));
Mockito.when(groupSubService.findAll()).thenReturn(singletonList(groupSub));
//when
StatisticDTO statisticDTO = statisticsService.countBotStatistic();
//then
Assertions.assertNotNull(statisticDTO);
Assertions.assertEquals(1, statisticDTO.getActiveUserCount());
Assertions.assertEquals(1, statisticDTO.getInactiveUserCount());
Assertions.assertEquals(1.0, statisticDTO.getAverageGroupCountByUser());
Assertions.assertEquals(singletonList(new GroupStatDTO(groupSub.getId(), groupSub.getTitle(), groupSub.getUsers().size())),
statisticDTO.getGroupStatDTOs());
}
}
Далее нужно обновить нашу команду StatCommand:
package com.github.javarushcommunity.jrtb.command;
import com.github.javarushcommunity.jrtb.command.annotation.AdminCommand;
import com.github.javarushcommunity.jrtb.dto.StatisticDTO;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import com.github.javarushcommunity.jrtb.service.StatisticsService;
import com.github.javarushcommunity.jrtb.service.TelegramUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.telegram.telegrambots.meta.api.objects.Update;
import java.util.stream.Collectors;
/**
* Statistics {@link Command}.
*/
@AdminCommand
public class StatCommand implements Command {
private final StatisticsService statisticsService;
private final SendBotMessageService sendBotMessageService;
public final static String STAT_MESSAGE = "✨<b>Подготовил статистику</b>✨\n" +
"- Количество активных пользователей: %s\n" +
"- Количество неактивных пользователей: %s\n" +
"- Среднее количество групп на одного пользователя: %s\n\n" +
"<b>Информация по активным группам</b>:\n" +
"%s";
@Autowired
public StatCommand(SendBotMessageService sendBotMessageService, StatisticsService statisticsService) {
this.sendBotMessageService = sendBotMessageService;
this.statisticsService = statisticsService;
}
@Override
public void execute(Update update) {
StatisticDTO statisticDTO = statisticsService.countBotStatistic();
String collectedGroups = statisticDTO.getGroupStatDTOs().stream()
.map(it -> String.format("%s (id = %s) - %s подписчиков", it.getTitle(), it.getId(), it.getActiveUserCount()))
.collect(Collectors.joining("\n"));
sendBotMessageService.sendMessage(update.getMessage().getChatId().toString(), String.format(STAT_MESSAGE,
statisticDTO.getActiveUserCount(),
statisticDTO.getInactiveUserCount(),
statisticDTO.getAverageGroupCountByUser(),
collectedGroups));
}
}
Здесь мы просто компонуем всю информацию из GroupStatDTO в сообщение для админа.
Так как теперь у нас StatCommand — не просто команда, нужно написать для нее отдельный тест. Переписываем StatCommandTest:
package com.github.javarushcommunity.jrtb.command;
import com.github.javarushcommunity.jrtb.dto.GroupStatDTO;
import com.github.javarushcommunity.jrtb.dto.StatisticDTO;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import com.github.javarushcommunity.jrtb.service.StatisticsService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import java.util.Collections;
import static com.github.javarushcommunity.jrtb.command.AbstractCommandTest.prepareUpdate;
import static com.github.javarushcommunity.jrtb.command.StatCommand.STAT_MESSAGE;
import static java.lang.String.format;
@DisplayName("Unit-level testing for StatCommand")
public class StatCommandTest {
private SendBotMessageService sendBotMessageService;
private StatisticsService statisticsService;
private Command statCommand;
@BeforeEach
public void init() {
sendBotMessageService = Mockito.mock(SendBotMessageService.class);
statisticsService = Mockito.mock(StatisticsService.class);
statCommand = new StatCommand(sendBotMessageService, statisticsService);
}
@Test
public void shouldProperlySendMessage() {
//given
Long chatId = 1234567L;
GroupStatDTO groupDto = new GroupStatDTO(1, "group", 1);
StatisticDTO statisticDTO = new StatisticDTO(1, 1, Collections.singletonList(groupDto), 2.5);
Mockito.when(statisticsService.countBotStatistic())
.thenReturn(statisticDTO);
//when
statCommand.execute(prepareUpdate(chatId, CommandName.STAT.getCommandName()));
//then
Mockito.verify(sendBotMessageService).sendMessage(chatId.toString(), format(STAT_MESSAGE,
statisticDTO.getActiveUserCount(),
statisticDTO.getInactiveUserCount(),
statisticDTO.getAverageGroupCountByUser(),
format("%s (id = %s) - %s подписчиков", groupDto.getTitle(), groupDto.getId(), groupDto.getActiveUserCount())));
}
}
Здесь мы проверили, что передается именно то сообщение, которое мы ожидаем.
Разумеется, нужно будет обновить CommandContainer и JavaRushTelegramBot, чтобы CommandStat теперь передавал StatisticCommand. Оставлю это на вашей совести.
Но как же это будет выглядеть? Запускаем на тестовом боте и пишем /stat, получаем:Разумеется, это теперь видно только админам. Теперь им будет понятнее, что творится с ботом.В завершение
Ставим в последний раз новую SNAPSHOT версию в pom.xml:
<version>0.8.0-SNAPSHOT</version>
Раз обновили версию, значит, нужно обновить и RELEASE_NOTES:
# Release Notes
## 0.8.0-SNAPSHOT
* JRTB-10: extended bot statistics for admins.
Казалось бы, зачем все это заполнять? Зачем писать описание, кто его читать будет? Я вам скажу, что просто написать код — это только треть (а то и четверть) дела. Потому что кто-то должен потом этот код читать, расширять, поддерживать. А документирование дает возможность сделать это быстрее и лучше.
Все изменения по коду вы найдете в этом пулл-реквесте.
Что нам осталось? Только последняя статья с небольшими правками в рефакторинге вместе с ретроспективой. Пошаманим перед релизом и проанализируем, к чему мы пришли за эти 8 месяцев.Мысли о будущем бота
Пока готовил ужин, думал о будущем телеграм-бота, что еще осталось, что хочется сделать. И понял, что совсем не затронул тему сохранения состояния базы (бэкап) данных, чтобы можно было восстановить ее в случае чего. Думаю, вот как бы я хотел это видеть? Так, чтобы максимально автоматизировать этот процесс. И на фоне этих мыслей пришел к такому выводу: хочется, чтобы бэкапы базы данных создавались время от времени и сохранялись где-то без моего участия. Но где хранить? Определенно, это нужно делать вне docker’a, в котором развернута база. На основном сервере, где развернут докер с приложением, тоже не особо хочется, потому что с сервером может что-то произойти и все, тю-тю данным. И в результате я пришел к идее. Сразу скажу, что я не знаю, можно ли ее реализовать или нет, но она мне больше всего нравится. Сама идея:- Бэкапы каждый день (неделю, месяц или другой промежуток времени) будет делать бэкап в специальный чат в телеграмме, доступ к которому будет админам, например. Такое распределенное хранилище данных))
- Добавить админускую команду моментального бэкапа для админских нужд.
- Добавить СУПЕР АДМИНСКУЮ команду, которая бы умела восстанавливать базу данных по предоставленному файлу.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ