Концепція багатотенантності та варіанти її реалізації

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Відкриваючи список мітингів у Google Calendar, редагуючи документ в Microsoft Office 365 або замовляючи щось в інтернет-магазині на Shopify кожен з нас стикався з концепцією багатотенатості, навіть не підозрюючи про це. Вона дозволяє одній інфраструктурі служити багатьом користувачам або «орендарям», зберігаючи при цьому їхні дані окремо та забезпечуючи необхідний рівень безпеки та персоналізації.

Мене звати Костянтин Дементьєв, я Java-розробник у компанії NCube і випускник Mate academy. Сьогодні я хочу ближче познайомити вас з концепцією багатотенатності і розглянути декілька варіантів її реалізації. Також ми розглянемо імплементацію і тестування цього підходу на прикладі Spring Boot-застосунку. Стаття буде корисна як новачкам, так і більш досвідченим розробникам, які прагнуть глибше зрозуміти концепцію багатотенатності і її застосування в розробці.

Огляд концепції

Багатотенантність — концепція, яка дозволяє декільком користувачам спільно використовувати обчислювальні, мережеві ресурси та сховища, ніколи не маючи доступу до даних одне одного. Кожен клієнт (називається тенантом) може отримати індивідуалізовану версію застосунку, але її архітектура та основні функції залишаються незмінними.

Вона набула широкого розповсюдження у хмарних технологіях, де один і той же сервер може обслуговувати багатьох різних клієнтів. Ця модель забезпечує ефективність управління ресурсами та скорочує витрати, оскільки тенанти ділять витрати на інфраструктуру та обслуговування.

Варіанти імплементації

  1. Спільна база даних, спільна схема. Всі клієнти використовують одну і ту ж базу даних і схему. Дані різних тенантів відокремлюються за допомогою спеціальних ідентифікаторів у таблицях. Цей метод є найбільш економічно ефективним з погляду використання ресурсів, але потребує додаткових зусиль для забезпечення безпеки та ізоляції даних.
  2. Спільна база даних, окремі схеми. Усі клієнти використовують одну і ту ж базу даних, але кожен тенант має власну схему. Цей підхід дозволяє спростити управління базою даних та знизити вартість обслуговування, при цьому забезпечуючи певний рівень ізоляції даних між різними тенантами.
  3. Окремі бази даних. Кожен клієнт (тенант) має свою власну базу даних, що забезпечує високий рівень ізоляції та безпеки даних. Це дозволяє клієнтам мати індивідуальні схеми баз даних і оптимізувати їх під власні потреби. Однак цей підхід може вимагати більше ресурсів для управління та обслуговування інфраструктури.

Тепер розгляньмо кожен з цих підходів детально, щоб зрозуміти їх переваги та потенційні виклики, залежно від бізнес-вимог та технічних умов.

Shared schema

Це найпопулярніший підхід. При ньому дані кількох орендарів (або клієнтів) зберігаються в одній і тій же базі даних і спільно використовують одну схему. Тобто, вся інформація розміщується у спільних таблицях.

Основною особливістю цього підходу є використання спеціального поля (часто називаються «Tenant ID» або «Client ID») у таблицях для ідентифікації даних, що належать різним орендарям. Це дозволяє ефективно відокремлювати й управляти даними кожного орендаря в рамках однієї бази даних.

Переваги:

  • економія ресурсів: загальна схема зменшує кількість необхідних ресурсів, таких як пам’ять і обчислювальні можливості, оскільки всі дані обробляються в одному місці;
  • легкість управління: управління однією базою даних зазвичай простіше, ніж керування множиною окремих баз, що знижує витрати на технічне обслуговування та управління;
  • спрощене оновлення та масштабування: оновлення та масштабування системи спрощуються, оскільки зміни вносяться в одну схему, що одразу ж стосується всіх орендарів.

Недоліки:

  • гірша продуктивність бази даних, оскільки багато даних зберігається в одних і тих же таблицях;
  • проблеми з безпекою та конфіденційністю: ризик злому зростає, оскільки доступ до одного облікового запису може потенційно відкрити доступ до даних інших орендарів. Також у разі помилки в коді або неправильного налаштування, існує ризик того, що дані одного орендаря можуть стати доступні іншому;
  • складнощі з проєктуванням: потрібно дуже уважно проєктувати базу даних, щоб забезпечити, що запити одного орендаря не впливатимуть на продуктивність системи для інших.

