Hi all. Let me remind you: in the first part we added Flyway. Let's continue.
Adding a database to docker-compose.yml
The next stage is setting up work with the database in the main docker-compose.yml. Let's add the database to the docker-compose file: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'
I also added this line to our application:
depends_on:
- jrtb-db
This means that we wait for the database to start before starting the application. Next, you can notice the addition of two more variables that we need to work with the database:
${BOT_DB_USERNAME}
${BOT_DB_PASSWORD}
We will get them in docker-compose in the same way as for the telegram bot - through environment variables. I did this so that we have only one place where we set the values of the database user name and its password. We pass them to the docker image of our application and to the docker container of our database. Next we need to update the Dockerfile to teach our SpringBoot to accept variables for the database.
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"]
Now we add database variables to the Dockerfile:
ENV BOT_DB_USERNAME=jrtb_db_user
ENV BOT_DB_PASSWORD=jrtb_db_password
The variable values will be different. The ones we will pass into the Dockerfile, however, require default values, so I entered some. We expand the last line with two elements, with the help of which we will pass the DB username and password to the application launch:
"-Dspring.datasource.password=${BOT_DB_PASSWORD}", "-Dbot.username=${BOT_NAME}"
The last line in the Dockerfile (which starts with ENTRYPOINT) must be without wrapping. If you make a transfer, this code will not work. The last step is to update the start.sh file to pass variables to the database.
#!/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
We already know how to add environment variables before running docker-compose. To do this, you just need to execute export var_name=var_value.. Therefore, we add only two lines:
export BOT_DB_USERNAME='prod_jrtb_db_user'
export BOT_DB_PASSWORD='Pap9L9VVUkNYj99GCUCC3mJkb'
This is where we set the database username and password. Of course, it would be possible to pass these variables when running the bash script, as we do for the bot’s name and token. But it seems to me that this is unnecessary. To actually access the database, you need to know the IP of the server on which the database will be deployed, and be on the list of allowed IP addresses for the request. As for me, this is already enough. The foundation has been laid: now you can do things that are more understandable for a developer - write code. Before that, we were doing what DevOps engineers do - setting up the environment.
Adding a Repository layer
Typically an application has three layers:- Controllers are the entry points into the application.
- Services are where business logic works. We already partially have this: SendMessageService is an explicit representative of business logic.
- Repositories are a place to work with a database. In our case, this is a telegram bot.
- We will not have to work with JDBC: we will work directly with higher abstractions. That is, store POJOs that correspond to tables in the database. We will call such classes entity , as they are officially called in the Java Persistence API (this is a common set of interfaces for working with a database through an ORM, that is, an abstraction over working with JDBC). We will have an entity class that we will save in the database, and they will be written to exactly the table that we need. We will receive the same objects when searching in the database.
- Spring Data offers to use their set of interfaces: JpaRepository , CrudRepository , etc... There are other interfaces: a full list can be found here . The beauty is that you can use their methods without implementing them(!). Moreover, there is a certain template using which you can write new methods in the interface, and they will be implemented automatically.
- Spring simplifies our development as much as it can. To do this, we need to create our own interface and inherit from those described above. And so that Spring knows that it needs to use this interface, add the Repository annotation.
- If we need to write a method for working with a database that does not exist, then this is also not a problem - we will write it. I'll show you what and how to do there.
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;
}
Here you can see that we have all the annotations from the javax.persistence package. These are common annotations that are used for all ORM implementations. By default, Spring Data Jpa uses Hibernate, although other implementations can be used. Here is a list of annotations we use:
- Entity - indicates that this is an entity for working with the database;
- Table - here we define the name of the table;
- Id - the annotation says which field will be the Primary Key in the table;
- Column - determine the name of the field from the table.
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();
}
Here you can see how I added the findAllByActiveTrue() method , which I do not implement anywhere. But that won't stop him from working. Spring Data will understand that it needs to get all records from the tg_user table whose active field = true . We add a service for working with the TelegramUser entity (we use dependency inversion from SOLID in the context that services of other entities cannot directly communicate with the repository of another entity - only through the service of that entity). We create a service TelegramUserService in the package, which for now will have several methods: save the user, get the user by his ID and display a list of active users. First we create the TelegramUserService interface:
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);
}
And, in fact, the implementation of 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);
}
}
Here it should be noted that we use dependency injection (introduce a class instance) of the TelegramuserRepository object using the Autowired annotation , and on the constructor. You can do this for a variable, but this is the approach the Spring Framework team recommends to us.
Adding statistics for the bot
Next you need to update the /start and /stop commands. When the /start command is used, you need to save the new user in the database and set it to active = true. And when there is /stop, update the user data: set active = false. Let's fix the StartCommand class :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);
}
}
Here we also pass the TelegramuserService object to the constructor, with which we will save the new user. Further, using the delights of Optional in Java, the following logic works: if we have a user in the database, we simply make him active, if not, we create a new active one. StopCommand:
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);
});
}
}
We pass TelegramServiceTest to StopCommand in the same way. The additional logic is this: if we have a user with such a chat ID, we deactivate it, that is, we set active = false. How can you see this with your own eyes? Let's create a new command /stat, which will display the bot's statistics. At this stage, these will be simple statistics available to all users. In the future, we will limit it and make access only for administrators. There will be one entry in the statistics: the number of active bot users. To do this, add the value STAT("/stat") to CommandName. Next, create the StatCommand class:
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));
}
}
Everything is simple here: we get a list of all active users using the retrieveAllActiveUsers method and get the size of the collection. We also now need to update the ascending classes: CommandContainer and JavarushTelegramBot so that they learn to transfer the new service we need. CommandContainer:
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);
}
}
Here we added a new command to the map and passed it through the TelegramUserService constructor. But in the bot itself, only the constructor will change:
@Autowired
public JavarushTelegramBot(TelegramUserService telegramUserService) {
this.commandContainer = new CommandContainer(new SendBotMessageServiceImpl(this), telegramUserService);
}
Now we pass TelegramUserService as an argument, adding the Autowired annotation. This means that we will receive it from the Application Context. We will also update the HelpCommand class so that a new statistics command appears in the description.
Manual testing
Let's launch the database from docker-compose-test.yml and the main method in the JavarushTelegramBotApplication class. Next we write a set of commands:- /stat - we expect that if the database is empty, there will be zero people using this bot;
- /start - start the bot;
- /stat - now we expect that the bot will be used by 1 person;
- /stop - stop the bot;
- /stat - we expect that again there will be 0 people using it.
We write and update tests
Since we changed the constructors, we will need to update the test classes as well. In the AbstractCommandTest class, we need to add one more field - the TelegramUserService class , which is needed for three commands:protected TelegramUserService telegramUserService = Mockito.mock(TelegramUserService.class);
Next, let’s update the init() method in CommandContainer :
@BeforeEach
public void init() {
SendBotMessageService sendBotMessageService = Mockito.mock(SendBotMessageService.class);
TelegramUserService telegramUserService = Mockito.mock(TelegramUserService.class);
commandContainer = new CommandContainer(sendBotMessageService, telegramUserService);
}
In StartCommand you need to update the getCommand() method:
@Override
Command getCommand() {
return new StartCommand(sendBotMessageService, telegramUserService);
}
Also in StopCommand:
@Override
Command getCommand() {
return new StopCommand(sendBotMessageService, telegramUserService);
}
Next, let's look at the new tests. Let's create a typical test for 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);
}
}
This is simple. Now let's talk about how we will test working with the database. All we did before were unit tests. An integration test tests integration between multiple parts of an application. For example, applications and databases. Here everything will be more complicated, because for testing we need a deployed database. Therefore, when we run our tests locally, we must have the database running from docker-compose-test.yml. To run this test, you need to run the entire SpringBoot application. The test class has a SpringBootTest annotation that will start the application. But this approach will not work for us, because when the application is launched, the telegram bot will also launch. But there is a contradiction here. Tests will be run both locally on our machine and publicly via GitHub Actions. In order for the tests to pass with the launch of the entire application, we must run them with valid data on the telegram bot: that is, by its name and token... Therefore, we have two options:
- So make the name and token of the bot public and hope that everything will be fine, no one will use it and interfere with us.
- Come up with another way.
@Sql(scripts = {"/sql/clearDbs.sql", "/sql/telegram_users.sql"})
For us, they will be located along the path ./src/test/resources/ + the path specified in the annotation. Here's what they look like:
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);
This is what our TelegramUserRepositoryIT test will look like as a result (as you can see, the name for integration testing will be different - we add IT, not Test):
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());
}
}
We wrote the tests, but the question arises: what will happen with the launch of our CI process on GitHub? It won't have a database. For now there will really just be a red build. To do this, we have GitHub actions, in which we can configure the launch of our build. Before running the tests, you need to add a database launch with the necessary settings. As it turns out, there aren’t many examples on the Internet, so I advise you to save this somewhere. Let's update the .github/workflows/maven.yml file:
# 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
Now there is a new Set up MySQL block . In it we add MySQL to our CI process, simultaneously defining the variables we need. Now we have added everything we wanted. The last stage is to push the changes and see that the build will pass and be green.
Updating the documentation
Let's update the project version from 0.3.0-SNAPSHOT to 0.4.0-SNAPSHOT in pom.xml and also add to RELEASE_NOTES:## 0.4.0-SNAPSHOT
* JRTB-1: added repository layer.
After all this, we create a commit, push and pull request. And most importantly, our build is green!
Useful links:
- Repository of our telegram bot
- Pull request with all the changes described in the article
- SpringBoot + Flyway article
- MySQL image from DockerHub
- Medium: How to Create a MySql Instance with Docker Compose
- Habr: Spring Data Jpa
- My telegram channel
GO TO FULL VERSION