JavaRush /Java блог /Java-проекты /Добавляем возможность подписаться на группу статей. (Част...
Roman Beekeeper
35 уровень

Добавляем возможность подписаться на группу статей. (Часть 2) - "Java-проект от А до Я"

Статья из группы Java-проекты
Всем привет! Продолжаем работу над задачей, которую мы начали на прошлой неделе."Java-проект от А до Я": Добавляем возможность подписаться на группу статей. Часть 2 - 1

Реализуем JRTB-5

Теперь нам нужно добавить команду, чтобы мы могли подписаться на какую-то группу статей из JavaRush. Как это сделать? Будем идти по наиболее простому сценарию, который я придумал. Так как доступ у нас по ID группы, нам нужно, чтобы пользователь его передал. Для этого пользователь будет вводить команду /addGroupSub GROUP_ID, которая будет работать по одному из двух вариантов: если приходит только сама команда: /addGroupSub, в ответ передается список всех групп и их ID-шники. Тогда пользователь сможет выбрать нужный ему ID группы и составить второй вариант запроса в этой команде: /addGroupSub GROUP_ID — и тогда уже будет запись этой группы с данным пользователем. Думаю, в будущем можно будет сделать и лучше. Наша цель — показать именно разработку, а не супер крутой user experience (стыдно сказать, но я не знаю термина на русском, который бы обозначал это). Чтобы правильно добавить функциональность, которая идет сквозь все приложение (в нашем случае — от клиента телеграмм-бота до базы данных), нужно начинать с какого-то конца. Будем делать это со стороны БД.

Добавляем новую миграцию в БД

Первое, что нужно сделать — добавить новую миграцию базы данных и возможность сохранять данные о подписке пользователей на группы в JR. Чтобы вспомнить, как это должно быть, вернитесь к статье “Планирование проекта: семь раз отмерь”. Там на втором фото нарисована приблизительная схема БД. Нам нужно добавить таблицу для сохранения информации группы:
  • ID группы в JavaRush будет и нашим ID. Мы доверяем им и считаем, что эти ID уникальны;
  • title — в наших картинках это было name — неформальное название группы; то есть то, что мы видим на сайте JavaRush;
  • last_article_id — а это интересное поле. Оно будет хранить последний ID-шник статьи в этой группе, которую бот уже отослал своим подписчикам. При помощи этого поля будет работать механизм поиска новых статей. Новым подписчикам не будут приходить статьи, опубликованные до того, как пользователь подписался: только те, которые вышли после подписки на группу.
Также у нас будет связь many-to-many между таблицей групп и пользователей, потому что у каждого пользователя может быть много подписок на группы (one-to-many), и у каждой подписки на группу может быть много пользователей (one-to-many, только с другой стороны). Получается, что это и будет наш many-to-many. У кого это вызывает вопросы, пересмотрите статьи за базы данных. Да, скоро планирую создать пост в Телеграм-канале, где соберу воедино все статьи по БД. Вот как будет выглядеть наша вторая миграция БД.

V00002__created_groupsub_many_to_many.sql:

-- add PRIMARY KEY FOR tg_user
ALTER TABLE tg_user ADD PRIMARY KEY (chat_id);

-- ensure that the tables with these names are removed before creating a new one.
DROP TABLE IF EXISTS group_sub;
DROP TABLE IF EXISTS group_x_user;

CREATE TABLE group_sub (
   id INT,
   title VARCHAR(100),
   last_article_id INT,
   PRIMARY KEY (id)
);

CREATE TABLE group_x_user (
   group_sub_id INT NOT NULL,
   user_id VARCHAR(100) NOT NULL,
   FOREIGN KEY (user_id) REFERENCES tg_user(chat_id),
   FOREIGN KEY (group_sub_id) REFERENCES group_sub(id),
   UNIQUE(user_id, group_sub_id)
);
Важно отметить, что вначале я изменяю старую таблицу — добавляю ей первичный ключ. Я как-то это упустил из виду в тот раз, но теперь мне MySQL не давал возможность добавить FOREIGN KEY для таблицы gorup_x_user, и я в рамках этой миграции обновил базу данных. Обратите внимание на важный аспект. Изменение БД нужно делать именно так — в новой миграции все то, что нужно, но никак не путем обновления уже выпущенной миграции. Да, в нашем случае ничего бы не случилось, так как это тестовый проект и мы знаем, что он разворачивается только в одном месте, но это был бы неправильный подход. А ведь мы хотим, чтобы все было правильно. Далее идет удаление таблиц перед их созданием. Зачем это? Чтобы если по какой-то случайности таблицы с такими именами были в БД, миграция не упала бы и отработала именно так как и ожидается. И далее добавляем две таблицы. Все как и хотели. Теперь нужно запустить наше приложение. Если все запустится и не сломается, значит, миграция записана. А чтобы это перепроверить, идем в базу данных удостовериться, что: а) такие таблицы появились; б) есть новая запись в технической таблице flyway. На этом работа с миграцией закончена, переходим к репозиториям.

Добавляем слой репозитория

Благодаря Spring Boot Data здесь все очень просто: нам нужно добавить сущность GroupSub, несколько обновить TelegramUser и добавить почти пустой GroupSubRepository: GroupSub entity добавляем в тот же пакет, что и TelegramUser:

package com.github.javarushcommunity.jrtb.repository.entity;

import lombok.Data;
import lombok.EqualsAndHashCode;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

import static java.util.Objects.isNull;

@Data
@Entity
@Table(name = "group_sub")
@EqualsAndHashCode
public class GroupSub {

   @Id
   private Integer id;

   @Column(name = "title")
   private String title;

   @Column(name = "last_article_id")
   private Integer lastArticleId;

   @ManyToMany(fetch = FetchType.EAGER)
   @JoinTable(
           name = "group_x_user",
           joinColumns = @JoinColumn(name = "group_sub_id"),
           inverseJoinColumns = @JoinColumn(name = "user_id")
   )
   private List<TelegramUser> users;

   public void addUser(TelegramUser telegramUser) {
       if (isNull(users)) {
           users = new ArrayList<>();
       }
       users.add(telegramUser);
   }
}
Из того, что стоит отметить — у нас есть дополнительное поле users, которое будет содержать коллекцию всех пользователей, подписанных на группу. И две аннотации — ManyToMany и JoinTable — как раз для этого нам и нужны. Такое по смыслу поле нужно добавить и для TelegramUser:

@ManyToMany(mappedBy = "users", fetch = FetchType.EAGER)
private List<GroupSub> groupSubs;
Это поле использует джоины, написанные в GroupSub сущности. И, собственно, наш класс репозиторий для GroupSub — GroupSubRepository:

package com.github.javarushcommunity.jrtb.repository;

import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/**
* {@link Repository} for {@link GroupSub} entity.
*/
@Repository
public interface GroupSubRepository extends JpaRepository<GroupSub, Integer> {
}
На данном этапе нам не нужны дополнительные методы: тех, что реализованы в предке JpaRepository, нам хватает. Напишем тест в TelegramUserRepositoryIT, который будет проверять, что наша many-to-many работает. Идея теста заключается в том, что мы добавим в базу данных 5 групп подписок на одного пользователя через sql скрипт, получим этого пользователя по его ID и проверим, что нам пришли именно те группы и именно с такими значениями. Как это сделать? В данные можно зашить счетчик, по которому потом пройдем и проверим. Вот скрипт fiveGroupSubsForUser.sql:

INSERT INTO tg_user VALUES (1, 1);

INSERT INTO group_sub VALUES
(1, 'g1', 1),
(2, 'g2', 2),
(3, 'g3', 3),
(4, 'g4', 4),
(5, 'g5', 5);

INSERT INTO group_x_user VALUES
(1, 1),
(2, 1),
(3, 1),
(4, 1),
(5, 1);
И сам тест:

