Всім привіт, дорогі друзі. Минулого посту ми додавали адмінські команди, а сьогодні поговоримо про те, як розширити нашу статистику. Також оскільки система вже по суті готова до MVP, проведемо невеликий рефакторинг.
лайк - передплата - дзвіночок став зірку нашому проекту , пиши коментарі та оцінюй статтю! Востаннє в цій серії статей говорю вам до зустрічі!
Хочете одразу дізнаватися, коли вийде новий код проекту? Коли виходить нова стаття? Приєднуйтесь до мого телеграм-каналу . Там я збираю свої статті, свої думки, свою open-source розробку докупи. |
На кого розраховано цю статтю?
Стаття розрахована на тих, хто вже прочитав усю серію раніше. Тим, хто тут уперше – ось початок . Хто може подолати цю серію? Так практично будь-яка людина, яка розуміється на 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, отримуємо: Зрозуміло, це тепер видно лише адмінам. Тепер їм буде зрозуміліше, що діється з ботом.
На завершення
Востаннє ставимо нову 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, в якому розгорнута база. На основному сервері, де розгорнуть докер з додатком, теж не дуже хочеться, тому що з сервером може щось відбутися і все, цю-тю даним. І в результаті я дійшов до ідеї. Відразу скажу, що я не знаю, чи можна її реалізувати чи ні, але вона мені найбільше подобається. Сама ідея:- Бекапи щодня (тиждень, місяць або інший проміжок часу) робитиме бекап у спеціальний чат у телеграмі, доступ до якого буде адмінам, наприклад. Таке розподілене сховище даних))
- Додати команду адміну моментального бекапу для адмінських потреб.
- Додати СУПЕР АДМІНСЬКУ команду, яка вміла б відновлювати базу даних по наданому файлу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