Всем привет. Напомню: в первой части мы добавляли 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 так же, как и для телеграм-бота — через переменные окружения. Сделал я это для того, чтобы у нас было только одно место, где мы выставляем значения имени пользователя БД и его пароль.
Мы передаем их в докер образ нашего приложения и в докер контейнер нашей базы данных.
Далее нужно обновить 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'
Именно здесь мы задаем имя пользователя БД и его пароль. Конечно, можно было бы эти переменные передавать при запуске баш скрипта, как мы это делаем для имя и токена бота. Но мне кажется, что это излишне. Чтобы реально получить доступ к базе данных, нужно знать IP сервера, на котором будет развернута БД, и быть в списке разрешенных IP-адресов для запроса. Как по мне, этого уже и так достаточно.
Основа заложена: теперь можно заниматься более понятными для разработчика вещами — писать код. До этого мы занимались тем, что делают DevOps инженеры — настраивали окружение.Добавляем Repository слой
Обычно у приложения есть три слоя:- Контроллеры — точки входа в приложение.
- Сервисы — место работы бизнес-логики. Это у нас уже отчасти есть: SendMessageService — явный представитель бизнес-логики.
- Репозитории — место работы с базой данных. В нашем случае это телеграм-бот.
- У нас не будет работы с JDBC: мы будем работать сразу с более высокими абстракциями. То есть, сохранять объекты POJO, которые соответствуют таблицам в базе данных. Такие классы мы будем называть entity, так, как они называются официально в Java Persistence API (это общий набор интерфейсов для работы БД через ORM, то есть абстракция над работой с JDBC). У нас будет класс entity, который мы будем сохранять в БД, и они запишутся именно в ту таблицу, которая нам нужна. Такие же объекты мы будем получать при поиске в базе данных.
- Spring Data предлагает использовать их набор интерфейсов: JpaRepository, CrudRepository, etc... Есть и другие интерфейсы: полный перечень можно найти здесь. Прелесть заключается в том, что можно использовать их методы не реализовывая их(!). Более того, есть определенный шаблон, используя который можно писать в интерфейсе новые методы, и они будут реализованы автоматически.
- 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 — аннотация говорит, какое поле будет Primary Key в таблице;
- Column — определяем имя поля из таблицы.
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 (применяем dependency inversion из SOLID в контексте того, что сервисы других сущностей не могут напрямую общаться с репозиторием другой сущности — только через сервис той сущности). Создаем в пакете service 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);
}
}
Здесь нужно отметить, что мы используем dependency injection (вводим экземпляр класса) объекта TelegramuserRepository при помощи аннотации Autowired, причем на конструкторе. Можно делать это и для переменной, но именно этот подход рекомендует нам Spring Framework команда.Добавляем статистику для бота
Далее нужно обновить команды /start и /stop. Когда используется команда /start, нужно сохранить в БД нового пользователя и поставить ему active = true. А когда будет /stop, обновить данные пользователя: поставить 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, при помощи которого будем сохранять нового пользователя.
Далее, используя прелести Optional в джаве, работает следующая логика: если пользователь в базе у нас есть, просто делаем его активным, если нет — создаем нового активного.
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);
});
}
}
В StopCommand точно так же передаем TelegramServiceTest. Дополнительная логика такая: если у нас есть пользователь с таким chat 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, чтобы они научились передавать нужный нам новый сервис.
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);
}
}
Здесь мы добавили в мапу новую команду и передали через конструктор TelegramUserService.
А вот в самом боте изменится только конструктор:
@Autowired
public JavarushTelegramBot(TelegramUserService telegramUserService) {
this.commandContainer = new CommandContainer(new SendBotMessageServiceImpl(this), telegramUserService);
}
Теперь мы передаем в виде аргумента TelegramUserService, добавляя аннотацию Autowired. Это значит, что ее мы получим из Application Context.
Также обновим класс HelpCommand, чтобы в описании появилась еще и новая команда по статистике.Мануальное тестирование
Запустим базу данных из docker-compose-test.yml и main метод в классе JavarushTelegramBotApplication. Далее пишем набор команд:- /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 (как видите, и имя для интеграционного тестирования будет другое — добавляем не Test, а 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());
}
}
Тесты написали, но возникает вопрос: а что будет с запуском нашего CI процесса на GitHub? У него же не будет базы данных. На данный момент действительно будет просто красный билд.
Для этого у нас есть GitHub actions, в котором мы можем настроить запуск нашего билда. До запуска тестов нужно добавить запуск базы данных с нужными настройками.
Как оказалось, на просторах интернета не так уж и много примеров, поэтому советую сохранить себе где-то это.
Обновим файл .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 процессу, попутно определяя нужные для нас переменные.
Теперь уже все, что хотели, мы добавили. Последний этап — запушить изменения и посмотреть, что билд пройдет и будет зеленый.Обновляем документацию
Обновим версию проекта с 0.3.0-SNAPSHOT на 0.4.0-SNAPSHOT в pom.xml и добавим в RELEASE_NOTES также:
## 0.4.0-SNAPSHOT
* JRTB-1: added repository layer.
После всего этого создаем коммит, пуш и пулл-реквест.
И самое главное — наш билд зеленый!Полезные ссылки:
- Репозиторий нашего телеграм-бота
- Пулл-реквест со всеми изменениями, описанными в статье
- SpringBoot + Flyway статья
- MySQL образ из DockerHub
- Medium: How to Create a MySql Instance with Docker Compose
- Habr: Spring Data Jpa
- Мой телеграм-канал
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