Цей підхід використовується у великій кількості CRM-систем і інших застосунків, де вимоги до ізоляції даних не дуже суворі, і коли загальна оптимізація ресурсів та спрощення управління є пріоритетними.

Schema-per-tenant

Використовуючи цей підхід, ми створюємо окремі схеми для кожного орендаря у спільному екземплярі бази даних. Це означає, що кожен орендар має свій власний набір таблиць, які фізично ізольовані від таблиць інших орендарів, але всі ці схеми зберігаються на одному сервері баз даних.

Такий підхід дозволяє досягти вищого рівня безпеки та ізоляції даних, порівняно з моделлю Shared Schema, зберігаючи при цьому деякі переваги спільного використання ресурсів.

Переваги:

  • підвищена безпека та ізоляція: дані кожного орендаря ізольовані на рівні схеми, що знижує ризик витоку або несанкціонованого доступу до даних;
  • гнучкість управління схемами: кожен орендар може мати індивідуалізовану структуру бази даних, що дозволяє адаптувати її під конкретні потреби;
  • легкість масштабування: додавання нових орендарів або масштабування ресурсів для конкретних орендарів може здійснюватися шляхом розширення їхніх власних схем без впливу на інших орендарів.

Недоліки:

  • вищі витрати на обслуговування: керування множиною схем може бути складніше і дорожче у порівнянні з однією спільною схемою;
  • складність розгортання оновлень: оновлення, які потрібно застосувати до всіх орендарів, потрібно виконувати окремо для кожної схеми;
  • потенційно неефективне використання ресурсів: на сервері може бути велика кількість невикористовуваних ресурсів через надлишок виділеного простору для кожної схеми.

Database-per-tenant

Такий підхід забезпечує найкраще розділення даних про орендарів — ми можемо зберігати їх у різних базах даних або навіть використовувати окремі екземпляри БД. Це дозволяє кожному тенанту мати повний контроль над своєю інформацією та налаштуваннями, забезпечуючи високий рівень безпеки та ізоляції.

Оскільки кожен тенант використовує окрему базу даних, це також сприяє гнучкості в адмініструванні та можливості індивідуальної настройки параметрів бази даних.

Переваги:

  • найвищий рівень безпеки та ізоляції: оскільки дані кожного клієнта зберігаються окремо, ризик витоку даних мінімальний;
  • гнучкість налаштувань: можливість сетапити базу даних відповідно до специфічних вимог кожного тенанта без впливу на інших;
  • легке масштабування та розширення: кожен тенант може масштабувати свою базу даних незалежно від інших, що дозволяє найбільш оптимально використовувати ресурси.

Недоліки:

  • вищі витрати: цей підхід є найскладнішим (і відповідно найдовшим) у розробці, у разі використання різних екземплярів бази даних має також найвищу вартість інфраструктури;
  • складність підтримки: велика кількість інстансів баз даних потребуватиме додаткового моніторингу та менеджменту (що також несе більші витрати).

Таким чином, підхід Database-per-tenant може бути оптимальним варіантом для організацій, що високо цінують безпеку та ізоляцію даних, але потрібно враховувати потенційно високі витрати та складності в управлінні такою архітектурою.

Імплементація за допомогою Spring-Boot

Для демонстрації багатотенантного застосунку ми створимо симуляцію медичної лабораторії, де інформація про дослідження для різних компаній буде зберігатися в окремих базах даних (через високі вимоги до безпеки).

Схеми бази даних

Для зберігання інформації про орендарів та користувачів я буду використовувати основний екземпляр бази даних з такою схемою:

А для зберігання даних про кожного орендаря будуть використовуватися бази даних з досить простою схемою:

Update app config

Спершу оновимо конфігурацію нашого застосунку для того, щоб забезпечити можливість динамічного створення бази даних. Ми повинні виконати наступні кроки:

Нам знадобляться деякі конфігурації, які будуть зберігатися у файлі application.yml в теці resources:

# Конфігурація основної бази даних, де зберігатимуться параметри під'єднання
# до таблиць тенантів
spring:
datasource:
    driverClassName: org.postgresql.Driver
    url: jdbc:postgresql://127.0.0.1:5432/demo_lab
    username: demo_lab
    password: your_db_password

Повний список конфігурацій можна переглянути в application.yml.

Creating DB for tenants

Для створення бази даних нам потрібно реалізувати TenantDao:

