Пользователь Roman Beskrovnyi
Roman Beskrovnyi
35 уровень
Харьков

"Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 1

Статья из группы Java-проекты
Всем привет, дорогие друзья. Сегодня будем реализовывать шаблон (шаблон — паттерн, в нашем контексте это одно и тоже) проектирования Command для наших нужд. При помощи этого шаблона мы будем удобно и правильно работать с обработкой команд нашего бота. "Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 1 - 1
Друзья, нравится проект Javarush Telegram Bot? Не ленитесь: ставьте звезду. Так будет понятно, что он интересный, и его будет приятней развивать!
Для начала хорошо было бы поговорить о том, что это за паттерн — Command. Но если я сделаю это, статья будет уж очень большой и громоздкой. Поэтому я выбрал материалы для самостоятельного изучения:
  1. Это моя статья 4-летней давности. Писал ее еще когда был джуниором, поэтому не судите строго.
  2. Видео очень эмоционального и интерактивного шведа на ютубе. Очень советую. Рассказывает шикарно, английская речь чистая и понятная. И вообще у него есть видео о других паттернах проектирования.
  3. В комментариях к моей статье некто Nullptr35 советовал это видео.
Этого должно хватить, чтобы погрузиться в тему и быть на одной волне со мной. Ну а те, кто знаком с этим шаблоном проектирования, могут пропустить смело и идти дальше.

Пишем JRTB-3

Все так же, как и раньше:
  1. Обновляем main ветку.
  2. На основе обновленной ветки main создаем новую JRTB-3.
  3. Реализуем паттерн.
  4. Создаем новый коммит с описанием проделанной работы.
  5. Создаем пул-реквест, проверяем, и если все ок — мержим нашу работу.
Пункты 1-2 показывать не буду: я их очень тщательно описывал в предыдущих статьях, поэтому приступим сразу к реализации шаблона. Почему нам подойдет этот шаблон? Да потому что каждый раз, когда мы будем выполнять какую-то команду, мы будем заходить в метод onUpdateReceived(Update update), и уже в зависимости от команды будем выполнять разную логику. Без этого паттерна у нас была бы целая тьма if-else if выражений. Что-то типа такого:

if (message.startsWith("/start")) {
   doStartCommand();
} else if(message.startsWith("/stop")) {
   doStopCommand();
} else if(message.startsWith("/addUser")) {
   doAddUserCommand();
}
...
else if(message.startsWith("/makeMeHappy")) {
   doMakeMeHappyCommand();
}
Причем там, где троеточие, может быть еще несколько десятков команд. И как это обрабатывать нормально? Как поддерживать? Сложно и тяжело. А значит нам такой вариант не подходит. Надо, чтобы это выглядело где-то так:

if (message.startsWith(COMMAND_PREFIX)) {
   String commandIdentifier = message.split(" ")[0].toLowerCase();
   commandContainer.getCommand(commandIdentifier, userName).execute(update);
} else {
   commandContainer.getCommand(NO.getCommand(), userName).execute(update);
}
И все! И сколько бы команд мы ни добавляли, этот участок кода будет неизменным. Что он делает? Первый иф смотрит, что сообщение начинается с префикса команды "/". Если это так, то вычленяем строку до первого пробела и ищем соответствующую команду у CommandContainer, как только нашли ее — запускаем команду. И все…) Если будет желание и время, можно реализовать работу с командами вначале сразу в одном классе, с кучей условий и всего такого, а потом — при помощи шаблона. Вы увидите разницу. Какая будет красота! Сперва создадим пакет рядом с пакетом bot, который и будет называться command."Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 1 - 2И уже в этом пакете будут все классы, которые относятся реализации команды. Нам же нужен один интерфейс для работы с командами. Для этого дела создадим его:

package com.github.javarushcommunity.jrtb.command;

import org.telegram.telegrambots.meta.api.objects.Update;

/**
* Command interface for handling telegram-bot commands.
*/
public interface Command {

   /**
    * Main method, which is executing command logic.
    *
    * @param update provided {@link Update} object with all the needed data for command.
    */
   void execute(Update update);
}
На данном этапе нам не нужно реализовывать обратную операцию команды, поэтому пропустим этот метод (unexecute). В методе execute в качестве аргумента приходит объект Update — как раз тот, который приходит в наш главный метод в боте. Этот объект будет содержать все нужное для обработки команды. Далее добавим enum, в котором будут храниться значения команд (start, stop и так далее). Зачем нам это нужно? Чтобы у нас был только один источник истины для названий команд. Создаем его также в нашем пакете command. Назовем его CommandName:

package com.github.javarushcommunity.jrtb.command;

/**
* Enumeration for {@link Command}'s.
*/
public enum CommandName {
  
   START("/start"),
   STOP("/stop");
  
   private final String commandName;
  
   CommandName(String commandName) {
       this.commandName = commandName;
   }
  
   public String getCommandName() {
       return commandName;
   }
  
}
Также нам нужен сервис, который будет заниматься отправкой сообщений через бота. Для этого дела создадим рядом с пакетом command — пакет service, в который будем добавлять все нужные сервисы. Здесь стоит заострить внимание на том, что я подразумеваю под словом сервис в данном случае. Если рассмотреть приложение, то зачастую оно делится на несколько слоев: слой работы с эндпоинтами — контроллеры, слой бизнес логики — сервисы, и слой работы с БД — репозиторий. Поэтому в нашем случае сервис — это класс, который осуществляет какую-то бизнес-логику. Как правильно создавать сервис? Вначале создать интерфейс к нему и реализацию. Реализацию при помощи аннотации `@Service` добавить в Application Context нашего SpringBoot приложения, и уже при необходимости подтягивать его при помощи аннотации `@Autowired`. Поэтому создаем интерфейс SendBotMessageService (в именовании сервисов обычно добавляют в конце имени Service):

package com.github.javarushcommunity.jrtb.service;

/**
* Service for sending messages via telegram-bot.
*/
public interface SendBotMessageService {

   /**
    * Send message via telegram bot.
    *
    * @param chatId provided chatId in which messages would be sent.
    * @param message provided message to be sent.
    */
   void sendMessage(String chatId, String message);
}
Далее создаем его реализацию:

package com.github.javarushcommunity.jrtb.service;

import com.github.javarushcommunity.jrtb.bot.JavarushTelegramBot;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;

/**
* Implementation of {@link SendBotMessageService} interface.
*/
@Service
public class SendBotMessageServiceImpl implements SendBotMessageService {

   private final JavarushTelegramBot javarushBot;

   @Autowired
   public SendBotMessageServiceImpl(JavarushTelegramBot javarushBot) {
       this.javarushBot = javarushBot;
   }

   @Override
   public void sendMessage(String chatId, String message) {
       SendMessage sendMessage = new SendMessage();
       sendMessage.setChatId(chatId);
       sendMessage.enableHtml(true);
       sendMessage.setText(message);

       try {
           javarushBot.execute(sendMessage);
       } catch (TelegramApiException e) {
           //todo add logging to the project.
           e.printStackTrace();
       }
   }
}
Вот так выглядит реализация. Самая главная магия находится там, где создается конструктор. При помощи аннотации @Autowired при конструктор, SpringBoot будет искать у себя в Application Context объект этого класса. А он уже там находится. Получается так работа: в нашем приложении в любом месте мы можем получить доступ к боту и что-то сделать. И вот этот сервис отвечает за то, чтобы отправлять сообщения. Чтобы мы не писали каждый раз в каждом месте что-то типа такого:

SendMessage sendMessage = new SendMessage();
sendMessage.setChatId(chatId);
sendMessage.setText(message);

try {
   javarushBot.execute(sendMessage);
} catch (TelegramApiException e) {
   //todo add logging to the project.
   e.printStackTrace();
}
Мы эту логику вынесли в отдельный класс и при необходимости будем ею пользоваться. Теперь нам нужно реализовать три команды: StartCommand, StopCommand и UnknownCommand. Нужны они для того, чтобы нам было чем заполнять наш контейнер для команд. Тексты пока что будут сухие и малоинформативные, в рамках этой задачи это не сильно важно. Итак, StartCommand:

package com.github.javarushcommunity.jrtb.command;

import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import org.telegram.telegrambots.meta.api.objects.Update;

/**
* Start {@link Command}.
*/
public class StartCommand implements Command {

   private final SendBotMessageService sendBotMessageService;

   public final static String START_MESSAGE = "Привет. Я Javarush Telegram Bot. Я помогу тебе быть в курсе последних " +
           "статей тех авторов, котрые тебе интересны. Я еще маленький и только учусь.";

