Концепція багатотенантності та варіанти її реалізації
Відкриваючи список мітингів у Google Calendar, редагуючи документ в Microsoft Office 365 або замовляючи щось в інтернет-магазині на Shopify кожен з нас стикався з концепцією багатотенатості, навіть не підозрюючи про це. Вона дозволяє одній інфраструктурі служити багатьом користувачам або «орендарям», зберігаючи при цьому їхні дані окремо та забезпечуючи необхідний рівень безпеки та персоналізації.
Мене звати Костянтин Дементьєв, я Java-розробник у компанії NCube і випускник Mate academy. Сьогодні я хочу ближче познайомити вас з концепцією багатотенатності і розглянути декілька варіантів її реалізації. Також ми розглянемо імплементацію і тестування цього підходу на прикладі Spring Boot-застосунку. Стаття буде корисна як новачкам, так і більш досвідченим розробникам, які прагнуть глибше зрозуміти концепцію багатотенатності і її застосування в розробці.
Огляд концепції
Багатотенантність — концепція, яка дозволяє декільком користувачам спільно використовувати обчислювальні, мережеві ресурси та сховища, ніколи не маючи доступу до даних одне одного. Кожен клієнт (називається тенантом) може отримати індивідуалізовану версію застосунку, але її архітектура та основні функції залишаються незмінними.
Вона набула широкого розповсюдження у хмарних технологіях, де один і той же сервер може обслуговувати багатьох різних клієнтів. Ця модель забезпечує ефективність управління ресурсами та скорочує витрати, оскільки тенанти ділять витрати на інфраструктуру та обслуговування.
Варіанти імплементації
- Спільна база даних, спільна схема. Всі клієнти використовують одну і ту ж базу даних і схему. Дані різних тенантів відокремлюються за допомогою спеціальних ідентифікаторів у таблицях. Цей метод є найбільш економічно ефективним з погляду використання ресурсів, але потребує додаткових зусиль для забезпечення безпеки та ізоляції даних.
- Спільна база даних, окремі схеми. Усі клієнти використовують одну і ту ж базу даних, але кожен тенант має власну схему. Цей підхід дозволяє спростити управління базою даних та знизити вартість обслуговування, при цьому забезпечуючи певний рівень ізоляції даних між різними тенантами.
- Окремі бази даних. Кожен клієнт (тенант) має свою власну базу даних, що забезпечує високий рівень ізоляції та безпеки даних. Це дозволяє клієнтам мати індивідуальні схеми баз даних і оптимізувати їх під власні потреби. Однак цей підхід може вимагати більше ресурсів для управління та обслуговування інфраструктури.
Тепер розгляньмо кожен з цих підходів детально, щоб зрозуміти їх переваги та потенційні виклики, залежно від бізнес-вимог та технічних умов.
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.
Сподіваюсь, це допоможе вам отримати задоволення від роботи! Буду радий вашим зауваженням чи пропозиціям.
13 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів