大家好。让我提醒您:在第一部分中我们添加了 Flyway。我们继续吧。
将数据库添加到 docker-compose.yml
下一阶段是在主 docker-compose.yml 中设置数据库工作。让我们将数据库添加到 docker-compose 文件中:version: '3.1'
services:
jrtb-bot:
depends_on:
- jrtb-db
build:
context: .
environment:
BOT_NAME: ${BOT_NAME}
BOT_TOKEN: ${BOT_TOKEN}
BOT_DB_USERNAME: ${BOT_DB_USERNAME}
BOT_DB_PASSWORD: ${BOT_DB_PASSWORD}
restart: always
jrtb-db:
image: mysql:5.7
restart: always
environment:
MYSQL_USER: ${BOT_DB_USERNAME}
MYSQL_PASSWORD: ${BOT_DB_PASSWORD}
MYSQL_DATABASE: 'jrtb_db'
MYSQL_ROOT_PASSWORD: 'root'
ports:
- '3306:3306'
expose:
- '3306'
我还将这一行添加到我们的应用程序中:
depends_on:
- jrtb-db
这意味着我们在启动应用程序之前等待数据库启动。接下来,您可以注意到我们添加了两个用于处理数据库的变量:
${BOT_DB_USERNAME}
${BOT_DB_PASSWORD}
我们将以与电报机器人相同的方式在 docker-compose 中获取它们 - 通过环境变量。我这样做是为了我们只有一个地方可以设置数据库用户名及其密码的值。我们将它们传递给应用程序的 docker 映像和数据库的 docker 容器。接下来,我们需要更新 Dockerfile 以教导 SpringBoot 接受数据库变量。
FROM adoptopenjdk/openjdk11:ubi
ARG JAR_FILE=target/*.jar
ENV BOT_NAME=test.javarush_community_bot
ENV BOT_TOKEN=1375780501:AAE4A6Rz0BSnIGzeu896OjQnjzsMEG6_uso
ENV BOT_DB_USERNAME=jrtb_db_user
ENV BOT_DB_PASSWORD=jrtb_db_password
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-Dspring.datasource.password=${BOT_DB_PASSWORD}", "-Dbot.username=${BOT_NAME}", "-Dbot.token=${BOT_TOKEN}", "-Dspring.datasource.username=${BOT_DB_USERNAME}", "-jar", "app.jar"]
现在我们将数据库变量添加到 Dockerfile 中:
ENV BOT_DB_USERNAME=jrtb_db_user
ENV BOT_DB_PASSWORD=jrtb_db_password
变量值会有所不同。然而,我们将传递到 Dockerfile 中的值需要默认值,所以我输入了一些。我们用两个元素扩展最后一行,借助这两个元素,我们将数据库用户名和密码传递给应用程序启动:
"-Dspring.datasource.password=${BOT_DB_PASSWORD}", "-Dbot.username=${BOT_NAME}"
Dockerfile 中的最后一行(以 ENTRYPOINT 开头)必须不换行。如果您进行转账,此代码将不起作用。 最后一步是更新start.sh文件以将变量传递到数据库。
#!/bin/bash
# Pull new changes
git pull
# Prepare Jar
mvn clean
mvn package
# Ensure, that docker-compose stopped
docker-compose stop
# Add environment variables
export BOT_NAME=$1
export BOT_TOKEN=$2
export BOT_DB_USERNAME='prod_jrtb_db_user'
export BOT_DB_PASSWORD='Pap9L9VVUkNYj99GCUCC3mJkb'
# Start new deployment
docker-compose up --build -d
我们已经知道如何在运行 docker-compose 之前添加环境变量。为此,你只需要执行export var_name=var_value.。因此,我们只添加两行:
export BOT_DB_USERNAME='prod_jrtb_db_user'
export BOT_DB_PASSWORD='Pap9L9VVUkNYj99GCUCC3mJkb'
这是我们设置数据库用户名和密码的地方。当然,在运行 bash 脚本时可以传递这些变量,就像我们对机器人的名称和令牌所做的那样。但在我看来,这是没有必要的。要实际访问数据库,您需要知道将部署数据库的服务器的 IP,并且位于请求允许的 IP 地址列表中。对于我来说,这已经足够了。基础已经奠定:现在您可以做开发人员更容易理解的事情 - 编写代码。在此之前,我们正在做 DevOps 工程师所做的事情——设置环境。
添加存储库层
通常,应用程序具有三层:- 控制器是应用程序的入口点。
- 服务是业务逻辑发挥作用的地方。我们已经部分了解了这一点:SendMessageService 是业务逻辑的显式代表。
- 存储库是使用数据库的地方。在我们的例子中,这是一个电报机器人。
- 我们不必使用 JDBC:我们将直接使用更高的抽象。即存储与数据库中的表对应的POJO。我们将此类类称为“实体” ,因为它们在Java 持久性 API中被正式称为“实体” (这是通过 ORM 操作数据库的一组通用接口,即对 JDBC 操作的抽象)。我们将有一个实体类,将其保存在数据库中,它们将准确地写入我们需要的表中。在数据库中搜索时,我们将收到相同的对象。
- Spring Data 建议使用他们的接口集:JpaRepository、CrudRepository等...还有其他接口:可以在此处找到完整列表。美妙之处在于您可以使用它们的方法而不需要实现它们(!)。此外,还有一个特定的模板,您可以使用它在接口中编写新方法,并且它们将自动实现。
- Spring 尽可能地简化了我们的开发。为此,我们需要创建自己的接口并继承上述接口。为了让 Spring 知道它需要使用这个接口,添加 Repository 注解。
- 如果我们需要编写一个方法来处理不存在的数据库,那么这也不是问题 - 我们会编写它。我将向您展示在那里做什么以及如何做。
package com.github.javarushcommunity.jrtb.repository.entity;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* Telegram User entity.
*/
@Data
@Entity
@Table(name = "tg_user")
public class TelegramUser {
@Id
@Column(name = "chat_id")
private String chatId;
@Column(name = "active")
private boolean active;
}
在这里您可以看到我们拥有 javax.persistence 包中的所有注释。这些是用于所有 ORM 实现的通用注释。默认情况下,Spring Data Jpa 使用 Hibernate,但也可以使用其他实现。以下是我们使用的注释列表:
- Entity - 表明这是一个用于操作数据库的实体;
- Table——这里我们定义表的名称;
- Id - 注释表明哪个字段将成为表中的主键;
- 列- 确定表中字段的名称。
package com.github.javarushcommunity.jrtb.repository;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* {@link Repository} for handling with {@link TelegramUser} entity.
*/
@Repository
public interface TelegramUserRepository extends JpaRepository<TelegramUser, String> {
List<TelegramUser> findAllByActiveTrue();
}
在这里您可以看到我如何添加findAllByActiveTrue()方法,我没有在任何地方实现该方法。但这不会阻止他工作。Spring Data 将理解它需要从 tg_user 表中获取active 字段 = true的所有记录。我们添加了一个与 TelegramUser 实体一起使用的服务(在其他实体的服务无法直接与另一个实体的存储库通信的情况下,我们使用 SOLID 的依赖关系反转 - 只能通过该实体的服务)。我们在包中创建一个服务 TelegramUserService,它现在有几种方法:保存用户、通过用户 ID 获取用户并显示活动用户列表。首先我们创建 TelegramUserService 接口:
package com.github.javarushcommunity.jrtb.service;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* {@link Service} for handling {@link TelegramUser} entity.
*/
public interface TelegramUserService {
/**
* Save provided {@link TelegramUser} entity.
*
* @param telegramUser provided telegram user.
*/
void save(TelegramUser telegramUser);
/**
* Retrieve all active {@link TelegramUser}.
*
* @return the collection of the active {@link TelegramUser} objects.
*/
List<TelegramUser> retrieveAllActiveUsers();
/**
* Find {@link TelegramUser} by chatId.
*
* @param chatId provided Chat ID
* @return {@link TelegramUser} with provided chat ID or null otherwise.
*/
Optional<TelegramUser> findByChatId(String chatId);
}
事实上,TelegramUserServiceImpl 的实现:
package com.github.javarushcommunity.jrtb.service;
import com.github.javarushcommunity.jrtb.repository.TelegramUserRepository;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* Implementation of {@link TelegramUserService}.
*/
@Service
public class TelegramUserServiceImpl implements TelegramUserService {
private final TelegramUserRepository telegramUserRepository;
@Autowired
public TelegramUserServiceImpl(TelegramUserRepository telegramUserRepository) {
this.telegramUserRepository = telegramUserRepository;
}
@Override
public void save(TelegramUser telegramUser) {
telegramUserRepository.save(telegramUser);
}
@Override
public List<TelegramUser> retrieveAllActiveUsers() {
return telegramUserRepository.findAllByActiveTrue();
}
@Override
public Optional<TelegramUser> findByChatId(String chatId) {
return telegramUserRepository.findById(chatId);
}
}
这里需要注意的是,我们使用Autowired注解对 TelegramuserRepository 对象进行依赖注入(引入类实例) ,并在构造函数上进行依赖注入。您可以对变量执行此操作,但这是 Spring Framework 团队向我们推荐的方法。
添加机器人的统计信息
接下来您需要更新 /start 和 /stop 命令。当使用/start命令时,需要将新用户保存到数据库中,并设置为active=true。并且当有/stop时,更新用户数据:set active = false。让我们修复StartCommand类:package com.github.javarushcommunity.jrtb.command;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import com.github.javarushcommunity.jrtb.service.TelegramUserService;
import org.telegram.telegrambots.meta.api.objects.Update;
/**
* Start {@link Command}.
*/
public class StartCommand implements Command {
private final SendBotMessageService sendBotMessageService;
private final TelegramUserService telegramUserService;
public final static String START_MESSAGE = "Привет. Я Javarush Telegram Bot. Я помогу тебе быть в курсе последних " +
"статей тех авторов, котрые тебе интересны. Я еще маленький и только учусь.";
public StartCommand(SendBotMessageService sendBotMessageService, TelegramUserService telegramUserService) {
this.sendBotMessageService = sendBotMessageService;
this.telegramUserService = telegramUserService;
}
@Override
public void execute(Update update) {
String chatId = update.getMessage().getChatId().toString();
telegramUserService.findByChatId(chatId).ifPresentOrElse(
user -> {
user.setActive(true);
telegramUserService.save(user);
},
() -> {
TelegramUser telegramUser = new TelegramUser();
telegramUser.setActive(true);
telegramUser.setChatId(chatId);
telegramUserService.save(telegramUser);
});
sendBotMessageService.sendMessage(chatId, START_MESSAGE);
}
}
这里我们还将 TelegramuserService 对象传递给构造函数,用它来保存新用户。此外,利用 Java 中的可选功能,可以实现以下逻辑:如果数据库中有用户,我们只需让他处于活动状态,如果没有,我们创建一个新的活动用户。停止命令:
package com.github.javarushcommunity.jrtb.command;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import com.github.javarushcommunity.jrtb.service.TelegramUserService;
import org.telegram.telegrambots.meta.api.objects.Update;
import java.util.Optional;
/**
* Stop {@link Command}.
*/
public class StopCommand implements Command {
private final SendBotMessageService sendBotMessageService;
private final TelegramUserService telegramUserService;
public static final String STOP_MESSAGE = "Деактивировал все ваши подписки \uD83D\uDE1F.";
public StopCommand(SendBotMessageService sendBotMessageService, TelegramUserService telegramUserService) {
this.sendBotMessageService = sendBotMessageService;
this.telegramUserService = telegramUserService;
}
@Override
public void execute(Update update) {
sendBotMessageService.sendMessage(update.getMessage().getChatId().toString(), STOP_MESSAGE);
telegramUserService.findByChatId(update.getMessage().getChatId().toString())
.ifPresent(it -> {
it.setActive(false);
telegramUserService.save(it);
});
}
}
我们以同样的方式将 TelegramServiceTest 传递给 StopCommand。额外的逻辑是这样的:如果我们有一个具有这样聊天ID的用户,我们将其停用,即我们设置active = false。你怎么能亲眼看到这一点?让我们创建一个新命令 /stat,它将显示机器人的统计信息。在此阶段,这些将是可供所有用户使用的简单统计数据。将来,我们将对其进行限制,仅允许管理员访问。统计数据中将包含一项:活跃机器人用户数。为此,请将值STAT("/stat")添加到 CommandName。接下来,创建StatCommand类:
package com.github.javarushcommunity.jrtb.command;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import com.github.javarushcommunity.jrtb.service.TelegramUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.telegram.telegrambots.meta.api.objects.Update;
/**
* Statistics {@link Command}.
*/
public class StatCommand implements Command {
private final TelegramUserService telegramUserService;
private final SendBotMessageService sendBotMessageService;
public final static String STAT_MESSAGE = "Javarush Telegram Bot использует %s человек.";
@Autowired
public StatCommand(SendBotMessageService sendBotMessageService, TelegramUserService telegramUserService) {
this.sendBotMessageService = sendBotMessageService;
this.telegramUserService = telegramUserService;
}
@Override
public void execute(Update update) {
int activeUserCount = telegramUserService.retrieveAllActiveUsers().size();
sendBotMessageService.sendMessage(update.getMessage().getChatId().toString(), String.format(STAT_MESSAGE, activeUserCount));
}
}
这里一切都很简单:我们使用retrieveAllActiveUsers方法 获取所有活动用户的列表,并获取集合的大小。我们现在还需要更新上升的类:CommandContainer和JavarushTelegramBot,以便它们学会传输我们需要的新服务。命令容器:
package com.github.javarushcommunity.jrtb.command;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import com.github.javarushcommunity.jrtb.service.TelegramUserService;
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, TelegramUserService telegramUserService) {
commandMap = ImmutableMap.<String, Command>builder()
.put(START.getCommandName(), new StartCommand(sendBotMessageService, telegramUserService))
.put(STOP.getCommandName(), new StopCommand(sendBotMessageService, telegramUserService))
.put(HELP.getCommandName(), new HelpCommand(sendBotMessageService))
.put(NO.getCommandName(), new NoCommand(sendBotMessageService))
.put(STAT.getCommandName(), new StatCommand(sendBotMessageService, telegramUserService))
.build();
unknownCommand = new UnknownCommand(sendBotMessageService);
}
public Command retrieveCommand(String commandIdentifier) {
return commandMap.getOrDefault(commandIdentifier, unknownCommand);
}
}
在这里,我们向地图添加了一个新命令,并将其传递给 TelegramUserService 构造函数。但在机器人本身中,只有构造函数会改变:
@Autowired
public JavarushTelegramBot(TelegramUserService telegramUserService) {
this.commandContainer = new CommandContainer(new SendBotMessageServiceImpl(this), telegramUserService);
}
现在我们将 TelegramUserService 作为参数传递,并添加 Autowired 注释。这意味着我们将从应用程序上下文接收它。我们还将更新HelpCommand类,以便在描述中出现新的统计命令。
手动测试
让我们从 docker-compose-test.yml 和 JavarushTelegramBotApplication 类中的 main 方法启动数据库。接下来我们写一组命令:- /stat - 我们预计如果数据库为空,则使用此机器人的人数将为零;
- /start - 启动机器人;
- /stat - 现在我们预计该机器人将由 1 人使用;
- /stop - 停止机器人;
- /stat - 我们预计将再次有 0 人使用它。
我们编写和更新测试
由于我们更改了构造函数,因此我们还需要更新测试类。在AbstractCommandTest类中,我们需要再添加一个字段 - TelegramUserService类,三个命令都需要它:protected TelegramUserService telegramUserService = Mockito.mock(TelegramUserService.class);
接下来,我们更新CommandContainer 中的init()方法:
@BeforeEach
public void init() {
SendBotMessageService sendBotMessageService = Mockito.mock(SendBotMessageService.class);
TelegramUserService telegramUserService = Mockito.mock(TelegramUserService.class);
commandContainer = new CommandContainer(sendBotMessageService, telegramUserService);
}
在 StartCommand 中,您需要更新getCommand()方法:
@Override
Command getCommand() {
return new StartCommand(sendBotMessageService, telegramUserService);
}
同样在 StopCommand 中:
@Override
Command getCommand() {
return new StopCommand(sendBotMessageService, telegramUserService);
}
接下来,让我们看看新的测试。让我们为StatCommand创建一个典型的测试:
package com.github.javarushcommunity.jrtb.command;
import static com.github.javarushcommunity.jrtb.command.CommandName.STAT;
import static com.github.javarushcommunity.jrtb.command.StatCommand.STAT_MESSAGE;
public class StatCommandTest extends AbstractCommandTest {
@Override
String getCommandName() {
return STAT.getCommandName();
}
@Override
String getCommandMessage() {
return String.format(STAT_MESSAGE, 0);
}
@Override
Command getCommand() {
return new StatCommand(sendBotMessageService, telegramUserService);
}
}
这很简单。现在我们来谈谈如何测试数据库的使用。我们之前所做的只是单元测试。 集成测试测试应用程序多个部分之间的集成。例如,应用程序和数据库。 这里一切都会更加复杂,因为为了测试我们需要一个已部署的数据库。因此,当我们在本地运行测试时,我们必须从 docker-compose-test.yml 运行数据库。要运行此测试,您需要运行整个 SpringBoot 应用程序。测试类有一个SpringBootTest注释,它将启动应用程序。但这种方法对我们不起作用,因为当应用程序启动时,电报机器人也会启动。但这里有一个矛盾。测试将在我们的机器上本地运行,并通过 GitHub Actions 公开运行。为了使测试在整个应用程序启动时通过,我们必须使用电报机器人上的有效数据运行它们:即通过其名称和令牌......因此,我们有两个选择:
- 因此,将机器人的名称和令牌公开,并希望一切顺利,没有人会使用它并干扰我们。
- 想出另一种办法。
@Sql(scripts = {"/sql/clearDbs.sql", "/sql/telegram_users.sql"})
对于我们来说,它们将位于路径 ./src/test/resources/ + 注释中指定的路径。它们是这样的:
clearDbs.sql:
DELETE FROM tg_user;
telegram_users.sql:
INSERT INTO tg_user VALUES ("123456789", 1);
INSERT INTO tg_user VALUES ("123456788", 1);
INSERT INTO tg_user VALUES ("123456787", 1);
INSERT INTO tg_user VALUES ("123456786", 1);
INSERT INTO tg_user VALUES ("123456785", 1);
INSERT INTO tg_user VALUES ("123456784", 0);
INSERT INTO tg_user VALUES ("123456782", 0);
INSERT INTO tg_user VALUES ("123456781", 0);
这就是我们的 TelegramUserRepositoryIT 测试的结果(如您所见,集成测试的名称会有所不同 - 我们添加 IT,而不是测试):
package com.github.javarushcommunity.jrtb.repository;
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 TelegramUserRepository}.
*/
@ActiveProfiles("test")
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
public class TelegramUserRepositoryIT {
@Autowired
private TelegramUserRepository telegramUserRepository;
@Sql(scripts = {"/sql/clearDbs.sql", "/sql/telegram_users.sql"})
@Test
public void shouldProperlyFindAllActiveUsers() {
//when
List<TelegramUser> users = telegramUserRepository.findAllByActiveTrue();
//then
Assertions.assertEquals(5, users.size());
}
@Sql(scripts = {"/sql/clearDbs.sql"})
@Test
public void shouldProperlySaveTelegramUser() {
//given
TelegramUser telegramUser = new TelegramUser();
telegramUser.setChatId("1234567890");
telegramUser.setActive(false);
telegramUserRepository.save(telegramUser);
//when
Optional<TelegramUser> saved = telegramUserRepository.findById(telegramUser.getChatId());
//then
Assertions.assertTrue(saved.isPresent());
Assertions.assertEquals(telegramUser, saved.get());
}
}
我们编写了测试,但问题出现了:在 GitHub 上启动 CI 流程会发生什么?它不会有数据库。现在实际上只有一个红色版本。为此,我们有 GitHub 操作,可以在其中配置构建的启动。在运行测试之前,您需要添加具有必要设置的数据库启动。事实证明,互联网上的例子并不多,所以我建议你把它保存在某个地方。让我们更新 .github/workflows/maven.yml 文件:
# This workflow will build a Java project with Maven
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
name: Java CI with Maven
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up MySQL
uses: mirromutth/mysql-action@v1.1
with:
mysql version: '5.7'
mysql database: 'dev_jrtb_db'
mysql root password: 'root'
mysql user: 'dev_jrtb_db_user'
mysql password: 'dev_jrtb_db_password'
- name: Set up JDK 1.11
uses: actions/setup-java@v1
with:
java-version: 1.11
- name: Build with Maven
run: mvn -B package --file pom.xml
现在有一个新的Set up MySQL块。在其中,我们将 MySQL 添加到 CI 流程中,同时定义我们需要的变量。现在我们已经添加了我们想要的一切。最后一个阶段是推动更改并确保构建能够通过并变为绿色。
更新文档
让我们在 pom.xml 中将项目版本从 0.3.0-SNAPSHOT 更新为 0.4.0-SNAPSHOT,并添加到 RELEASE_NOTES:## 0.4.0-SNAPSHOT
* JRTB-1: added repository layer.
所有这些之后,我们创建一个提交、推送和拉取请求。最重要的是,我们的构建是绿色的!
有用的链接:
- 我们的电报机器人的存储库
- 拉取请求以及文章中描述的所有更改
- SpringBoot + Flyway文章
- 来自 DockerHub 的MySQL 镜像
- 中:如何使用 Docker Compose 创建 MySql 实例
- Habr:Spring Data Jpa
- 我的电报频道
GO TO FULL VERSION