@Sql(scripts = {"/sql/clearDbs.sql", "/sql/fiveGroupSubsForUser.sql"})
@Test
public void shouldProperlyGetAllGroupSubsForUser() {
   //when
   Optional<TelegramUser> userFromDB = telegramUserRepository.findById("1");

   //then
   Assertions.assertTrue(userFromDB.isPresent());
   List<GroupSub> groupSubs = userFromDB.get().getGroupSubs();
   for (int i = 0; i < groupSubs.size(); i++) {
       Assertions.assertEquals(String.format("g%s", (i + 1)), groupSubs.get(i).getTitle());
       Assertions.assertEquals(i + 1, groupSubs.get(i).getId());
       Assertions.assertEquals(i + 1, groupSubs.get(i).getLastArticleId());
   }
}
Теперь добавим такой же по смыслу тест для GroupSub сущности. Дл этого создадим тестовый класс groupSubRepositoryIT в том же пакете, что и groupSubRepositoryIT:

package com.github.javarushcommunity.jrtb.repository;

import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;

import java.util.List;
import java.util.Optional;

import static org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace.NONE;

/**
* Integration-level testing for {@link GroupSubRepository}.
*/
@ActiveProfiles("test")
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
public class GroupSubRepositoryIT {

   @Autowired
   private GroupSubRepository groupSubRepository;

   @Sql(scripts = {"/sql/clearDbs.sql", "/sql/fiveUsersForGroupSub.sql"})
   @Test
   public void shouldProperlyGetAllUsersForGroupSub() {
       //when
       Optional<GroupSub> groupSubFromDB = groupSubRepository.findById(1);

       //then
       Assertions.assertTrue(groupSubFromDB.isPresent());
       Assertions.assertEquals(1, groupSubFromDB.get().getId());
       List<TelegramUser> users = groupSubFromDB.get().getUsers();
       for(int i=0; i<users.size(); i++) {
           Assertions.assertEquals(String.valueOf(i + 1), users.get(i).getChatId());
           Assertions.assertTrue(users.get(i).isActive());
       }
   }
}
И недостающий скрипт fiveUsersForGroupSub.sql:

INSERT INTO tg_user VALUES
(1, 1),
(2, 1),
(3, 1),
(4, 1),
(5, 1);

INSERT INTO group_sub VALUES (1, 'g1', 1);

INSERT INTO group_x_user VALUES
(1, 1),
(1, 2),
(1, 3),
(1, 4),
(1, 5);
На этом часть работы с репозиторием можно считать оконченной. Теперь напишем слой сервисов.

Пишем GroupSubService

На данном этапе для работы с группами подписок нам нужно только уметь сохранять их, поэтому никаких проблем: создаем сервис GroupSubService и его реализацию GroupSubServiceImpl в пакете, где есть и другие сервисы — service:

package com.github.javarushcommunity.jrtb.service;

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;

/**
* Service for manipulating with {@link GroupSub}.
*/
public interface GroupSubService {

   GroupSub save(String chatId, GroupDiscussionInfo groupDiscussionInfo);
}
И его реализацию:

package com.github.javarushcommunity.jrtb.service;

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.repository.GroupSubRepository;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.ws.rs.NotFoundException;
import java.util.Optional;

@Service
public class GroupSubServiceImpl implements GroupSubService {

   private final GroupSubRepository groupSubRepository;
   private final TelegramUserService telegramUserService;

   @Autowired
   public GroupSubServiceImpl(GroupSubRepository groupSubRepository, TelegramUserService telegramUserService) {
       this.groupSubRepository = groupSubRepository;
       this.telegramUserService = telegramUserService;
   }