@Slf4j
public class TenantDao extends AbstractDao {
    @Autowired
    public TenantDao(@Qualifier("mainDataSource") DataSource mainDataSource) {
        super(mainDataSource);
    }
    public List<TenantDbInfoDto> getTenantDbInfo(DatabaseCreationStatus creationStatus) {
        String query = "select id, db_name, user_name, db_password " +
                "from tenants " +
                "where creation_status = :creationStatus";
        MapSqlParameterSource params = new MapSqlParameterSource("creationStatus", creationStatus.getValue());
        return namedParameterJdbcTemplate.query(query, params, (rs, rowNum) -> {
            TenantDbInfoDto dto = new TenantDbInfoDto();
            dto.setId(rs.getLong("id"));
            dto.setDbName(rs.getString("db_name"));
            dto.setUserName(rs.getString("user_name"));
            dto.setDbPassword(rs.getString("db_password"));
            return dto;
        });
    }
    public void createTenantDb(String dbName, String userName, String password) {
        createUserIfMissing(userName, password);
        String createDbQuery = "CREATE DATABASE " + dbName;
        jdbcTemplate.execute(createDbQuery);
        String grantPrivilegesQuery = String.format("GRANT ALL PRIVILEGES ON DATABASE %s TO \"%s\"", dbName, userName);
        jdbcTemplate.execute(grantPrivilegesQuery);
    }
    private void createUserIfMissing(String userName, String password) {
        try {
            String createUserQuery = String.format("""
                DO
                            $do$
                                BEGIN
                                    IF EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '%s') THEN
                                       ALTER USER "%s" WITH PASSWORD '%s';                      ELSE
                                        CREATE USER "%s" WITH CREATEDB CREATEROLE PASSWORD '%s';
                                    END IF;
                                END
                            $do$""", userName, userName, password, userName, password);
            jdbcTemplate.execute(createUserQuery);
        } catch (Exception exception) {
            log.error("Error during user creation : {}", exception.getMessage());
        }
    }
}

Реляційні бази даних потребують схеми, тому після створення бази даних нам потрібно виконати міграції.

Я познайомився з Liquibase ще під час навчання. Це була одна з тем у модулі по роботі з Spring Boot і, як на мене, такі теми для новачків є одними з найцінніших в плані підготовки до реального продакшену.

Для проведення міграцій в основній базі даних та базах даних тенантів додамо LiquibaseService. Приклад такої міграції:

databaseChangeLog:
  -  preConditions:
  -  changeSet:
      id: createTenantsTable
      author:  konstde00
      changes:
        -  createTable:
             columns:
               - column:
                   name: id
                   type: bigint
               - column:
                   name: name
                   type: varchar
               - column:
                   name: db_name
                   type: varchar
               - column:
                   name: user_name
                   type: varchar
               - column:
                   name: db_password
                   type: varchar
               - column:
                   name: creation_status
                   type: varchar
             schemaName: public
             tableName: tenants
  - changeSet:
      id: createTenantsIdSequence
      author: konstde00
      changes:
        - createSequence:
            dataType: bigint
            minValue: 2
            incrementBy: 1
            schemaName: public
            sequenceName: tenants_id_seq

Приклад файлу журналу змін:

databaseChangeLog:
# Тут ми перелінковуємо файли міграцій для таблиць у нашій схемі
  - include:
      file: changelog/Tenants.yml
      relativeToChangelogFile: true
  - include:
      file: changelog/Users.yml
      relativeToChangelogFile: true
  - include:
      file: changelog/UserRoles.yml
      relativeToChangelogFile: true

Після того, як бази даних створені та мають схему, ми можемо почати їх використовувати.

Dynamic DB choosing

Тепер імплементуймо направлення запитів від тенантів до саме їх баз даних. Розглянемо підхід із використанням Spring Data JPA та Spring Data JDBC.

Spring Data JPA

Спочатку необхідно додати ці залежності до pom.xml файлу:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<-- у моєму прикладі використано PostgreSQL, додамо залежність для його драйверу -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

Для керування міграціями основної БД та датасорсів тенентів додамо DataSourceConfigService:

@Service
@FieldDefaults(level = AccessLevel.PRIVATE)
public class DataSourceConfigService {
    @Value("${datasource.main.name}")
    String mainDatasourceName;
    @Value("${datasource.main.username}")
    String mainDatasourceUsername;
    @Value("${datasource.main.password}")
    String mainDatasourcePassword;
    @Value("${datasource.base-url}")
    String datasourceBaseUrl;
    Boolean wasMainDatasourceConfigured = false;
    DataSource mainDataSource;
    final LiquibaseService liquibaseService;
    static Long MAIN_DATASOURCE_ID = 0L;
    public DataSourceConfigService(@Qualifier("mainDataSource") DataSource mainDataSource,
                                   LiquibaseService liquibaseService) {
        this.mainDataSource = mainDataSource;
        this.liquibaseService = liquibaseService;
    }
    public Map<Long, DataSource> configureDataSources() {
        Map<Long, DataSource> dataSources = new HashMap<>();
        if (!wasMainDatasourceConfigured)  {
            liquibaseService.executeMigrationsToMainDatasource(mainDatasourceName,
                    mainDatasourceUsername, mainDatasourcePassword);
            wasMainDatasourceConfigured = true;
        }
        List<TenantDbInfoDto> dtos = new TenantDao(mainDataSource).getTenantDbInfo(CREATED);
        dataSources.put(MAIN_DATASOURCE_ID, mainDataSource);
        for (TenantDbInfoDto dto : dtos) {
            dataSources.put(dto.getId(), configureDataSource(dto));
        }
        return dataSources;
    }
    private DataSource configureDataSource(TenantDbInfoDto dto) {
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setUrl(getUrl(dto));
        dataSource.setUsername(dto.getUserName());
        dataSource.setPassword(dto.getDbPassword());
        return dataSource;
    }
    private String getUrl(TenantDbInfoDto dto) {
        return datasourceBaseUrl + dto.getDbName();
    }
}

Для зберігання того, який тенант наразі вибраний, створимо DataSourceContextHolder:

@Slf4j
@Component
@FieldDefaults(level = PRIVATE, makeFinal = true)
@Scope(value = ConfigurableBeanFactory.SCOPE_SINGLETON)
public class DataSourceContextHolder {
    UserService userService;
    @NonFinal
    static ThreadLocal<Long> currentTenantId = new ThreadLocal<>();
    static Long DEFAULT_TENANT_ID = null;
    public DataSourceContextHolder(@Lazy UserService userService) {
        this.userService = userService;
    }
    public static void setCurrentTenantId(Long tenantId) {
        currentTenantId.set(tenantId);
    }
    public static Long getCurrentTenantId() {
        return currentTenantId.get();
    }
    public void updateTenantContext(HttpServletRequest request) {
        Long tenantId;
        try {
            UserAuthShortDto user = userService.getActualUser(request);
            tenantId = user.getTenantId();
            setCurrentTenantId(tenantId);
        } catch (Exception e) {
            log.error("Exception occurred while 'updateTenantContext' execution: {}", e.getMessage());
           tenantId = DEFAULT_TENANT_ID;
        }
        setCurrentTenantId(tenantId);
    }
}

Щоб надати можливість вибору різних джерел даних, ми повинні додати DataSourceRoutingService:

@Slf4j
@Service(value = "dataSourceRouting")
@FieldDefaults(level = PRIVATE, makeFinal = true)
public class DataSourceRoutingService extends AbstractRoutingDataSource implements SmartInitializingSingleton {
    Map<String, AbstractDaoHolder> daoHolders;
    DataSourceConfigService datasourceConfigService;
    @NonFinal
    @Value("${datasource.main.name}")
    String mainDatasourceName;
    @NonFinal
    @Value("${datasource.main.username}")
    String mainDatasourceUsername;
    @NonFinal
    @Value("${datasource.main.password}")
    String mainDatasourcePassword;
    public DataSourceRoutingService(@Lazy DataSourceConfigService datasourceConfigService,
                                    LiquibaseService liquibaseService,
                                    @Qualifier("mainDataSource") DataSource mainDataSource,
                                    Map<String, AbstractDaoHolder> daoHolders) {
        this.datasourceConfigService = datasourceConfigService;
        liquibaseService.executeMigrationsToMainDatasource(mainDatasourceName,
                mainDatasourceUsername, mainDatasourcePassword);
        Map<Object, Object> dataSourceMap = this.datasourceConfigService
                .configureDataSources()
                .entrySet().stream()
                .collect(HashMap::new, (map, entry) -> map.put(entry.getKey(), entry.getValue()), HashMap::putAll);
        this.setTargetDataSources(dataSourceMap);
        this.setDefaultTargetDataSource(mainDataSource);
        this.daoHolders = daoHolders;
    }
    @Override
    public void afterSingletonsInstantiated() {
        Map<Object, Object> dataSources
                = new HashMap<>(datasourceConfigService.configureDataSources());
        updateResolvedDataSources(dataSources);
        updateDaoTemplateHolders(dataSources);
    }
    @Override
    protected Long determineCurrentLookupKey() {
        return DataSourceContextHolder.getCurrentTenantId();
    }
    public void updateResolvedDataSources(Map<Object, Object> dataSources) {
        setTargetDataSources(dataSources);
        afterPropertiesSet();
    }
    public void updateDaoTemplateHolders(Map<Object, Object> dataSources) {
        daoHolders.forEach((key, value) -> value.addNewTemplates(dataSources));
    }
}

