Реализация мультиарендности с использованием Spring Boot, MongoDB и Redis

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В этом руководстве мы рассмотрим, как реализовать мультиарендность в Spring Boot приложении с использованием MongoDB и Redis.

Используются:

  • Spring Boot 2.4

  • Maven 3.6. +

  • JAVA 8+

  • Монго 4.4

  • Redis 5

Что такое мультиарендность?

Мультиарендность (англ. multitenancy — «множественная аренда») — это программная архитектура, в которой один экземпляр программного приложения обслуживает нескольких клиентов. Все должно быть общим, за исключением данных разных клиентов, которые должны быть должным образом разделены. Несмотря на то, что они совместно используют ресурсы, арендаторы не знают друг друга, и их данные хранятся совершенно отдельно. Каждый покупатель называется арендатором.

Предложение «программное обеспечение как услуга» (SaaS) является примером мультиарендной архитектуры. Более подробно.

Модели с несколькими арендаторами

Можно выделить три основных архитектурных шаблона для мультиарендной архитектуры (Multitenancy), которые различаются степенью физического разделения данных клиента.

  1. База данных для каждого арендатора : каждый арендатор имеет свою собственную базу данных и изолирован от других арендаторов.

  2. Общая база данных, общая схема: все арендаторы совместно используют базу данных и таблицы. В каждой таблице есть столбец с идентификатором клиента, который показывает владельца строки.

  3. Общая база данных, отдельная схема : все арендаторы совместно используют базу данных, но имеют свои собственные схемы и таблицы базы данных.

Начнем

В этом руководстве мы реализуем мультиарендность на основе базы данных для каждого клиента.

Мы начнем с создания простого проекта Spring Boot на start.spring.io со следующими зависимостями:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-mongodb</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

Определение текущего идентификатора клиента

Идентификатор клиента необходимо определить для каждого клиентского запроса. Для этого мы включим поле идентификатора клиента в заголовок HTTP-запроса. 

Давайте добавим перехватчик, который получает идентификатор клиента из http заголовка X-Tenant.

@Slf4j
@Component
public class TenantInterceptor implements WebRequestInterceptor {
    private static final String TENANT_HEADER = "X-Tenant";
    @Override
    public void preHandle(WebRequest request) {
        String tenantId = request.getHeader(TENANT_HEADER);
        if (tenantId != null && !tenantId.isEmpty()) {
            TenantContext.setTenantId(tenantId);
            log.info("Tenant header get: {}", tenantId);
        } else {
            log.error("Tenant header not found.");
            throw new TenantAliasNotFoundException("Tenant header not found.");
        }
    }
    @Override
    public void postHandle(WebRequest webRequest, ModelMap modelMap) {
        TenantContext.clear();
    }
    @Override
    public void afterCompletion(WebRequest webRequest, Exception e) {
    }
}

TenantContext — это хранилище, содержащее переменную ThreadLocal. ThreadLocal можно рассматривать как область доступа (scope of access), такую ​​как область запроса (request scope) или область сеанса (session scope).

Сохраняя tenantId в ThreadLocal, мы можем быть уверены, что каждый поток имеет свою собственную копию этой переменной и что текущий поток не имеет доступа к другому tenantId:

@Slf4j
public class TenantContext {
    private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
    public static void setTenantId(String tenantId) {
        log.debug("Setting tenantId to " + tenantId);
        CONTEXT.set(tenantId);
    }
    public static String getTenantId() {
        return CONTEXT.get();
    }
    public static void clear() {
        CONTEXT.remove();
    }
}

Настройка источников данных клиента (Tenant Datasources)

В нашей архитектуре у нас есть экземпляр Redis, представляющий базу метаданных (master database), в которой централизована вся информация о базе данных клиента. Таким образом, из каждого предоставленного идентификатора клиента информация о подключении к базе данных извлекается из базы метаданных .

RedisDatasourceService.java — это класс, отвечающий за управление всеми взаимодействиями с базой метаданных .

@Service
public class RedisDatasourceService {

    private final RedisTemplate redisTemplate;
    private final ApplicationProperties applicationProperties;
    private final DataSourceProperties dataSourceProperties;

		public RedisDatasourceService(RedisTemplate redisTemplate, ApplicationProperties applicationProperties, DataSourceProperties dataSourceProperties) {
        this.redisTemplate = redisTemplate;
        this.applicationProperties = applicationProperties;
        this.dataSourceProperties = dataSourceProperties;
    }
    