   @Override
   public GroupSub save(String chatId, GroupDiscussionInfo groupDiscussionInfo) {
       TelegramUser telegramUser = telegramUserService.findByChatId(chatId).orElseThrow(NotFoundException::new);
       //TODO add exception handling
       GroupSub groupSub;
       Optional<GroupSub> groupSubFromDB = groupSubRepository.findById(groupDiscussionInfo.getId());
       if(groupSubFromDB.isPresent()) {
           groupSub = groupSubFromDB.get();
           Optional<TelegramUser> first = groupSub.getUsers().stream()
                   .filter(it -> it.getChatId().equalsIgnoreCase(chatId))
                   .findFirst();
           if(first.isEmpty()) {
               groupSub.addUser(telegramUser);   
           }
       } else {
           groupSub = new GroupSub();
           groupSub.addUser(telegramUser);
           groupSub.setId(groupDiscussionInfo.getId());
           groupSub.setTitle(groupDiscussionInfo.getTitle());
       }
       return groupSubRepository.save(groupSub);
   }
}
Чтобы Spring Data заработала правильно и создалась запись many-to-many, нам нужно для группы подписки, которую мы создаем, достать из нашей БД юзера и добавить его в объект GroupSub. Тем самым, когда мы передадим на сохранение эту подписку, будет создана еще и связь через таблицу group_x_user. Может быть ситуация, когда уже создана такая группа подписки и нужно просто добавить к ней еще одного юзера. Для этого мы сперва получаем по ID группы из БД, и если запись есть, работаем с ней, если нет — создаем новую. Важно отметить, что для работы с TelegramUser мы используем TelegramUserService, чтобы следовать последнему из принципов SOLID. На данный момент, если мы не находим запись по ID, я просто выбрасываю исключение. Оно никак сейчас не обрабатывается: мы это сделаем уже в самом конце, перед MVP. Напишем два юнит-теста на класс GroupSubServiceTest. Какие нам нужны? Я хочу быть уверенным, что в GroupSubRepository вызовется метод save и будет передана GroupSub сущность с одним единственным пользователем — тем, что вернет нам TelegramUserService по предоставленному ID. И второй вариант, когда группа с таким ID уже есть в БД и уже у этой группы есть один пользователь, и нужно проверить, что к этой группе будет добавлен еще один пользователь и этот объект сохранен. Вот реализация:

package com.github.javarushcommunity.jrtb.service;

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.repository.GroupSubRepository;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.util.Optional;

@DisplayName("Unit-level testing for GroupSubService")
public class GroupSubServiceTest {

   private GroupSubService groupSubService;
   private GroupSubRepository groupSubRepository;
   private TelegramUser newUser;

   private final static String CHAT_ID = "1";

   @BeforeEach
   public void init() {
       TelegramUserService telegramUserService = Mockito.mock(TelegramUserService.class);
       groupSubRepository = Mockito.mock(GroupSubRepository.class);
       groupSubService = new GroupSubServiceImpl(groupSubRepository, telegramUserService);

       newUser = new TelegramUser();
       newUser.setActive(true);
       newUser.setChatId(CHAT_ID);

       Mockito.when(telegramUserService.findByChatId(CHAT_ID)).thenReturn(Optional.of(newUser));
   }

   @Test
   public void shouldProperlySaveGroup() {
       //given

       GroupDiscussionInfo groupDiscussionInfo = new GroupDiscussionInfo();
       groupDiscussionInfo.setId(1);
       groupDiscussionInfo.setTitle("g1");

       GroupSub expectedGroupSub = new GroupSub();
       expectedGroupSub.setId(groupDiscussionInfo.getId());
       expectedGroupSub.setTitle(groupDiscussionInfo.getTitle());
       expectedGroupSub.addUser(newUser);

       //when
       groupSubService.save(CHAT_ID, groupDiscussionInfo);

       //then
       Mockito.verify(groupSubRepository).save(expectedGroupSub);
   }