Щоб надати можливість Spring Data JPA використовувати різні бази даних для запитів різних тенантів, необхідно створити власний Entity Manager, використавши власний routing service:

@Configuration
@EnableTransactionManagement
@DependsOn("dataSourceRouting")
public class DataSourceConfig {
    private DataSourceRoutingService dataSourceRouting;
    public DataSourceConfig(DataSourceRoutingService dataSourceRouting) {
        this.dataSourceRouting = dataSourceRouting;
    }
    @Bean
    @Primary
    public DataSource dataSource() {
        return dataSourceRouting;
    }
    @Primary
    @Bean(name="customEntityManager")
    public LocalContainerEntityManagerFactoryBean entityManagerBean(EntityManagerFactoryBuilder builder) {
        return builder.dataSource(dataSource()).packages("com.konstde00.auth", "com.konstde00.commons",
                "com.konstde00.tenant_management", "com.konstde00.lab", "com.konstde00.applicationmodule").build();
    }
    @Bean(name="customEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean customEntityManagerFactoryBean(EntityManagerFactoryBuilder builder) {
        return builder.dataSource(dataSource()).packages("com.konstde00.auth", "com.konstde00.commons",
                "com.konstde00.tenant_management", "com.konstde00.lab", "com.konstde00.applicationmodule").build();
    }
    @Bean(name = "customTransactionManager")
    public JpaTransactionManager transactionManager(
        @Autowired @Qualifier("customEntityManager") LocalContainerEntityManagerFactoryBean customEntityManagerFactoryBean) {
        return new JpaTransactionManager(customEntityManagerFactoryBean.getObject());
    }
}

Також для того, щоб розділяти запити різних тенантів, створимо TenantsRoutingFilter:

@Slf4j
@Order(2) // потрібно оновити порядок фільтра у випадку використання іншої кількості фільтрів
@Component
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class TenantsRoutingFilter extends OncePerRequestFilter {
    DataSourceContextHolder dataSourceContextHolder;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        dataSourceContextHolder.updateTenantContext(request);
        filterChain.doFilter(request, response);
    }
}

У такий спосіб ми налаштували динамічний вибір джерела даних для наших тенантів так, щоб вони мали доступ тільки до своєї бази даних.

Spring Data JDBC

Ідея використання Spring Data JDBC тут досить проста і походить з патерну Strategy: ми будемо оперувати декількома Dao-класами, кожен з яких буде потрібен для доступу до даних у джерелі даних певного орендаря. Ми також реалізуємо Dao-Holders, які будуть використовуватися для роботи з цими Dao-класами кожного разу, коли нам буде потрібен доступ до бази даних конкретного тенанту.

По-перше, додамо наступну залежність:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>

Потім реалізуймо AbstactDao, батьківський клас для всіх інших DAO-класів:

@Slf4j
@Data
@Repository
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PROTECTED, makeFinal = true)
public abstract class AbstractDao {
    JdbcTemplate jdbcTemplate;
    NamedParameterJdbcTemplate namedParameterJdbcTemplate;
    @Autowired
    protected AbstractDao(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
        this.namedParameterJdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
    }
}

Потім створимо ResearchDao, конкретний DAO клас для роботи з дослідженнями:

public class ResearchDao extends AbstractDao {
    protected ResearchDao(DataSource dataSource) {
        super(dataSource);
    }
    public List<ResearchDto> findAll() {
        String query = """
                select id, name, description
                from researches
                """;
        return namedParameterJdbcTemplate.query(query, (rs, rowNum) -> toDto(rs));
    }
    private ResearchDto toDto(ResultSet resultSet) throws SQLException {
        return ResearchDto
                .builder()
                .id(resultSet.getLong("id"))
                .name(resultSet.getString("name"))
                .description(resultSet.getString("description"))
                .build();
    }
}

