refactor: Integrate Flyway for database migrations and simplify Docker Compose and tenant configurations.
This commit is contained in:
77
backend/README.md
Normal file
77
backend/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Руководство Backend-разработчика Magistr
|
||||
|
||||
Добро пожаловать в проект Magistr! Этот бэкенд построен на **Spring Boot** и имеет сложную **мультитенантную архитектуру**, где одно приложение обслуживает множество независимых университетов, каждый со своей базой данных. В проекте также есть интеграция с Kubernetes для "горячего" управления этими тенантами.
|
||||
|
||||
Здесь описано, как тут всё устроено, чтобы вы ничего не сломали.
|
||||
|
||||
---
|
||||
|
||||
## 1. Архитектура мультитенантности
|
||||
|
||||
Мы используем подход **Separate Database per Tenant** (Отдельная БД для каждого клиента).
|
||||
|
||||
- **Как приложение понимает, к какой базе обращаться?**
|
||||
Все запросы с фронтенда приходят с заголовком `Host` (например, `swsu.zuev.company`).
|
||||
В классе `TenantInterceptor` (находится в `config/tenant/TenantInterceptor.java`) мы перехватываем этот запрос ДО того, как он дойдёт до контроллеров, вытаскиваем поддомен (`swsu`) и сохраняем его в `ThreadLocal` переменную через класс `TenantContext`.
|
||||
|
||||
- **Как переключаются базы данных?**
|
||||
Класс `TenantRoutingDataSource` наследуется от спринговского `AbstractRoutingDataSource`. Перед каждым запросом в базу (любой `findById` или `save` из репозитория) Spring спрашивает этот класс: *"Какой сейчас ключ тенанта?"*. Класс берёт имя из `TenantContext` и переключает коннект на нужную БД на лету.
|
||||
|
||||
> **Важно:** Вся логика переключения абсолютно прозрачна для бизнес-кода. В контроллерах и сервисах вы пишете обычный код (`userRepository.findAll()`), и он сам выполнится в нужной базе.
|
||||
|
||||
---
|
||||
|
||||
## 2. Динамическое управление тенантами (Kubernetes / ConfigMap)
|
||||
|
||||
Бэкенд спроектирован для работы в **Kubernetes с несколькими репликами (replicas: 2+)**.
|
||||
|
||||
Список тенантов не зашит в код:
|
||||
- В K8s он лежит в специальном `ConfigMap`, который монтируется внутрь пода как файл `tenants.json`.
|
||||
- В классе `DatabaseController` находится API для добавления нового тенанта из админки.
|
||||
- Чтобы изменения применились ко **всем подам** без перезагрузки, `DatabaseController` вызывает `ConfigMapUpdater`. Этот класс обращается напрямую к **Kubernetes API** (используя ServiceAccount токен пода) и патчит `ConfigMap`.
|
||||
- В фоне работает планировщик `TenantConfigWatcher` (каждые 30 секунд). Он следит за изменениями `tenants.json` и, если видит нового тенанта, на лету поднимает для него новый `HikariCP` пул соединений и добавляет в маршрутизатор баз данных.
|
||||
|
||||
---
|
||||
|
||||
## 3. Базы данных и Миграции (Flyway)
|
||||
|
||||
Мы **НЕ используем** автоматическую генерацию таблиц через Hibernate (`spring.jpa.hibernate.ddl-auto=none`).
|
||||
Структурой баз данных правит **Flyway**.
|
||||
|
||||
Поскольку баз данных много (они создаются динамически), стандартный Spring Boot Flyway отключён. Вместо этого `TenantConfigWatcher` вызывает Flyway **программно** в момент первого подключения нового тенанта.
|
||||
|
||||
### 🛑 ПРАВИЛА ИЗМЕНЕНИЯ СТРУКТУРЫ БД:
|
||||
|
||||
Если вам нужно добавить новую таблицу, колонку или изменить тип поля:
|
||||
|
||||
1. **Запрещено трогать старые файлы миграций!**
|
||||
Запомните: файл `V1__init.sql` (и любые другие V-файлы, которые уже попали в коммит) — **СВЯЩЕНЕН**. Если вы его измените, бэкенд не запустится на сервере с ошибкой `Migration checksum mismatch`.
|
||||
|
||||
2. **Как правильно добавить таблицу?**
|
||||
- Зайдите в папку `src/main/resources/db/migration/`.
|
||||
- Создайте новый файл. Название **строго** по формату: `V<Номер>__<Описание>.sql`. Например: `V2__add_student_rating_table.sql`.
|
||||
- Напишите в нём ваш SQL (`CREATE TABLE ...`, `ALTER TABLE ...`).
|
||||
- Сохраните и запустите проект. Flyway **сам** пройдёт по всем базам данных тенантов и накатит этот скрипт.
|
||||
|
||||
3. **Что если локально я накосячил в V2?**
|
||||
Пока файл `V2_...` не залит в Git и крутится только у вас на локалке, вы можете его переписывать. Но для этого вам нужно зайти в вашу локальную БД (через DBeaver/pgAdmin), вручную откатить свои кривые изменения (удалить таблицу) и **удалить запись из истории Flyway**:
|
||||
`DELETE FROM flyway_schema_history WHERE version = '2';`
|
||||
Либо, что проще: удалите контейнер с локальной БД (`docker compose down -v`) и поднимите заново пустую.
|
||||
|
||||
---
|
||||
|
||||
## 4. Как запускать проект локально
|
||||
|
||||
В корневой папке репозитория (где лежит `docker-compose.yaml`) поднимите инфраструктуру:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Соберется и запустится:
|
||||
- Фронтенд
|
||||
- Бэкенд
|
||||
- Ваша локальная тестовая PostgreSQL-база данных (на порту 5432, имя базы `app_db`, юзер `myuser`, логин/пароль см. в compose файле).
|
||||
|
||||
Файл `backend/tenants.json` нужен для локальной разработки. Если вы запускаете бэкенд в Docker Compose, вы можете указать URL `jdbc:postgresql://db:5432/app_db` (где `db` — имя контейнера в compose сети).
|
||||
Либо, если вы тестируете взаимодействие бэкенда с вашим текущим IP-адресом (например, `192.168.1.87`), вы можете использовать этот IP. Оба варианта рабочие! Проект сразу подхватит настройки и накатит таблицы через Flyway.
|
||||
|
||||
Контроллеры и бизнес-логику пишите как в обычном Spring Boot проекте. Главное, помните: у каждого тенанта — своё изолированное хранилище!
|
||||
@@ -32,6 +32,12 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Flyway Database Migrations -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
|
||||
@@ -2,10 +2,11 @@ package com.magistr.app;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
|
||||
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class})
|
||||
@EnableScheduling
|
||||
public class Application {
|
||||
|
||||
|
||||
0
backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
Normal file → Executable file
0
backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
Normal file → Executable file
51
backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
Normal file → Executable file
51
backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
Normal file → Executable file
@@ -120,46 +120,37 @@ public class TenantConfigWatcher {
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует БД нового тенанта: проверяет наличие таблиц,
|
||||
* если нет — выполняет init.sql.
|
||||
* Выполняет миграции Flyway для конкретного тенанта пи подключении.
|
||||
* Если БД уже существует, но история Flyway пуста —
|
||||
* делает baseline (считает V1_init.sql уже выполненным).
|
||||
*/
|
||||
public void initDatabaseForTenant(TenantConfig tenant) {
|
||||
String domain = tenant.getDomain();
|
||||
try {
|
||||
TenantContext.setCurrentTenant(domain);
|
||||
|
||||
if (needsInit()) {
|
||||
log.info("[{}] Tables not found — executing init.sql...", domain);
|
||||
executeInitSql();
|
||||
log.info("[{}] init.sql executed successfully", domain);
|
||||
} else {
|
||||
log.info("[{}] Tables already exist, skipping init", domain);
|
||||
log.info("[{}] Starting Flyway migrations...", domain);
|
||||
|
||||
// Получаем DataSource конкретно для этого тенанта
|
||||
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
|
||||
if (tenantDs == null) {
|
||||
// Если ещё не resolve'нулся (первый запуск), берём обёртку
|
||||
tenantDs = dataSource;
|
||||
}
|
||||
|
||||
org.flywaydb.core.Flyway flyway = org.flywaydb.core.Flyway.configure()
|
||||
.dataSource(tenantDs)
|
||||
.baselineOnMigrate(true)
|
||||
.baselineVersion("1")
|
||||
.load();
|
||||
|
||||
flyway.migrate();
|
||||
log.info("[{}] Flyway migrations completed successfully", domain);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] DB init failed: {}", domain, e.getMessage());
|
||||
log.error("[{}] Flyway migration failed: {}", domain, e.getMessage());
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean needsInit() {
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
ResultSet rs = conn.getMetaData().getTables(null, null, "users", new String[]{"TABLE"})) {
|
||||
return !rs.next();
|
||||
} catch (Exception e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void executeInitSql() throws Exception {
|
||||
String sql;
|
||||
try (InputStream is = new ClassPathResource("init.sql").getInputStream();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
|
||||
sql = reader.lines().collect(Collectors.joining("\n"));
|
||||
}
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
Statement stmt = conn.createStatement()) {
|
||||
stmt.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,12 +101,12 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
|
||||
em.setPackagesToScan("com.magistr.app.model");
|
||||
|
||||
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
|
||||
vendorAdapter.setGenerateDdl(true);
|
||||
vendorAdapter.setGenerateDdl(false);
|
||||
vendorAdapter.setDatabasePlatform("org.hibernate.dialect.PostgreSQLDialect");
|
||||
em.setJpaVendorAdapter(vendorAdapter);
|
||||
|
||||
Map<String, Object> props = new HashMap<>();
|
||||
props.put("hibernate.hbm2ddl.auto", "update");
|
||||
props.put("hibernate.hbm2ddl.auto", "none");
|
||||
props.put("hibernate.show_sql", "false");
|
||||
em.setJpaPropertyMap(props);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
"name": "Default (dev)",
|
||||
"domain": "default",
|
||||
"url": "jdbc:postgresql://192.168.1.87:5432/app_db",
|
||||
"url": "jdbc:postgresql://db:5432/app_db",
|
||||
"username": "myuser",
|
||||
"password": "supersecretpassword"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user