JavaRush /Java 博客 /Random-ZH /我们正在添加订阅一组文章的功能。(第 2 部分)-“Java 项目从头到尾”
Roman Beekeeper
第 35 级

我们正在添加订阅一组文章的功能。(第 2 部分)-“Java 项目从头到尾”

已在 Random-ZH 群组中发布
大家好!我们继续完成上周开始的任务。“Java 项目从 A 到 Z”:添加订阅一组文章的功能。 第 2 - 1 部分

我们实施 JRTB-5

现在我们需要添加一个命令,以便我们可以订阅 JavaRush 的一组文章。怎么做?我们将遵循我想出的最简单的场景。由于我们可以通过组 ID 进行访问,因此需要用户进行转移。为此,用户将输入命令/addGroupSub GROUP_ID,该命令将以两种方式之一工作:如果只有命令本身:/addGroupSub,则发送所有组及其 ID 的列表作为响应。然后用户将能够选择他需要的组ID,并在此命令中创建请求的第二个版本:/addGroupSub GROUP_ID - 然后将有该用户的该组的记录。我认为我们未来可以做得更好。我们的目标是展示开发成果,而不是超酷的用户体验(我很惭愧地说,但我不知道俄语中的这个词是什么意思)。要正确添加贯穿整个应用程序的功能(在我们的例子中,从电报机器人客户端到数据库),您需要从某个端开始。我们将从数据库端执行此操作。

向数据库添加新的迁移

首先要做的是添加新的数据库迁移以及在JR中保存用户组订阅数据的能力。要记住它应该如何,请返回文章“项目规划:测量七次”。第二张照片中有数据库的大概图表。我们需要添加一个表来存储组信息:
  • JavaRush 中的组 ID 也将是我们的 ID。我们信任他们并相信这些 ID 是唯一的;
  • 标题 - 在我们的图片中,它是名称 - 团体的非正式名称;也就是我们在JavaRush网站上看到的;
  • last_article_id - 这是一个有趣的领域。它将存储该组中文章的最后一个 ID,机器人已将其发送给其订阅者。使用此字段,搜索新文章的机制将起作用。新订阅者不会收到用户订阅之前发布的文章:只会收到订阅该群组后发布的文章。
我们还会在组和用户表之间建立多对多的关系,因为每个用户可以有多个组订阅(一对多),而每个组订阅可以有多个用户(一对多,仅另一方面)。事实证明,这将是我们的多对多。对于有疑问的人,请查看数据库中的文章。是的,我很快就计划在 Telegram 频道上创建一个帖子,在那里我将整理数据库中的所有文章。这就是我们的第二次数据库迁移的样子。
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 没有给我为 gorup_x_user 表添加外键的机会,作为迁移的一部分,我更新了数据库。请注意一个重要的方面。更改数据库应该完全按照这种方式完成 - 所需的一切都在新迁移中,而不是通过更新已发布的迁移来完成。是的,在我们的例子中什么也不会发生,因为这是一个测试项目,我们知道它只部署在一个地方,但这将是错误的方法。但我们希望一切都正确。接下来是在创建表之前删除表。为什么是这样?因此,如果数据库中偶然存在具有此类名称的表,迁移就不会失败并且会完全按照预期进行。然后我们添加两个表。一切都如我们所愿。现在我们需要启动我们的应用程序。如果一切都开始并且没有中断,那么迁移就会被记录。为了仔细检查这一点,我们进入数据库以确保:a)此类表已出现;b) 飞行路线技术表中有一个新条目。这样就完成了迁移工作,让我们继续讨论存储库。

添加存储库层

