Spring Boot中如何实现一个租户一个数据库的多租户应用

最近,我在为一个采用Per-Tenant-Per-DB架构的多租户Spring Boot应用程序配置数据库连接。在这种架构中,每个租户都有自己的数据库,而一个应用程序负责管理这些连接。所有租户数据库的模式(schema)保持一致。

在这篇文章中,我将展示如何实现这一目标。让我们开始吧!

首先,我们从start.spring.io创建一个应用程序,并添加以下依赖项:

  1. 1. spring-boot-starter-web
  2. 2. spring-boot-starter-data-jpa
  3. 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. 1. 该类定义了两个全局变量:defaultDataSourceroutingDataSource。顾名思义,defaultDataSource保存默认数据库的连接详细信息(稍后会详细介绍),而routingDataSource包含每个租户数据库的连接详细信息。
  2. 2. 我们将routingDataSource暴露为一个Bean,允许Spring使用它与所有数据库建立连接。
  3. 3. 两个私有方法defaultDataSource()tenantDataSources()是辅助方法,分别创建默认数据库连接和租户数据库连接的DataSource对象。
  4. 4. Spring利用AbstractRoutingDataSourcedetermineCurrentLookupKey()方法来选择正确的租户数据库连接。TenantResolver.getCurrentTenant()方法提供租户键,Spring使用它在routingDataSourcetargetDataSources映射中定位相应的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类。

AbstractRoutingDataSourcedetermineCurrentLookupKey()方法中,我们将调用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. 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"
    }
]
  1. 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"
    }
]
  1. 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. 1. 我们在请求头中发送租户键。
  2. 2. 在TenantFilter中,我们将租户键设置在TenantResolverThreadLocal中。
  3. 3. 在查询数据库之前,Spring调用AbstractRoutingDataSourcedetermineCurrentLookupKey()方法。
  4. 4. TenantResolvergetCurrentTenant()方法从ThreadLocal中检索租户键。
  5. 5. 使用此租户键,Spring从routingDataSource Bean的映射中获取DataSource并返回预期的数据。

因此,在本文中,我们探讨了如何使用Per DB Per Tenant架构配置多租户Spring Boot应用程序。

如果你觉得这篇文章有帮助,请点赞并分享!欢迎提出建议和反馈。

 

请登录后发表评论

    没有回复内容