Після цього нам потрібно створити AbstractDaoHolder та ResearchDaoHolder, де будуть створені методи для роботи з екземплярами ResearchDao:

@Service
@FieldDefaults(level = PROTECTED, makeFinal = true)
@Scope(value = ConfigurableBeanFactory.SCOPE_SINGLETON)
public abstract class AbstractDaoHolder implements SmartInitializingSingleton {
    @NonFinal
    Map<Long, TenantDao> templates;
    public abstract void addNewTemplates(Map<Object, Object> dataSources);
}
I ResearchDaoHolder:
public class ResearchDaoHolder extends AbstractDaoHolder {
    @Override
    public void afterSingletonsInstantiated() {
        templates = new HashMap<>();
    }
    public TenantDao getTemplateByTenantKey(Long tenantKey) {
        return templates.get(tenantKey);
    }
    public void addNewTemplates(Map<Object, Object> dataSources) {
        dataSources.forEach((key, value) -> {
            TenantDao tenantDao = new TenantDao((DataSource) value);
            templates.putIfAbsent((Long) key, tenantDao);
        });
    }
}

Потім нам потрібно оновити DataSourceRoutingService: додати

Map<String, AbstractDaoHolder> daoHolders

поле і метод

public void updateDaoHolders(Map<Object, DataSource> dataSources) {
    daoHolders.forEach((key, value) -> value.addNewTemplates(dataSources));
}

надавши DAO-холдерам спосіб оновлювати список доступних DAO-класів під час рантайму аплікейшену.

Тепер наш застосунок повністю готовий для роботи з багатьма базами даних і підтримує додавання нових без необхідності перезавантажувати застосунок.

Інтеграційне тестування

Тепер розгляньмо інтеграційне тестування нашого застосунку. Для цього нам знадобляться Testcontainers, DbRider, Datasource Proxy, p6spy.

Спершу додамо необхідні залежності в файл pom.xml:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>1.19.7</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependency>
    <groupId>com.github.database-rider</groupId>
    <artifactId>rider-core</artifactId>
    <version>1.42.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>com.github.database-rider</groupId>
    <artifactId>rider-spring</artifactId>
    <version>1.42.0</version>
</dependency>
<dependency>
    <groupId>com.github.gavlyukovskiy</groupId>
    <artifactId>p6spy-spring-boot-starter</artifactId>
    <version>1.9.1</version>
    <scope>test</scope>
</dependency>

Наші тести будуть виглядати наступним чином:

@DBRider(dataSourceBeanName = "tenantDataSource")
public class ResearchControllerTest extends AbstractApiTest {
    @Test
    @DataSet(value = {"datasets/get_all_researches/setup.yaml"})
    @ExpectedDataSet(value = {"datasets/get_all_researches/expected.yaml"})
    public void getAllResearchesTest() throws Exception {
        mockMvc.perform(
                get("/api/v1/researches")
                        .contentType(APPLICATION_JSON)
                        .header(AUTHORIZATION_HEADER, generateBearerToken()))
                .andExpect(status().isOk())
                .andExpect(
                        content().json(jsonReader.read("get_all_researches/response.json")))
                .andDo(print());
    }
}

Використаємо Database Rider для керування наборами даних. Існує багато варіантів конфігурації, залежно від вашого середовища, але у випадку JUnit5 і Spring Boot все, що вам потрібно зробити — це розмістити анотацію @DBRider для вашого тестового класу.

Після цього ви можете розмістити анотацію @DataSet у вашому класі/тестовому методі та використовувати набір даних DbUnit у бажаному форматі (YAML, XML, JSON, CSV, XLS або навіть ваш власний Java-клас). У нашому випадку це буде YAML:

researches:
  - id: '0'
    name: First research name
    description: First research description

Крім того, є можливість вказати дані, які, як очікується, будуть в базі даних після певних дій, що виконуються в тесті. Це можна зробити за допомогою анотації @ExpectedDataSet.

Якщо нам не потрібно перевіряти деякі стовпці (наприклад, автоматично згенеровані ідентифікатори або деякі мітки часу), ми можемо вказати їх у властивості ignoreCols, наприклад, так:

@ExpectedDataSet(
        value = {"datasets/create_research/expected.yaml"},
        ignoreCols = {"id"}
)

Тепер подумаймо, де ми візьмемо базу даних для тестування. Найкраще, щоб тестове і продакшн-середовища були подібні, тому ми будемо використовувати ту ж саму базу даних, що і для клієнтських даних (в нашому випадку PostgreSQL).

У нас є Docker, який може принести нам практично будь-яку зовнішню залежність для тестування. Ми підемо далі і використаємо бібліотеку Testcontainers, яка полегшує запуск докер контейнерів безпосередньо з наших тестів.

Крім того, Testcontainers надає гарні обгортки для багатьох популярних продуктів (включаючи PostgreSQL, MySQL та деякі інші бази даних). Тепер ми можемо створити DatabaseContainerInitializer як кастомний ініціалізатор Spring:

@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class DatabaseContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    public static PostgreSQLContainer postgresMainContainer
            = new PostgreSQLContainer<>("postgres:16.2")
            .withUsername("demo_lab")
            .withPassword("mega_secure_password")
            .withDatabaseName("demo_lab")
            .withReuse(true)
            .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withRestartPolicy(RestartPolicy.alwaysRestart()))
            .withLabel("group", "demo_lab");
    @Override
    public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
        postgresMainContainer.start();
        TestPropertyValues.of(
                "spring.datasource.url=" + postgresMainContainer.getJdbcUrl(),
                "spring.datasource.password=" + postgresMainContainer.getPassword(),
                "spring.datasource.username=" + postgresMainContainer.getUsername(),
                "datasource.main.name=" + postgresMainContainer.getDatabaseName(),
                "datasource.main.password=" + postgresMainContainer.getPassword(),
                "datasource.main.url=" + postgresMainContainer.getJdbcUrl(),
                "datasource.base-url=" + postgresMainContainer.getJdbcUrl()
                        .replace(postgresMainContainer.getDatabaseName(), "")
        ).applyTo(configurableApplicationContext.getEnvironment());
        LiquibaseUtil.executeMigrationsToMainDatasource(DatabaseContainerInitializer.postgresMainContainer);
    }
}

Тут ми визначили потік створення тестової бази даних орендарів і bean — джерело даних орендарів, яке використовується у всіх тестах в анотації @DbRider:

@DBRider(dataSourceBeanName = "tenantDataSource")

Також у деяких випадках може бути вкрай важливо побачити, які SQL-запити були виконані. Для цього ми можемо використовувати проксі джерела даних. У нашому випадку проксі p6spy, який буде записувати запити в журнал, як показано нижче:

Some-data-point  INFO 2702 --- [           main] p6spy                                    : #1672006512538 | took 2ms | statement | connection 23| url jdbc:
postgresql://localhost:55138/?loggerLevel=OFFtenant

insert into researches (name, description, id) values (‘Demo name’, ‘Demo description’, 1);

Достатньо лише додати необхідну залежність, а всі інші конфігураційні речі буде виконано цією бібліотекою.

Висновок

Підсумовуючи, зазначу, що ми створили демонстрацію багатомодульного багатотенантного додатку з підходом «база даних на користувача».

Вихідний код програми та прості інструкції з її запуску доступні в репозиторії на GitHub.

Сподіваюсь, це допоможе вам отримати задоволення від роботи! Буду радий вашим зауваженням чи пропозиціям.

👍ПодобаєтьсяСподобалось7
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Мне кажется одна база но отдельная схема под каждого тенанта рабочая история, но опять же — мы говорим про одну и ту же среду или про разные? Т.е. для nonprod (dev/test/uat, etc.) можно прямо в yaml прописать какую схему использовать. Тогда у нас остаётся одна база и разные схемы. А вот как это реализовать для одной среды — открытый вопрос

Мне кажется одна база но отдельная схема под каждого тенанта рабочая история, но опять же — мы говорим про одну и ту же среду или про разные? Т.е. для nonprod (dev/test/uat, etc.) можно прямо в yaml прописать какую схему использовать. Тогда у нас остаётся одна база и разные схемы. А вот как это реализовать для одной среды — открытый вопрос

а «спільна бд» + «партиції» як спроба «дешевої» оптимізації — таким хтось займався? какіє подводниє камні?

Це треба вирішувати на рівні інфри (наприклад k8s) а не робити таких костилів
Типу розгортувати тенанти оператором, для збору метрик використовувати сайдкар і наприклад агрегувати метрики і алерти з сайдкарів в якусь централізовану систему
БД per tenant менеджити теж можна за допомогою операторів якщо хочете селф хостед

Звісно) Оскільки наша інфраструктура була в AWS, варіант AWS CDK, який спавнить RDS інстанси (з моніторингом i альортам через CloudWatch + SNS) нам підходила трошки краще, але в цілому запропонований Вами підхід теж дуже крутий)
В цілому стаття більше про демонстрацію концепції і одного з можливих реалізацій, а не побудову production-ready рішення))

Із не архітектурних технічних відмінностей між підходами до тенантів в рамках postgresql можна зауважити, що у випадку з різними базами даних (database, не інстанс), кожна зміна бази даних зумовлює нову db сесію на рівні драйвера. В postgresql не передбачається на існуючій сесії перемкнутися на нову бд і почати слати запити до неї, треба заново ініціювати аутентифікацію до бд. TCP & TLS хендшейки також заново будуть проходити. Відповідно цей підхід виключає можливість використання загальних пулів з’єднань. Хіба що pool per tenant.

У випадку тенантів, коли розділяти схемами, то можна перевикористовувати одне і те ж з’єднання, просто завжди задавати відповідний до тенанта search_path перед запитами.

Спільна база даних, спільна схема

Завідома фейл.
1) Ліміти і масштабування БД.
2) Логіка управління тенантами буде на рівні аплікації, що знову ж таки уб’є можливості масштабуватися. Всі леєри системи муситимуть знати про схему.
3) Неможливість апдейта окремих тенантів. Міграція БД має застосовуватись для всіх.

Для проектів з високим навантаженням та необхідністю швидкого масштабування — так, звісно, але цей підхід може бути непоганим варіантом для когось, хто не очікує великого навантаження або необхідності кастомізувати БД під окремих клієнтів, чи як початковий варіант імплементації перед переходом до інших підходів (створити MVP продукту з підходом «Shared schema», і у разі успіху перейти до більш відповідного до нових вимог підходу)

1. Для клиентов с небольшой нагрузкой можно не заморачиваться и развернуть отдельную БД, это не потребует много ресурсов
2. Для клиентов с большой нагрузкой придется заморачиваться и развернуть отдельную БД, т.к. это потребует много ресурсов.

Общая схема с разделеним данных по идентификаторам клиентов значительно ухудшает выполнение запросов, повышает конкуренцию и так далее, и приводит к тому что «простая БД» уже становится «средней» БД как минимум
Разделение по схемам приводит к тому, что нужно как-то разбираться с dbml, с хранимыми процедурами, запросами и так далее

Короче говоря, наверное единственный в среднем (и не среднем) нормальный способ — «каждому клиенту по своей БД»

Это еще и вопросы масштабирования решает, когда внезапно выросшего клиента можно «легко и просто» смигрировать на другой сервер, чего нельзя сделать с общей схемой/своей схемой.

3) Неможливість апдейта окремих тенантів. Міграція БД має застосовуватись для всіх.

Зазвичай з багатотенантністю граються як раз для того щоб не було ніякого «апдейту окремих тенантів».

не було ніякого «апдейту окремих тенантів»

Це не проблема, яку вирішують, а наслідок неправильно вирішеної проблеми.
Мета здешевити ресурси. І найтупіший варіант це моноліт. Тоді так, апдейтиться вся система для всіх.
Якщо у вас мікросервісна архітектура і, наприклад, два регіони, Європа і Азія, ви будете мати два лейбла на кластері emea і apac. Апдейт ви будете робити в нічний час для кожного регіону окремо. Спільна БД при такій архітектурі уже втрачає свій сенс, бо вам треба буде як мінімум два інстанса.

Якщо у вас мікросервісна архітектура і, наприклад, два регіони, Європа і Азія, ви будете мати два лейбла на кластері emea і apac.

Цe нe заважає робити i монолит, окрeмi кластeри пiд рeгiони, чи envirements, чи пiд дужe жирних клiєнтiв свої окрeмi.
Багато сeрвeрiв баз-данних також можна мати i з одним backend iнстансом, хоч для кожного клieнта своя, хоч по 100 малeньких клiєтiв в однiй базi, а вeликi на окрeмих. Звiсно що один бeкeнд нiхто робити нe будe, алe принципово з точки зору коду нiчого нe заважає.

Нещодавно робив це ж саме, лише імплементація вишла дещо інакшою. Це була моя одна з перших задач на Java. Було цікаво. Після того досвіду якось по новому зацінив .NET з його EF Core. До речі бібліотека для роботи з шардами для Java від Microsoft не працює в Docker контейнері github.com/...​elastic-db-tools-for-java, то ж мусили робити свій форк оминаючи maven версію.

Підписатись на коментарі