最近,我在为一个采用Per-Tenant-Per-DB架构的多租户Spring Boot应用程序配置数据库连接。在这种架构中,每个租户都有自己的数据库,而一个应用程序负责管理这些连接。所有租户数据库的模式(schema)保持一致。
在这篇文章中,我将展示如何实现这一目标。让我们开始吧!
首先,我们从start.spring.io创建一个应用程序,并添加以下依赖项:
- 1. spring-boot-starter-web
- 2. spring-boot-starter-data-jpa
- 3. postgresql(用于数据库驱动)
如果你在IDE中打开应用程序并尝试运行它,它会失败。这是因为应用程序由于spring-boot-starter-data-jpa
的存在,会寻找数据库连接。
为了解决这个问题,我们创建一个配置类,其中包含所有数据库连接的详细信息。
@Configuration
public class DBConnectionManager {
private static final String POSTGRES_JDBC_DRIVER = "org.postgresql.Driver";
private static final String USERNAME = "postgres";
private static final String PASSWORD = "postgres";
private DataSource defaultDataSource;
private AbstractRoutingDataSource routingDataSource;
public AbstractRoutingDataSource getRoutingDataSource() {
return routingDataSource;
}
@Bean
public DataSource routingDataSource() {
routingDataSource = new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
return TenantResolver.getCurrentTenant();
}
};
// 合并默认和租户数据源
Map<Object, Object> dataSourceMap = new HashMap<>(tenantDataSources());
routingDataSource.setDefaultTargetDataSource(defaultDataSource());
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.afterPropertiesSet();
return routingDataSource;
}
private DataSource defaultDataSource() {
if (defaultDataSource == null) {
defaultDataSource = DataSourceBuilder.create()
.driverClassName(POSTGRES_JDBC_DRIVER)
.url("jdbc:postgresql://localhost:5432/postgres")
.username(USERNAME)
.password(PASSWORD)
.build();
}
return defaultDataSource;
}
private Map<String, DataSource> tenantDataSources() {
Map<String, DataSource> dataSourceMap = new HashMap<>();
try (Connection connection = defaultDataSource().getConnection()) {
PreparedStatement stmt = connection.prepareStatement("SELECT key, db_url, db_username, db_password FROM tenant");
ResultSet resultSet = stmt.executeQuery();
while (resultSet.next()) {
String tenantKey = resultSet.getString("key");
String dbUrl = resultSet.getString("db_url");
String dbUsername = resultSet.getString("db_username");
String dbPassword = resultSet.getString("db_password");
DataSource tenantDataSource = DataSourceBuilder.create()
.url(dbUrl)
.username(dbUsername)
.password(dbPassword)
.driverClassName(POSTGRES_JDBC_DRIVER)
.build();
try (Connection c = tenantDataSource.getConnection()) {
// 测试连接
} catch (SQLException e) {
e.printStackTrace();
}
dataSourceMap.put(tenantKey, tenantDataSource);
}
} catch (Exception e) {
e.printStackTrace();
}
return dataSourceMap;
}
}
DBConnectionManager
类处理了几个重要任务。让我们逐步分解它。
- 1. 该类定义了两个全局变量:
defaultDataSource
和routingDataSource
。顾名思义,defaultDataSource
保存默认数据库的连接详细信息(稍后会详细介绍),而routingDataSource
包含每个租户数据库的连接详细信息。 - 2. 我们将
routingDataSource
暴露为一个Bean,允许Spring使用它与所有数据库建立连接。 - 3. 两个私有方法
defaultDataSource()
和tenantDataSources()
是辅助方法,分别创建默认数据库连接和租户数据库连接的DataSource
对象。 - 4. Spring利用
AbstractRoutingDataSource
的determineCurrentLookupKey()
方法来选择正确的租户数据库连接。TenantResolver.getCurrentTenant()
方法提供租户键,Spring使用它在routingDataSource
的targetDataSources
映射中定位相应的DataSource
。
在上述几点中,你可能会问:什么是默认数据库?
默认数据库是Spring Boot在应用程序启动时连接的数据库。拥有一个默认数据库是必要的,因为它作为备用连接,如果没有特定租户的连接可用时使用。
默认数据库还存储有关租户的信息,例如租户键、租户名称、数据库URL、用户名、密码等,这些信息存储在一个表中。我将此表称为tenant
。
tenant
表的DDL如下:
CREATE TABLE IF NOT EXISTS tenant
(
id bigint NOT NULL,
name varchar varying",
key character varying",
db_url character varying",
db_username character varying",
db_password character varying",
CONSTRAINT tenant_pkey PRIMARY KEY (id)
)
应该有一个包含
tenant
表的默认数据库。确保在启动应用程序之前,将所有租户信息填充到此表中。这种方法通常用于存储租户信息。
在DBConnectionManager
类的tenantDataSources()
方法中,我们查询默认数据库以检索租户信息,并为每个租户创建DataSource
对象。
例如,让我们在默认数据库中插入两个租户并运行应用程序。此设置将涉及总共三个数据库连接:一个用于默认数据库,两个用于租户数据库。
INSERT INTO tenant(id, name, db_url, db_username, db_password, key)
VALUES (1, 'tenant one', 'jdbc:postgresql://localhost:5433/postgres', 'tenant1user', 'postgres', 'tenant1');
INSERT INTO tenant(id, name, db_url, db_username, db_password, key)
VALUES (2, 'tenant two', 'jdbc:postgresql://localhost:5434/postgres', 'tenant2user', 'postgres', 'tenant2');
为了简单起见,我将密码存储在数据库中。人们通常将其存储在Secret Manager中。
我使用Postgres Docker镜像运行3个Postgres实例。其中一个将作为默认数据库,另外两个作为租户数据库。
docker run -itd -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 --name postgresql postgres
docker run -itd -e POSTGRES_USER="tenant1user" -e POSTGRES_PASSWORD=postgres -p 5433:5432 --name postgresql1 postgres
docker run -itd -e POSTGRES_USER="tenant2user" -e POSTGRES_PASSWORD=postgres -p 5434:5432 --name postgresql2 postgres
如果你对Docker不熟悉,可以阅读这篇博客来入门!
从日志中,我们可以确认应用程序已成功启动!日志还显示每个数据库连接都由其自己的Hikari-CP池管理。
接下来,我们创建一个API来从租户数据库中获取数据。
在继续之前,我们将在应用程序中定义一个实体Person
,并在每个租户数据库中手动创建一个person
表,并预填充一些虚拟数据。
以下是我用于在两个租户数据库中设置person
表的命令:
CREATE TABLE IF NOT EXISTS person
(
id bigint NOT NULL,
name character varying ,
age character varying ,
CONSTRAINT person_pkey PRIMARY KEY (id)
);
--- 租户1的数据
INSERT INTO person(id, name, age) VALUES (1, 'rav', '34');
INSERT INTO person(id, name, age) VALUES (2, 'ron', '23');
--- 租户2的数据
INSERT INTO person(id, name, age) VALUES (1, 'ramesh', '11');
INSERT INTO person(id, name, age) VALUES (2, 'suresh', '39');
在PersonController
中创建一个Get API /persons来获取所有人员。我想你可以做到。如果需要帮助,请参考我的Github。
我们需要通知Spring当前租户,以便它可以建立正确的租户DataSource
连接。为此,我们将创建一个TenantResolver
类。
在AbstractRoutingDataSource
的determineCurrentLookupKey()
方法中,我们将调用TenantResolver
类的getCurrentTenant()
方法。
public class TenantResolver {
private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
public static void setCurrentTenant(String tenantKey) {
currentTenant.set(tenantKey);
}
public static String getCurrentTenant() {
return currentTenant.get();
}
public static void clear() {
currentTenant.remove();
}
}
在下面的代码片段中,我们通过捕获请求将当前租户的键(即tenantKey
)设置在ThreadLocal
中。ThreadLocal
变量将保存当前租户,并且仅对处理此请求的当前线程可见。
@Component
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String tenantKey = httpServletRequest.getHeader("X-Tenant-Key");
if (tenantKey != null) {
TenantResolver.setCurrentTenant(tenantKey); // 只有在头信息存在时才设置租户
}
try {
filterChain.doFilter(servletRequest, servletResponse);
} finally {
TenantResolver.clear();
}
}
}
好了!现在,所有配置都已完成。是时候行动了。
让我们启动应用程序并访问**/persons** API,看看应用程序是否按预期运行。
- 1. 获取租户信息的Curl命令。
curl --location 'localhost:8080/tenants'
--响应
[
{
"id": 1,
"name": "tenant one",
"key": "tenant1",
"db_url": "jdbc:postgresql://localhost:5433/postgres",
"db_username": "tenant1user",
"db_password": "postgres"
},
{
"id": 2,
"name": "tenant two",
"key": "tenant2",
"db_url": "jdbc:postgresql://localhost:5434/postgres",
"db_username": "tenant2user",
"db_password": "postgres"
}
]
- 2. 获取租户1数据库中所有人员的Curl命令应成功运行。
curl --location 'localhost:8080/persons' \
--header 'X-Tenant-Key: tenant1'
响应将是:
[
{
"id": 1,
"name": "rav",
"age": "34"
},
{
"id": 2,
"name": "ron",
"age": "23"
}
]
- 3. 获取租户2数据库中所有人员的Curl命令应成功运行。
curl --location 'localhost:8080/persons' \
--header 'X-Tenant-Key: tenant2'
响应将是:
[
{
"id": 1,
"name": "ramesh",
"age": "11"
},
{
"id": 2,
"name": "suresh",
"age": "39"
}
]
让我们回顾一下流程是如何进行的:
- 1. 我们在请求头中发送租户键。
- 2. 在
TenantFilter
中,我们将租户键设置在TenantResolver
的ThreadLocal
中。 - 3. 在查询数据库之前,Spring调用
AbstractRoutingDataSource
的determineCurrentLookupKey()
方法。 - 4.
TenantResolver
的getCurrentTenant()
方法从ThreadLocal
中检索租户键。 - 5. 使用此租户键,Spring从
routingDataSource
Bean的映射中获取DataSource
并返回预期的数据。
因此,在本文中,我们探讨了如何使用Per DB Per Tenant架构配置多租户Spring Boot应用程序。
如果你觉得这篇文章有帮助,请点赞并分享!欢迎提出建议和反馈。
没有回复内容