    /**
     * Save tenant datasource infos
     *
     * @param tenantDatasource data of datasource
     * @return status if true save successfully , false error
     */
    
    public boolean save(TenantDatasource tenantDatasource) {
        try {
            Map ruleHash = new ObjectMapper().convertValue(tenantDatasource, Map.class);
            redisTemplate.opsForHash().put(applicationProperties.getServiceKey(), String.format("%s_%s", applicationProperties.getTenantKey(), tenantDatasource.getAlias()), ruleHash);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
    
    /**
     * Get all of keys
     *
     * @return list of datasource
     */
     
    public List findAll() {
        return redisTemplate.opsForHash().values(applicationProperties.getServiceKey());
    }
    
    /**
     * Get datasource
     *
     * @return map key and datasource infos
     */
     
    public Map<String, TenantDatasource> loadServiceDatasources() {
        List<Map<String, Object>> datasourceConfigList = findAll();
        // Save datasource credentials first time
        // In production mode, this part can be skip
        if (datasourceConfigList.isEmpty()) {
            List<DataSourceProperties.Tenant> tenants = dataSourceProperties.getDatasources();
            tenants.forEach(d -> {
                TenantDatasource tenant = TenantDatasource.builder()
                        .alias(d.getAlias())
                        .database(d.getDatabase())
                        .host(d.getHost())
                        .port(d.getPort())
                        .username(d.getUsername())
                        .password(d.getPassword())
                        .build();
                save(tenant);
            });
        }
        return getDataSourceHashMap();
    }
    
    /**
     * Get all tenant alias
     *
     * @return list of alias
     */
     
    public List<String> getTenantsAlias() {
        // get list all datasource for this microservice
        List<Map<String, Object>> datasourceConfigList = findAll();
        return datasourceConfigList.stream().map(data -> (String) data.get("alias")).collect(Collectors.toList());
    }
    
    /**
     * Fill the data sources list.
     *
     * @return Map<String, TenantDatasource>
     */
     
    private Map<String, TenantDatasource> getDataSourceHashMap() {
        Map<String, TenantDatasource> datasourceMap = new HashMap<>();
        // get list all datasource for this microservice
        List<Map<String, Object>> datasourceConfigList = findAll();
        datasourceConfigList.forEach(data -> datasourceMap.put(String.format("%s_%s", applicationProperties.getTenantKey(), (String) data.get("alias")), new TenantDatasource((String) data.get("alias"), (String) data.get("host"), (int) data.get("port"), (String) data.get("database"), (String) data.get("username"), (String) data.get("password"))));
        return datasourceMap;
    }
}

В этом руководстве мы заполнили информацию о клиенте из yml-файла (tenants.yml). В производственном режиме можно создать конечные точки для сохранения информации о клиенте в базе метаданных.

Чтобы иметь возможность динамически переключаться на подключение к базе данных mongo, мы создаем класс MultiTenantMongoDBFactory, расширяющий класс SimpleMongoClientDatabaseFactory из org.springframework.data.mongodb.core. Он вернет экземплярMongoDatabase, связанный с текущим арендатором.

@Configuration
public class MultiTenantMongoDBFactory extends SimpleMongoClientDatabaseFactory {

		@Autowired
    MongoDataSources mongoDataSources;

		public MultiTenantMongoDBFactory(@Qualifier("getMongoClient") MongoClient mongoClient, String databaseName) {
        super(mongoClient, databaseName);
    }

    @Override
    protected MongoDatabase doGetMongoDatabase(String dbName) {
        return mongoDataSources.mongoDatabaseCurrentTenantResolver();
    }
}

Нам нужно инициализировать конструктор MongoDBFactoryMultiTenant с параметрами по умолчанию ( MongoClient и databaseName).

Это реализует прозрачный механизм для получения текущего клиента. 

@Component
@Slf4j
public class MongoDataSources {

    /**
     * Key: String tenant alias
     * Value: TenantDatasource
     */
    private Map<String, TenantDatasource> tenantClients;

    private final ApplicationProperties applicationProperties;
    private final RedisDatasourceService redisDatasourceService;

    public MongoDataSources(ApplicationProperties applicationProperties, RedisDatasourceService redisDatasourceService) {
        this.applicationProperties = applicationProperties;
        this.redisDatasourceService = redisDatasourceService;
    }

    /**
     * Initialize all mongo datasource
     */
    @PostConstruct
    @Lazy
    public void initTenant() {
        tenantClients = new HashMap<>();
        tenantClients = redisDatasourceService.loadServiceDatasources();
    }

    /**
     * Default Database name for spring initialization. It is used to be injected into the constructor of MultiTenantMongoDBFactory.
     *
     * @return String of default database.
     */
    @Bean
    public String databaseName() {
        return applicationProperties.getDatasourceDefault().getDatabase();
    }

    /**
     * Default Mongo Connection for spring initialization.
     * It is used to be injected into the constructor of MultiTenantMongoDBFactory.
     */
    @Bean
    public MongoClient getMongoClient() {
        MongoCredential credential = MongoCredential.createCredential(applicationProperties.getDatasourceDefault().getUsername(), applicationProperties.getDatasourceDefault().getDatabase(), applicationProperties.getDatasourceDefault().getPassword().toCharArray());
        return MongoClients.create(MongoClientSettings.builder()
                .applyToClusterSettings(builder ->
                        builder.hosts(Collections.singletonList(new ServerAddress(applicationProperties.getDatasourceDefault().getHost(), Integer.parseInt(applicationProperties.getDatasourceDefault().getPort())))))
                .credential(credential)
                .build());
    }

    /**
     * This will get called for each DB operations
     *
     * @return MongoDatabase
     */
    public MongoDatabase mongoDatabaseCurrentTenantResolver() {
        try {
            final String tenantId = TenantContext.getTenantId();

            // Compose tenant alias. (tenantAlias = key + tenantId)
            String tenantAlias = String.format("%s_%s", applicationProperties.getTenantKey(), tenantId);

            return tenantClients.get(tenantAlias).getClient().
                    getDatabase(tenantClients.get(tenantAlias).getDatabase());
        } catch (NullPointerException exception) {
            throw new TenantAliasNotFoundException("Tenant Datasource alias not found.");
        }
    }
73
}

Тест

Давайте создадим CRUD пример с документом Employee.

@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
@Document(collection = "employee")
public class Employee  {

    @Id
    private String id;

    private String firstName;

    private String lastName;

    private String email;
}

Также нам нужно создать классы EmployeeRepository, EmployeeService  и EmployeeController. Для тестирования при запуске приложения мы загружаем фиктивные данные в каждую базу данных клиента.

@Override
public void run(String... args) throws Exception {
    List<String> aliasList = redisDatasourceService.getTenantsAlias();
    if (!aliasList.isEmpty()) {
        //perform actions for each tenant
        aliasList.forEach(alias -> {
            TenantContext.setTenantId(alias);
            employeeRepository.deleteAll();

            Employee employee = Employee.builder()
                    .firstName(alias)
                    .lastName(alias)
                    .email(String.format("%s%s", alias, "@localhost.com" ))
                    .build();
            employeeRepository.save(employee);

            TenantContext.clear();
        });
    }
}

Теперь мы можем запустить наше приложение и протестировать его. 

Итак, мы все сделали. Надеюсь, это руководство поможет вам понять, что такое мультиарендность и как она может быть реализована в Spring Boot проекте с использованием MongoDB и Redis.

Полный исходный код примера можно найти на GitHub.

Источник: https://habr.com/ru/post/544642/


Интересные статьи

Интересные статьи

Наверное многие, из тех, кто увлекается изучением того, как работает компьютер на самом низком уровне читали такие книги как: Таненбаум "Архитектура компьютера" или Харри...
Анализ производительности и настройка — мощный инструмент проверки соответствия производительности для клиентов. Анализ производительности можно применять для проверки узких мест в ...
Ace (Ajax.org Cloud9 Editor) — популярный редактор кода для веб-приложений. У него есть как плюсы, так и минусы. Одно из больших преимуществ библиотеки — возможность использования пользовательс...
Продолжу неспешный разбор реализации базовых типов в CPython, ранее были рассмотрены словари и целые числа. Тем, кто думает, что в их реализации не может быть ничего интересного и хитрого, рекоме...
Привет, Хабравчане. Для тех кто не знает, совсем недавно Рикардо начал новый курс, который посвящен реверсингу и эксплоитингу. Это продолжение предыдущего оригинального авторского курса, котор...