JavaRush /Java блог /Java-проекты /Обновляем статистику для админа - "Java-проект от А до Я"...
Roman Beekeeper
35 уровень

Обновляем статистику для админа - "Java-проект от А до Я"

Статья из группы Java-проекты
Всем привет, дорогие друзья. В прошлом посте мы добавляли админские команды, а сегодня поговорим о том, как расширить нашу статистику. Также, поскольку система уже по сути готова к MVP, проведем небольшой рефакторинг.
Хотите сразу узнавать, когда выйдет новый код проекту? Когда выходит новая статья? Присоединяйтесь к моему телеграм-каналу. Там я собираю свои статьи, свои мысли, свою open-source разработку воедино.
"Java-проект от А до Я": Обновляем статистику для админа - 1

На кого рассчитана эта статья?

Статья рассчитана на тех, кто уже прочел всю серию до этого. Тем, кто здесь впервые — вот начало. Кто может осилить эту серию? Да практически любой человек, который разбирается с 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, получаем:"Java-проект от А до Я": Обновляем статистику для админа - 2Разумеется, это теперь видно только админам. Теперь им будет понятнее, что творится с ботом.

В завершение

Ставим в последний раз новую 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, в котором развернута база. На основном сервере, где развернут докер с приложением, тоже не особо хочется, потому что с сервером может что-то произойти и все, тю-тю данным. И в результате я пришел к идее. Сразу скажу, что я не знаю, можно ли ее реализовать или нет, но она мне больше всего нравится. Сама идея:
  1. Бэкапы каждый день (неделю, месяц или другой промежуток времени) будет делать бэкап в специальный чат в телеграмме, доступ к которому будет админам, например. Такое распределенное хранилище данных))
  2. Добавить админускую команду моментального бэкапа для админских нужд.
  3. Добавить СУПЕР АДМИНСКУЮ команду, которая бы умела восстанавливать базу данных по предоставленному файлу.
Таким образом управление данными БД становится проще. Плюс работа бота станет более независимой от сервера, на котором находится, и в любой момент может быть развернута при помощи одной команды на любом другом сервере. Но тут в голову приходят мысли о том, что это не очень безопасно, потому как доступ к данным достаточно прост. Поэтому логичней будет поставить доступ только к одному супер админу к этим данным. как обычно лайк - подписка - колокольчик ставь звезду нашему проекту, пиши комментарии и оценивай статью! Предпоследний раз в этой серии статей говорю вам до встречи!

Список всех материалов серии в начале этой статьи.

Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