感谢 Spring Boot Data,这里的一切都非常简单:我们需要添加 GroupSub 实体,稍微更新 TelegramUser 并添加一个几乎空的 GroupSubRepository:我们将 GroupSub 实体添加到与 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);
   }
}
值得注意的一件事是,我们有一个额外的用户字段,其中包含订阅该组的所有用户的集合。两个注释 - 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 中编写一个测试来检查我们的多对多是否有效。测试的想法是,我们将通过 SQL 脚本向数据库中的每个用户添加 5 组订阅,通过用户 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 的实现 - 服务:
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 正常工作并创建多对多记录,我们需要从数据库中获取我们正在创建的订阅组的用户并将其添加到 GroupSub 对象中。因此,当我们传输此订阅进行保存时,也会通过 group_x_user 表创建一个连接。可能存在这样的情况:这样的订阅组已经创建,您只需要向其中添加另一个用户即可。为此,我们首先从数据库中获取组 ID,如果有记录,我们将使用它,如果没有,我们将创建一个新记录。值得注意的是,为了与 TelegramUser 合作,我们使用 TelegramUserService 来遵循最后一个 SOLID 原则。目前,如果我们没有通过 ID 找到记录,我只会抛出异常。现在还没有以任何方式对其进行处理:我们将在最后、MVP 之前执行此操作。让我们为GroupSubServiceTest类编写两个单元测试。我们需要哪些?我想确保在 GroupSubRepository 中调用 save 方法,并且将具有一个用户的实体传递给 GroupSub - 该实体将使用提供的 ID 将 TelegramUserService 返回给我们。第二个选项,当数据库中已经存在具有相同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);
   }

}
我还添加了带有 BeforeEach 注释的init()方法。通过这种方式,您通常会创建一个在每个测试运行之前执行的方法,并且您可以将所有测试的通用逻辑放入其中。在我们的例子中,我们需要以相同的方式锁定 TelegramUserService 对于此类的所有测试,因此将此逻辑转移到通用方法是有意义的。这里使用了两种 mokito 设计:
  • Mockito.when(o1.m1(a1)).thenReturn(o2) - 在其中我们说,当使用参数a1对对象o1 调用方法 m1,该方法将返回对象o2。这几乎是mockito最重要的功能——强制mock对象准确返回我们需要的内容;

  • Mockito.verify(o1).m1(a1) - 验证使用参数a1在对象o1上调用方法m1。当然,可以使用 save 方法返回的对象,但我决定通过显示另一种可能的方法来使其变得更复杂一些。什么时候才能有用呢?在模拟类的方法返回 void 的情况下。那么如果没有 Mockito.verify 将无法工作)))

我们继续坚持测试需要编写的想法,而且需要编写很多测试。下一阶段是与电报机器人团队合作。

创建命令 /addGroupSub

这里我们需要执行以下逻辑:如果我们只收到一个命令,没有任何上下文,我们会帮助用户并向他提供所有组及其 ID 的列表,以便他可以将必要的信息传递给机器人。如果用户向机器人发送带有其他单词的命令 - 找到具有该 ID 的组或写下没有这样的组。让我们在 ename 中添加一个新值 - CommandName:
ADD_GROUP_SUB("/addgroupsub")
让我们进一步从数据库转向电报机器人 -在命令包中 创建AddGroupSubCommand类:
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 = "Whatбы подписаться на группу - передай комадну вместе с ID группы. \n" +
               "Например: /addGroupSub 16. \n\n" +
               "я подготовил список всех групп - выберай Howую хочешь :) \n\n" +
               "Name группы - ID группы \n\n" +
               "%s";

       sendBotMessageService.sendMessage(chatId, String.format(message, groupIds));
   }
}
这个类使用apache-commons 库中的 isNumeric方法,所以让我们将它添加到我们的内存中:
<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-lang3</artifactId>
  <version>${apache.commons.version}</version>
</dependency>
在属性块中:
<apache.commons.version>3.11</apache.commons.version>
所有这些逻辑都在课堂上。仔细阅读。如果您有任何问题/建议,请将其写在评论中。之后,我们需要将命令添加到命令映射中的 CommandContainer:
.put(ADD_GROUP_SUB.getCommandName(), new AddGroupSubCommand(sendBotMessageService, javaRushGroupClient, groupSubService))
以及这支球队的一切。我想以某种方式测试这个功能,但到目前为止我只能在数据库中真正查看它。在第三部分中,我将添加 JRTB-6 中的更改,以便我们可以查看用户订阅的组列表。现在最好检查一下这一切。为此,我们将执行 Telegram 中的所有操作并检查数据库。既然我们已经编写了测试,那么一切都应该没问题。文章已经比较长了,后面我们会写一个AddGroupSubCommand的测试,并在代码中添加TODO以免忘记。

结论

在本文中,我们研究了在整个应用程序中添加功能的工作,从数据库开始,到与使用机器人的客户合作结束。通常这样的任务有助于理解项目并理解其本质。了解它是如何工作的。这些天的话题并不容易,所以不要害羞:在评论中写下你的问题,我会尽力回答。你喜欢这个项目吗?在 Github 上给它一颗星:这样就可以清楚地看出他们对这个项目感兴趣,我会很高兴。正如人们所说,当他的作品受到赞赏时,大师总是感到高兴。该代码将包含 STEP_6 的所有三个部分,并将在本文之前提供。如何了解它?这很简单 - 加入telegram 频道,我在其中发布有关 telegram 机器人的文章的所有信息。谢谢阅读!第 3 部分已经在这里

该系列所有材料的列表位于本文开头。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION