JavaRush /Java блог /Random UA /Реалізуємо Command Pattern для роботи з роботом. (Частина...
Roman Beekeeper
35 рівень

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

Стаття з групи Random UA
Всім привіт, дорогі друзі. Сьогодні реалізовуватимемо шаблон (шаблон — патерн, у нашому контексті це одне й теж) проектування 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.codegymcommunity.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.codegymcommunity.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.codegymcommunity.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.codegymcommunity.jrtb.service;

import com.github.codegymcommunity.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 codegymBot;

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

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

       try {
           codegymBot.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 {
   codegymBot.execute(sendMessage);
} catch (TelegramApiException e) {
   //todo add logging to the project.
   e.printStackTrace();
}
Ми цю логіку винесли в окремий клас і за потреби будемо нею користуватися. Тепер нам потрібно реалізувати три команди: StartCommand, StopCommand та UnknownCommand. Потрібні вони для того, щоб було чим заповнювати наш контейнер для команд. Тексти поки що будуть сухі та малоінформативні, в рамках цього завдання це не дуже важливо. Отже, StartCommand:
package com.github.codegymcommunity.jrtb.command;

import com.github.codegymcommunity.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.codegymcommunity.jrtb.command;

import com.github.codegymcommunity.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.codegymcommunity.jrtb.command;

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

import static com.github.codegymcommunity.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.codegymcommunity.jrtb.command;

import com.github.codegymcommunity.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.codegymcommunity.jrtb.command;

import com.github.codegymcommunity.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.codegymcommunity.jrtb.command;

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

import static com.github.codegymcommunity.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, яка відповідає за випадки, коли ми не можемо знайти відповідну команду. Тепер ми готові впровадити контейнер у наш клас з ботом - в CodeGymTelegramBot: Ось так тепер виглядає наш клас бота:
package com.github.codegymcommunity.jrtb.bot;

import com.github.codegymcommunity.jrtb.command.CommandContainer;
import com.github.codegymcommunity.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.codegymcommunity.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Бот відпрацював саме так, як ми очікували. Продовження за посиланням .

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

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