Я пытаюсь реализовать мультиарендность весной. Я использую JWT, и у меня есть tenant_id, хранящийся в JWT. Я настроил свой класс MultiTenantDataSourceRouter, расширяющий AbstractRoutingDataSource, чтобы ключ поиска был tenant_id.
@Override
protected Object determineCurrentLookupKey() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication!= null && authentication.getPrincipal() instanceof User user) {
return user.getTenant_id();
}
return null;
}
У меня также есть метод контроллера, который входит в систему пользователя. При входе в систему я хочу создать объект пользователя и сохранить его в базе данных, которая, как предполагается, принадлежит этому арендатору.
public ResponseEntity<AuthenticationResponse> loginUser(AuthenticateRequest authenticateRequest) {
//authenticate the User
authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(authenticateRequest.getEmail(),authenticateRequest.getPassword()));
//this line of code runs oly if authentication is successful
User user = userRepository.findByEmail(authenticateRequest.getEmail()).orElseThrow();
String jwt = jwtService.generateToken(user);
//set the security context to allow routing of db and to know the tenantID and also set the datasource
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(user.getUsername(),null,user.getAuthorities()));
var mds = new MultiTenantDataSourceRouter();
mds.setTargetDataSources(dataSourceConfig.loadAllDataSources());
mds.afterPropertiesSet();
//create an Employee Object for the user once
Employee employee = employeeRepository.findByEmail(user.getEmail());
if (employee!= null){
Employee newEmployee = Employee.builder()
.email(user.getEmail())
.name(user.getName())
.build();
}
return ResponseEntity.ok().body(new AuthenticationResponse(jwt));
}
При запуске приложения у меня загружаются источники данных (dataSourceConfig.loadAllDataSources()). Это компонент, который возвращает карту tenant_id и соответствующие источники данных. Но когда я вызываю этот метод, источник данных по умолчанию, загружаемый в приложение через app.properties, равен где объект сотрудника — это место, где ищется метод findbyEmail(). Я ожидаю, что он будет искать его внутри арендатора, связанного с этим пользователем, но это не так.
Это приводит к ошибке, поскольку в моей базе данных по умолчанию нет таблицы сотрудников.
Я отредактировал TenantRouterClass. Теперь у меня есть класс ниже. Идея состоит в том, что при запуске приложения оно проверяет хранилище арендаторов по умолчанию, которое содержит информацию, используемую для создания других доступных источников данных.
package dev.iyanu.multitenancy.security_config;
import com.zaxxer.hikari.HikariDataSource;
import dev.iyanu.multitenancy.entities.Tenants;
import dev.iyanu.multitenancy.repository.TenantRepository;
import dev.iyanu.multitenancy.users.User;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.security.core.context.SecurityContextHolder;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Configuration
@Slf4j
public class TenantManager {
private final TenantRepository tenantRepository;
private final Map<Object,Object> tenantDataSources = new ConcurrentHashMap<>();
private final DataSource dataSource;
@Getter
private Object currentTenant;
private AbstractRoutingDataSource dataRouter;
public TenantManager(TenantRepository tenantRepository, DataSource dataSource) {
this.tenantRepository = tenantRepository;
this.dataSource = dataSource;
}
//before you do a save or interact with the db call this method
public void setCurrentTenant() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof User user){
this.currentTenant = user.getTenantId();
}
}
@PostConstruct
public DataSource loadAllDataSources(){
dataRouter = new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
return currentTenant;
}
};
List<Tenants> tenantsList = tenantRepository.findAll();
if (!tenantsList.isEmpty()){
for(Tenants tenant : tenantsList){
DataSource tenantDatasource = createDataSource(tenant);
tenantDataSources.put(tenant.getTenantId(),tenantDatasource);
}
}
tenantDataSources.forEach((tenantId, ds)->{
var schemaInitializer = new ResourceDatabasePopulator(new ClassPathResource("schema.sql"));
schemaInitializer.execute((DataSource) ds);
});
dataRouter.setDefaultTargetDataSource(dataSource);
System.out.println("Default DataSource: "+dataSource);
dataRouter.setTargetDataSources(tenantDataSources);
System.out.println("DataSources loaded from my repo: "+tenantDataSources);
dataRouter.afterPropertiesSet();
return dataRouter;
}
public void addTenant(Tenants tenants){
//create the Db for the Client
createDatabase(tenants);
//create datasource for the client
DataSource newDataSource = createDataSource(tenants);
//just to check that the new tenant added already has his datasource active
try(Connection c = newDataSource.getConnection()){
tenantDataSources.put(tenants.getTenantId(),newDataSource);
dataRouter.afterPropertiesSet();
System.out.println("The new tenant datasource is live");
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private DataSource createDataSource(Tenants tenants){
var properties = new DataSourceProperties();
properties.setPassword("root");
properties.setUsername("root");
properties.setDriverClassName("com.mysql.cj.jdbc.Driver");
properties.setUrl("jdbc:mysql://localhost:3306/"+tenants.getDatabaseName());
return properties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
private void createDatabase(Tenants tenants){
try(Connection connection = dataSource.getConnection()){
var databaseName = StringUtils.substringBefore(tenants.getName()," ");
Statement statement = connection.createStatement();
statement.executeUpdate("CREATE DATABASE "+databaseName);
log.info("Database for {} created successfully", tenants.getName());
}catch (SQLException e){
e.printStackTrace();
throw new RuntimeException();
}
}
}
Поэтому, когда пользователь пытается войти в систему, я аутентифицирую его, устанавливаю держателя контекста безопасности и вызываю метод setCurrentTenant() перед вызовом метода Emloyee.findByEmail(email). Я ожидаю, что метод вызовет источник данных, связанный с вызовом, но этого не произошло, он все равно направляется в установленную базу данных по умолчанию.
public ResponseEntity<AuthenticationResponse> loginUser(AuthenticateRequest authenticateRequest) {
//authenticate the User
authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(authenticateRequest.getEmail(),authenticateRequest.getPassword()));
//this line of code runs oly if authentication is successful
User user = userRepository.findByEmail(authenticateRequest.getEmail()).orElseThrow();
String jwt = jwtService.generateToken(user);
//set the security context to allow routing of db and to know the tenantID and also set the datasource
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities()));
tenantManager.setCurrentTenant();
Object currentTenant = tenantManager.getCurrentTenant();
System.out.println(currentTenant);
//create an Employee Object for the user once
Employee employee = employeeRepository.findByEmail(user.getEmail());
if (employee!= null){
Employee newEmployee = Employee.builder()
.email(user.getEmail())
.name(user.getName())
.build();
}
return ResponseEntity.ok().body(new AuthenticationResponse(jwt));
}
Печатаемый currentTenant — это тот, который связан с ожидаемым источником данных, но он по-прежнему не направляется в нужную базу данных и не направляется к целевому источнику данных по умолчанию.