   @Test
   public void shouldProperlyAddUserToExistingGroup() {
       //given
       TelegramUser oldTelegramUser = new TelegramUser();
       oldTelegramUser.setChatId("2");
       oldTelegramUser.setActive(true);

       GroupDiscussionInfo groupDiscussionInfo = new GroupDiscussionInfo();
       groupDiscussionInfo.setId(1);
       groupDiscussionInfo.setTitle("g1");

       GroupSub groupFromDB = new GroupSub();
       groupFromDB.setId(groupDiscussionInfo.getId());
       groupFromDB.setTitle(groupDiscussionInfo.getTitle());
       groupFromDB.addUser(oldTelegramUser);

       Mockito.when(groupSubRepository.findById(groupDiscussionInfo.getId())).thenReturn(Optional.of(groupFromDB));

       GroupSub expectedGroupSub = new GroupSub();
       expectedGroupSub.setId(groupDiscussionInfo.getId());
       expectedGroupSub.setTitle(groupDiscussionInfo.getTitle());
       expectedGroupSub.addUser(oldTelegramUser);
       expectedGroupSub.addUser(newUser);

       //when
       groupSubService.save(CHAT_ID, groupDiscussionInfo);

       //then
       Mockito.verify(groupSubRepository).findById(groupDiscussionInfo.getId());
       Mockito.verify(groupSubRepository).save(expectedGroupSub);
   }

}
Добавил здесь еще метод init() с аннотацией BeforeEach. Таким образом обычно создают метод, который будет выполняться перед запуском каждого теста, и в него можно вынести общую логику для всех тестов. В нашем случае — замокать TelegramUserService нужно одинаково для всех тестов этого класса, поэтому разумно перенести эту логику в общий метод. Здесь используется две конструкции из мокито:
  • Mockito.when(o1.m1(a1)).thenReturn(o2) — в ней говорим, что когда в объекте o1 будет вызван метод m1 с аргументом a1, метод вернет объект o2. Это чуть ли не самая главная функциональность мокито — заставить моковый объект возвращать именно то, что нам нужно;

  • Mockito.verify(o1).m1(a1) — который проверяет, что в объекте o1 был вызван метод m1 с аргументом a1. Можно было, конечно, воспользоваться возвращаемым объектом метода save, но я решил сделать немного сложнее, показав еще один из возможных способов. Когда он может быть полезен? В случаях когда методы моковых классов возвращают void. Тогда без Mockito.verify дела не будет)))

Продолжаем придерживаться идеи, что тесты писать нужно, и писать их нужно много. Следующий этап — работа с командой телеграм-бота.

Создаем команду /addGroupSub

Здесь нужно выполнить следующую логику: если нам приходит просто команда, без какого-либо контекста, мы помогаем пользователю и передаем ему список всех групп с их ID-шниками, чтобы он смог передать боту нужную информацию. А если пользователь передает боту команду с еще каким-то словом (словами) — найти группу с таким ID или написать, что такой группы нет. Добавим новое значение в нашем енаме — CommandName:

ADD_GROUP_SUB("/addgroupsub")
Двигаемся дальше от БД до телеграм-бота — создаем класс AddGroupSubCommand в пакете command:

package com.github.javarushcommunity.jrtb.command;

import com.github.javarushcommunity.jrtb.javarushclient.JavaRushGroupClient;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupRequestArgs;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.service.GroupSubService;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import org.telegram.telegrambots.meta.api.objects.Update;

import java.util.stream.Collectors;

import static com.github.javarushcommunity.jrtb.command.CommandName.ADD_GROUP_SUB;
import static com.github.javarushcommunity.jrtb.command.CommandUtils.getChatId;
import static com.github.javarushcommunity.jrtb.command.CommandUtils.getMessage;
import static java.util.Objects.isNull;
import static org.apache.commons.lang3.StringUtils.SPACE;
import static org.apache.commons.lang3.StringUtils.isNumeric;

/**
* Add Group subscription {@link Command}.
*/
public class AddGroupSubCommand implements Command {

   private final SendBotMessageService sendBotMessageService;
   private final JavaRushGroupClient javaRushGroupClient;
   private final GroupSubService groupSubService;

   public AddGroupSubCommand(SendBotMessageService sendBotMessageService, JavaRushGroupClient javaRushGroupClient,
                             GroupSubService groupSubService) {
       this.sendBotMessageService = sendBotMessageService;
       this.javaRushGroupClient = javaRushGroupClient;
       this.groupSubService = groupSubService;
   }

