JavaRush/Java блог/Java-проекты/Реализуем Command Pattern для работы с ботом. (Часть 1) -...
Roman Beekeeper
35 уровень

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

Статья из группы 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Бот отработал именно так, как мы и ожидали. Продолжение по ссылке.

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

Комментарии (32)
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны авторизоваться
Anonymous #2856674 Software Developer
17 марта, 12:19
Пока не совсем понятны мелочи, например зачем сообщения в командах - это паблик статик, а не прайват, например И зачем @Service у SendMessageServiceImp - бин пока не нужен, убрал аннотацию Возможно дальше станет понятнее, почему так, а не иначе UPD в конечных лекциях про Spring Scheduler всплывет необходимость аннотации @Service, до этого она не нужна В любом случае отличный проект, продолжаю ему следовать, автору спасибо и респект!) ЗЫ использовал обычную мапу вместо иммутабельной. При получении значения обернул в Optional и возвращаю UnknowCommand если в опшенеле null
Anonymous #2856674 Software Developer
1 апреля, 17:27
PS на этапе тестирования станет ясно, почему статик =)
Mr.Selby
Уровень 18
28 августа 2023, 11:56
Всем привет! Спасибо автору за подробное изложение! Наткнулся на статью когда уже начал делать и сейчас такой вопрос Корректно ли создавать CommandContainer в таком виде? Или его тоже @Компонентом помечать и в конструктор отправлять?
@Log4j2
@Component
public class Bot extends TelegramLongPollingBot {

    public final static String COMMAND_PREFIX = "/";

    private final BotConfig CONFIG;

    private final CommandContainer COMMAND_CONTAINER = new CommandContainer(new SendMessageServiceImpl(this));

    @Autowired
    public Bot(BotConfig config) {
        CONFIG = config;
    }
Anonymous #2856674 Software Developer
17 марта, 12:25
Если ты его помечаешь компонентом и запихнешь в конструктор, получается при создании бина Bot у тебя будет запрошен бин CommandContainer . В нем требуется бин SendMessageService. А в нем требуется бин Bot, таким образом получится зацикливание, о котором упоминал автор при написании команды Start
Мария
Уровень 23
19 августа 2023, 07:08
Сулейманов Евгений в этом видео круто объясняет паттерн Command на практике.
Vitaly Demchenko
Уровень 44
18 августа 2023, 17:15
Для доступа к ImmutableMap необходимо в Maven добавить следующую зависимость:
<dependency>
	<groupId>com.google.guava</groupId>
	<artifactId>guava</artifactId>
	<version>32.1.2-jre</version>
</dependency>
Далее в теле конструктора CommandContainer скорректировать:
...
commandMap = ImmutableMap.<String, Command>builder()
.put(..., ...)
...
.build();
Alex
Уровень 35
8 февраля 2023, 10:05
В нынешней версии библиотеки TelegramBots(6.5.0 на момент написания этого коммента) была удалена зависимость guava Если не хотите тянуть guava в проект, в java11 unmodifiable map можно создать так
Рогов Игорь
Уровень 17
9 января 2023, 11:47
у каждого уважающего себя программиста есть пакет с пакетами
Denis
Уровень 33
12 ноября 2022, 23:23
Все работает! Спасибо автору!
Денис
Уровень 1
29 октября 2022, 15:15
Сделал все по чертежу, работает собака. Автору глубочайшее спасибо...
Джу
Уровень 35
30 июля 2021, 17:28
Среда разработки ругается, бот не отвечает. В чем ошибка?
Roman Beekeeper тг-канал по java разработ в t.me/romankh3
31 июля 2021, 07:04
У тебя там есть ошибка, думаю надо начать с того, что понять отчего она
Кирилл C.
Уровень 40
7 июня 2021, 17:40
Роман, а как ты понимаешь, что вот именно вот эту вот библиотеку стоит ковырять и учить (в нашем случае, это гугловская com.google.common.collect.ImmutableMap)? Библиотек великое множество, если учить все, не хватит жизни, а как найти стоящие - я хз. Чисто опыт использования, при работе в каком-либо проекте мож/быть?
Roman Beekeeper тг-канал по java разработ в t.me/romankh3
7 июня 2021, 18:12
Кирилл, есть общепризнанный набор библиотек, которые можно использовать. Если есть сомнение по какой-то из них - можно сходить посмотреть на их состояние в их репозитории: когда последний раз обновляли, есть ли открытые критические проблемы, есть ли сообщество, которое их разрабатывает. Например через GitHub можно исследовать достаточно успешно. Посмотреть на версию продукта, готовая либа не будет иметь версию 0.001-alpha/beta или что-то похожее.
Кирилл C.
Уровень 40
21 июня 2021, 07:11
Роман, благодарю!