Я считаю, что вы упускаете здесь шаг, где вы указываете своему приложению, какой арендатор ему необходимо использовать для вашего сотрудника. Вы предоставляете своему мультитенантному источнику данных некоторые источники данных с помощью dataSourceConfig.loadAllDataSources(), но вы не указываете ему, какой из них использовать, поэтому он использует источник по умолчанию.
@OmotosoIyanu Действительно, это должно сработать. Не могли бы вы поделиться своим классом MultiTenantDataSourceRouter и конфигурацией application.properties? Мне интересно, почему вы создаете новый экземпляр MultiTenantDataSourceRouter и устанавливаете источники данных в своем контроллере.
@OmotosoIyanu эта ссылка также может помочь. Обратите внимание, что в обеих ссылках, которыми я поделился, есть метод setCurrentTenant() или что-то подобное, который они используют во время выполнения. Это то, чего, я думаю, вам не хватает, если ваша конфигурация в MultiTenantDataSourceRouter верна.
Спасибо вам за ссылки. Я изучаю их, но не хочу использовать ThreadLocal непосредственно на своем этапе, я хочу использовать SecurityContextHolder, чтобы Spring мог управлять потоком, и я мог просто использовать эту информацию для получения результата. Я сообщу, смогу ли я заставить его работать
Я отредактировал код, но результат тот же, я лучше объяснил в посте.
Спасибо за добавленные подробности. Почему вы используете @PostConstruct? можете ли вы попробовать заменить это аннотациями @Bean и @Primary? Это установит ваш мультитенантный источник данных в качестве используемого по умолчанию bean-компонента. Я предполагаю, что вы используете репозиторий JPA, верно? вам также может потребоваться добавить аннотацию @EnableJpaRepositories(basePackages = "com.your.repository") в ваш класс конфигурации.
Я использую @PostConstruct, потому что в моем app.properties настроен источник данных, который будет загружаться как база данных по умолчанию. Если я изменю его на @Bean, это приведет к циклической зависимости, потому что для загрузки этого компонента мне нужно будет выполнить вызов tenantRepository, который также зависит от источника данных компонента. Поэтому я использовал @PostConstruct, поэтому, когда основной источник данных загружается, я могу загрузить другие источники данных. Это связано с тем, что информация об источнике данных для загрузки находится внутри репозитория арендатора TenantRepository
Есть ли причина, по которой вы сохраняете источник данных по умолчанию как отдельный компонент? \ dataRouter.setDefaultTargetDataSource(dataSource) не нужен конкретный компонент для вашего источника данных по умолчанию. Вы можете просто иметь частный метод в своем классе TenantManager, который создает ваш источник данных. Затем просто вызовите его при настройке источника данных по умолчанию dataRouter.setDefaultTargetDataSource(getMyDefaultDatasource())
Поскольку в моем проекте есть другие классы, в которые внедрены репозитории, и они также являются bean-компонентами либо @Config, либо @Service, у меня возникают проблемы с циклическими зависимостями. Это происходит из-за того, что источник данных по умолчанию еще не загружен внутри компонента.
Используют ли другие репозитории разные источники данных? Или они используют стандартный и/или мультитенантный?
Итак, у меня есть один репозиторий, содержащий информацию о мультитенантных источниках данных. Итак, я наконец-то смог решить эту проблему. Ссылка, которую вы разместили, очень помогла. По сути, мне пришлось сначала настроить два разных источника данных при запуске приложения и предоставить конфигурации транзакций и объектных компонентов. Большое спасибо.
Я думал, что ключ определения поиска должен появиться и проверить ключ поиска, который является идентификатором арендатора, а затем направить его к источнику данных, который сопоставляется с этим ключом поиска?