   // Здесь не добавляем сервис через получение из Application Context.
   // Потому что если это сделать так, то будет циклическая зависимость, которая
   // ломает работу приложения.
   public StartCommand(SendBotMessageService sendBotMessageService) {
       this.sendBotMessageService = sendBotMessageService;
   }

   @Override
   public void execute(Update update) {
       sendBotMessageService.sendMessage(update.getMessage().getChatId().toString(), START_MESSAGE);
   }
}
Внимательно прочитайте комментарии перед конструктором. Циклическая зависимость (круговая зависимость) может произойти из-за не совсем правильной архитектуры. В нашем случае мы сделаем все так, чтобы все работало и было правильным. Реальный объект из Application Context будет добавлен при создании команды уже в CommandContainer. StopCommand:

package com.github.javarushcommunity.jrtb.command;

import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import org.telegram.telegrambots.meta.api.objects.Update;

/**
* Stop {@link Command}.
*/
public class StopCommand implements Command {
  
   private final SendBotMessageService sendBotMessageService;
  
   public static final String STOP_MESSAGE = "Деактивировал все ваши подписки \uD83D\uDE1F.";

   public StopCommand(SendBotMessageService sendBotMessageService) {
       this.sendBotMessageService = sendBotMessageService;
   }

   @Override
   public void execute(Update update) {
       sendBotMessageService.sendMessage(update.getMessage().getChatId().toString(), STOP_MESSAGE);
   }
}
И UnknownCommand. Зачем он нам нужен? Для нас это важная команда, которая будет отвечать в случае, если мы не смогли найти ту команду, которую нам передали. А еще нам нужна будет NoCommand и HelpCommand.
  • NoCommand — будет отвечать за ситуацию, когда сообщение начинается вовсе не с команды;
  • HelpCommand — будет путеводителем для пользователя, своего рода документацией.
Добавим HelpCommand:

package com.github.javarushcommunity.jrtb.command;

import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import org.telegram.telegrambots.meta.api.objects.Update;

import static com.github.javarushcommunity.jrtb.command.CommandName.*;

/**
* Help {@link Command}.
*/
public class HelpCommand implements Command {

   private final SendBotMessageService sendBotMessageService;

   public static final String HELP_MESSAGE = String.format("✨<b>Дотупные команды</b>✨\n\n"

                   + "<b>Начать\\закончить работу с ботом</b>\n"
                   + "%s - начать работу со мной\n"
                   + "%s - приостановить работу со мной\n\n"
                   + "%s - получить помощь в работе со мной\n",
           START.getCommandName(), STOP.getCommandName(), HELP.getCommandName());

   public HelpCommand(SendBotMessageService sendBotMessageService) {
       this.sendBotMessageService = sendBotMessageService;
   }

   @Override
   public void execute(Update update) {
       sendBotMessageService.sendMessage(update.getMessage().getChatId().toString(), HELP_MESSAGE);
   }
}
NoCommand:

package com.github.javarushcommunity.jrtb.command;

import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import org.telegram.telegrambots.meta.api.objects.Update;

/**
* No {@link Command}.
*/
public class NoCommand implements Command {
  
   private final SendBotMessageService sendBotMessageService;

   public static final String NO_MESSAGE = "Я поддерживаю команды, начинающиеся со слеша(/).\n"
           + "Чтобы посмотреть список команд введите /help";
  
   public NoCommand(SendBotMessageService sendBotMessageService) {
       this.sendBotMessageService = sendBotMessageService;
   }
  
   @Override
   public void execute(Update update) {
       sendBotMessageService.sendMessage(update.getMessage().getChatId().toString(), NO_MESSAGE);
   }
}
И для этой задачи остался еще UnknownCommand:

package com.github.javarushcommunity.jrtb.command;

import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import org.telegram.telegrambots.meta.api.objects.Update;

/**
* Unknown {@link Command}.
*/
public class UnknownCommand implements Command {

   public static final String UNKNOWN_MESSAGE = "Не понимаю вас \uD83D\uDE1F, напишите /help чтобы узнать что я понимаю.";

   private final SendBotMessageService sendBotMessageService;

   public UnknownCommand(SendBotMessageService sendBotMessageService) {
       this.sendBotMessageService = sendBotMessageService;
   }

   @Override
   public void execute(Update update) {
       sendBotMessageService.sendMessage(update.getMessage().getChatId().toString(), UNKNOWN_MESSAGE);
   }
}
Далее добавим контейнер для наших команд. В нем будут храниться объекты наших команд, и по запросу мы ожидаем получить необходимую команду. Назовем его CommandContainer:

package com.github.javarushcommunity.jrtb.command;

import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import com.google.common.collect.ImmutableMap;

import static com.github.javarushcommunity.jrtb.command.CommandName.*;

/**
* Container of the {@link Command}s, which are using for handling telegram commands.
*/
public class CommandContainer {

   private final ImmutableMap<String, Command> commandMap;
   private final Command unknownCommand;

   public CommandContainer(SendBotMessageService sendBotMessageService) {
      
       commandMap = ImmutableMap.<string, command="">builder()
               .put(START.getCommandName(), new StartCommand(sendBotMessageService))
               .put(STOP.getCommandName(), new StopCommand(sendBotMessageService))
               .put(HELP.getCommandName(), new HelpCommand(sendBotMessageService))
               .put(NO.getCommandName(), new NoCommand(sendBotMessageService))
               .build();

       unknownCommand = new UnknownCommand(sendBotMessageService);
   }

   public Command retrieveCommand(String commandIdentifier) {
       return commandMap.getOrDefault(commandIdentifier, unknownCommand);
   }

}
Как видно, сделано все просто. У нас есть неизменяемая мапа с ключом в виде значения команды и со значением в виде объекта команды типа Command. В конструкторе мы заполняем неизменяемую мапу один раз и все время работы приложения к ней обращаемся. Главный и единственный метод для работы с контейнером — retrieveCommand(String commandIdentifier). Есть команда UnknownCommand, которая отвечает за случаи, когда мы не можем найти соответствующую команду. Теперь мы готовы внедрить контейнер в наш класс с ботом — в JavaRushTelegramBot: Вот так теперь выглядит наш класс бота:

package com.github.javarushcommunity.jrtb.bot;

import com.github.javarushcommunity.jrtb.command.CommandContainer;
import com.github.javarushcommunity.jrtb.service.SendBotMessageServiceImpl;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
import org.telegram.telegrambots.meta.api.objects.Update;

import static com.github.javarushcommunity.jrtb.command.CommandName.NO;

/**
* Telegram bot for Javarush Community from Javarush community.
*/
@Component
public class JavarushTelegramBot extends TelegramLongPollingBot {

   public static String COMMAND_PREFIX = "/";

   @Value("${bot.username}")
   private String username;

   @Value("${bot.token}")
   private String token;

   private final CommandContainer commandContainer;

   public JavarushTelegramBot() {
       this.commandContainer = new CommandContainer(new SendBotMessageServiceImpl(this));
   }

   @Override
   public void onUpdateReceived(Update update) {
       if (update.hasMessage() && update.getMessage().hasText()) {
           String message = update.getMessage().getText().trim();
           if (message.startsWith(COMMAND_PREFIX)) {
               String commandIdentifier = message.split(" ")[0].toLowerCase();

               commandContainer.retrieveCommand(commandIdentifier).execute(update);
           } else {
               commandContainer.retrieveCommand(NO.getCommandName()).execute(update);
           }
       }
   }

   @Override
   public String getBotUsername() {
       return username;
   }

   @Override
   public String getBotToken() {
       return token;
   }
}
И все, изменения в коде закончены. Как это проверить? Нужно запустить бота и проверить, что все работает. Для этого в application.properties обновляю токен, ставлю правильный и в классе JavarushTelegramBotApplication запускаю приложение:"Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 1 - 3Теперь нужно проверить, что команды работают как нужно. Поэтапно проверяю:
  • StopCommand;
  • StartCommand;
  • HelpCommand;
  • NoCommand;
  • UnknownCommand.
Вот что получилось:"Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 1 - 4Бот отработал именно так, как мы и ожидали. Продолжение по ссылке."Java-проект от А до Я": Реализуем Command Pattern для работы с ботом. Часть 1 - 5
Другие материалы серии:
Комментарии (9)
Чтобы просмотреть все комментарии или оставить свой,
перейдите в полную версию
sergey 22 уровень, Львов
19 февраля 2021
Почему в CommandContainer в методе retrieveCommand() идея требует каста к Command в строке return?
Wladyslaw 41 уровень Master
17 февраля 2021
В догонку к материалам по паттерну. Там в целом и другие паттерны классно описаны, с UML-ками, пседокодом и примерами на разных языках, в том числе и на Java. И не только паттерны. Сайтец интернационализирован, если кому английский не удобно читать.