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

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

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

На кого розраховано цю статтю?

Стаття розрахована на тих, хто вже прочитав усю серію раніше. Тим, хто тут уперше – ось початок . Хто може подолати цю серію? Так практично будь-яка людина, яка розуміється на Java Core. Все інше (як мені здається) я даю і так у серії статей. Тим, хто чекав на написання всього проекту, щоб почати розбиратися вже відразу і не чекати нових статей — уже можна починати, все практично зроблено.

Розширюємо статистику для адміну

Цей етап складається з двох кроків: планування та реалізації. Приступимо.

Плануємо оновлену статистику робота

Перш ніж узятись за функціонал, варто подумати, що ми хочемо. Яка статистика була б цікавою для адміна, щоб відстежувати роботу бота? Спочатку, щоб дізнатися думку людей, я створив пост у телеграм-каналі . Пропозицій не надійшло: єдине, що запропонували залишити все, як є, бо ідея показана, а як її реалізувати — кожен вирішує сам. Згоден, і свою думку треба мати, тому вирішив виділити ті дані, які мені цікаві:
  • потрібно знати кількість активних користувачів робота (це вже зроблено);
  • потрібно знати кількість неактивних користувачів - додамо це. Думаю, це дуже корисно, тому що буде зрозуміло, яка частка користувачів, які відмовляються користуватися ботом, серед усіх користувачів;
  • кількість активних користувачів у кожній активній групі;
  • середня кількість передплат на одного користувача.
Ці дані слід відстежувати щодня, або кожні два дні, наприклад. А на основі цих даних – малювати статистику за тиждень/місяць/квартал. Це буде окрема джоба, яка виконуватиметься щодня. Також на основі цих даних можна відстежувати зміну активних користувачів у часі. Ось так, така статистика була б корисною для адміна і більш зрозумілою.

Реалізуємо обрану статистику

Не думаю, що в рамках цієї статті ми реально встигнемо все реалізувати, але впевнений, що вона нам знадобиться вся. На основі ідей вище зробимо DTO клас із полями, які ми хочемо отримувати:
package com.github.codegymcommunity.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.codegymcommunity.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.codegymcommunity.jrtb.service;

import com.github.codegymcommunity.jrtb.dto.StatisticDTO;

/**
* Service for getting bot statistics.
*/
public interface StatisticsService {
   StatisticDTO countBotStatistic();
}
І його реалізацію:
package com.github.codegymcommunity.jrtb.service;

import com.github.codegymcommunity.jrtb.dto.GroupStatDTO;
import com.github.codegymcommunity.jrtb.dto.StatisticDTO;
import com.github.codegymcommunity.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.codegymcommunity.jrtb.service;

import com.github.codegymcommunity.jrtb.dto.GroupStatDTO;
import com.github.codegymcommunity.jrtb.dto.StatisticDTO;
import com.github.codegymcommunity.jrtb.repository.entity.GroupSub;
import com.github.codegymcommunity.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.codegymcommunity.jrtb.command;

import com.github.codegymcommunity.jrtb.command.annotation.AdminCommand;
import com.github.codegymcommunity.jrtb.dto.StatisticDTO;
import com.github.codegymcommunity.jrtb.service.SendBotMessageService;
import com.github.codegymcommunity.jrtb.service.StatisticsService;
import com.github.codegymcommunity.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.codegymcommunity.jrtb.command;

import com.github.codegymcommunity.jrtb.dto.GroupStatDTO;
import com.github.codegymcommunity.jrtb.dto.StatisticDTO;
import com.github.codegymcommunity.jrtb.service.SendBotMessageService;
import com.github.codegymcommunity.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.codegymcommunity.jrtb.command.AbstractCommandTest.prepareUpdate;
import static com.github.codegymcommunity.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 і CodeGymTelegramBot, щоб CommandStat тепер передавав StatisticCommand. Залишу це на вашій совісті. Але як це виглядатиме? Запускаємо на тестовому боті і пишемо /stat, отримуємо: "Java-проект від А до Я": Оновлюємо статистику для адміну - 2Зрозуміло, це тепер видно лише адмінам. Тепер їм буде зрозуміліше, що діється з ботом.

На завершення

Востаннє ставимо нову SNAPSHOT версію в pom.xml:
<version>0.8.0-SNAPSHOT</version>
Якщо оновабо версію, значить, потрібно оновити і RELEASE_NOTES:
# Release Notes ## 0.8.0-SNAPSHOT * JRTB-10: extensed bot statistics for admins.
Здавалося б, навіщо це заповнювати? Навіщо писати опис, хто його читатиме? Я вам скажу, що просто написати код — це лише третина (а то й чверть) справи. Бо хтось має потім цей код читати, розширювати, підтримувати. А документування дає можливість зробити це швидше та краще. Усі зміни за кодом ви знайдете в цьому пулл-реквесті . Що нам лишилося? Лише остання стаття з невеликими правками у рефакторингу разом із ретроспективою. Пошаманімо перед релізом і проаналізуємо, до чого ми прийшли за ці 8 місяців.

Думки про майбутнє бота

Поки готував вечерю, думав про майбутнє телеграм-бота, що ще лишилося, що хочеться зробити. І зрозумів, що зовсім не торкнувся теми збереження стану бази (бекап) даних, щоб можна було відновити її у разі чого. Думаю, як би я хотів це бачити? Так щоб максимально автоматизувати цей процес. І на тлі цих думок дійшов такого висновку: хочеться, щоб бекапи бази даних створювалися час від часу і зберігалися десь без моєї участі. Але де ж зберігати? Безперечно, це потрібно робити поза docker'a, в якому розгорнута база. На основному сервері, де розгорнуть докер з додатком, теж не дуже хочеться, тому що з сервером може щось відбутися і все, цю-тю даним. І в результаті я дійшов до ідеї. Відразу скажу, що я не знаю, чи можна її реалізувати чи ні, але вона мені найбільше подобається. Сама ідея:
  1. Бекапи щодня (тиждень, місяць або інший проміжок часу) робитиме бекап у спеціальний чат у телеграмі, доступ до якого буде адмінам, наприклад. Таке розподілене сховище даних))
  2. Додати команду адміну моментального бекапу для адмінських потреб.
  3. Додати СУПЕР АДМІНСЬКУ команду, яка вміла б відновлювати базу даних по наданому файлу.
Таким чином, управління даними БД стає простіше. Плюс робота робота стане більш незалежною від сервера, на якому знаходиться, і в будь-який момент може бути розгорнута за допомогою однієї команди на будь-якому іншому сервері. Але тут на думку спадають думки про те, що це не дуже безпечно, тому що доступ до даних досить простий. Тому логічніше буде поставити доступ лише до одного супер адміну до цих даних. як завжди лайк - передплата - дзвіночок став зірку нашому проекту , пиши коментарі та оцінюй статтю! Востаннє в цій серії статей говорю вам до зустрічі!

Список всіх матеріалів серії на початку цієї статті.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