   @Override
   public void execute(Update update) {
       if (getMessage(update).equalsIgnoreCase(ADD_GROUP_SUB.getCommandName())) {
           sendGroupIdList(getChatId(update));
           return;
       }
       String groupId = getMessage(update).split(SPACE)[1];
       String chatId = getChatId(update);
       if (isNumeric(groupId)) {
           GroupDiscussionInfo groupById = javaRushGroupClient.getGroupById(Integer.parseInt(groupId));
           if (isNull(groupById.getId())) {
               sendGroupNotFound(chatId, groupId);
           }
           GroupSub savedGroupSub = groupSubService.save(chatId, groupById);
           sendBotMessageService.sendMessage(chatId, "Подписал на группу " + savedGroupSub.getTitle());
       } else {
           sendGroupNotFound(chatId, groupId);
       }
   }

   private void sendGroupNotFound(String chatId, String groupId) {
       String groupNotFoundMessage = "Нет группы с ID = \"%s\"";
       sendBotMessageService.sendMessage(chatId, String.format(groupNotFoundMessage, groupId));
   }

   private void sendGroupIdList(String chatId) {
       String groupIds = javaRushGroupClient.getGroupList(GroupRequestArgs.builder().build()).stream()
               .map(group -> String.format("%s - %s \n", group.getTitle(), group.getId()))
               .collect(Collectors.joining());

       String message = "Чтобы подписаться на группу - передай комадну вместе с ID группы. \n" +
               "Например: /addGroupSub 16. \n\n" +
               "я подготовил список всех групп - выберай какую хочешь :) \n\n" +
               "имя группы - ID группы \n\n" +
               "%s";

       sendBotMessageService.sendMessage(chatId, String.format(message, groupIds));
   }
}
В это классе используется метод isNumeric из apache-commons библиотеки, поэтому добавим ее в наш помник:

<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-lang3</artifactId>
  <version>${apache.commons.version}</version>
</dependency>
И в блок properties:

<apache.commons.version>3.11</apache.commons.version>
Вся эта логика находится в классе. Прочитайте ее внимательно. Будут вопросы/предложения — пишите в комментариях. После этого нужно добавить команду в CommandContainer в нашу мапу команд:

.put(ADD_GROUP_SUB.getCommandName(), new AddGroupSubCommand(sendBotMessageService, javaRushGroupClient, groupSubService))
И все для этой команды. Хочется как-то проверить эту функциональность, но пока что реально можно посмотреть только в БД. В третьей части я добавлю изменения из JRTB-6, чтобы мы могли просмотреть список групп, на которые подписан пользователь. Теперь хорошо бы это все проверить. Для этого выполним все действия в Телеграме и проверить в базе данных. Так как у нас написаны тесты, все должно быть отлично. Статья уже и так вышла немалая, поэтому тест для AddGroupSubCommand напишем позже, а в коде добавим TODO, чтобы не забыть.

Выводы

В этой статье мы рассмотрели работу добавлению функциональности через все приложение, начиная от базы данных и заканчивая работой с клиентом, который пользуется ботом. Обычно такие задачи помогают разобраться в проекте, понять его суть. Понять то, как он устроен. Сейчас темы идут непростые, поэтому не стесняйтесь: пишите свои вопросы в комментариях, а я постараюсь на них ответить. Нравится проект? Ставьте ему звезду на гитхабе: таким образом будет понятно, что проектом интересуются, и я буду довольный. Как говорится, мастеру всегда приятно, когда его работу ценят. Код будет содержать все три части STEP_6 и будет доступен раньше этой статьи. Как узнать о нем? Легко — присоединиться к телеграм-каналу, где я публикую всю информацию о своих статьях о телеграм-боте. Спасибо за прочтение! Часть 3 уже тут.

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

Комментарии (6)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Dmitry Nevar Уровень 26
22 апреля 2021
Роман, привет! У меня в классе AddGroupSubCommand Idea ругается на: String groupIds = javaRushGroupClient.getGroupList(GroupRequestArgs.builder().build()).stream() .map(group -> String.format("%s - %s \n", group.getTitle(), group.getId())) .collect(Collectors.joining()); Суть его претензии "Cannot resolve method getTitle(getId также) in 'Object'" Я со стримами не особо дружу, не мог бы ты расписать что происходит и почему в стриме group идентифицирован как Object и чем он должен быть по задумке?