如何在 Spring Boot 应用中集成 Hibernate 的多租户(Multitenant)功能与 Spring Data JPA

工程 | Jens Schauder | July 31, 2022 | ...

Hibernate 很久以前就提供了多租户(Multitenant)功能。它与 Spring 集成得很好,但是关于如何实际设置的信息不多,所以我认为一个或两个或三个例子可能会有所帮助。

已经有一篇很棒的博客文章,但它有点过时,并且涵盖了很多作者试图解决的业务问题的具体细节。这种方法隐藏了一点实际的集成细节,而这正是本文的重点。

不用担心本文中的代码。你可以在这篇博客文章的末尾找到完整代码示例的链接。

多租户(Multitenant)是什么意思?

想象一下你正在构建一个应用程序。你希望自己托管它,并向多家公司提供应用程序提供的服务。但不同公司的数据应该干净地分开。

你有很多不同的方法来实现这一点。最简单的是多次部署你的应用程序,包括数据库。虽然概念简单,但一旦你需要服务不止少数几个租户,这会成为管理的噩梦。

相反,你想要一个能够分离数据的应用程序部署。Hibernate 提供了三种方式来实现这一点

  1. 你可以对你的表进行分区。在这种情况下,分区意味着除了正常的 ID 字段外,你的实体还有一个tenantId,它也是主键的一部分。

  2. 你可以将不同租户的数据存储在单独但其他方面相同的 schema 中。

  3. 或者你可以为每个租户设置一个数据库。

当然,你可以设想不同的方案,比如最大的客户拥有自己的数据库,中等规模的客户拥有自己的 schema,而所有其他客户则最终在分区中,但对于这些例子,我坚持简单的变体。

示例 0: 无租户

对于这些示例,我们可以使用一个简单的实体

@Entity
public class Person {

	@Id
	@GeneratedValue
	private Long id;

	private String name;

	// getter and setter skipped for brevity.
}

由于我们想使用 Spring Data JPA,我们有一个名为Persons 的仓库(repository)

interface Persons extends JpaRepository<Person, Long> {
	static Person named(String name) {
		Person person = new Person();
		person.setName(name);
		return person;
	}
}

我们可以通过 http://start.spring.io 设置应用程序,然后就可以引入租户了。

示例 1: 分区数据

对于这个例子,我们需要修改实体。它需要一个特殊的租户 ID

@Entity
public class Person {

	@TenantId
	private String tenant;

	// the rest of the class is unchanged just as shown above.
}

由于在存储实体时需要设置租户 ID,并在加载实体时将其添加到where 子句中,我们需要一个提供该值的对象。为此,Hibernate 要求实现CurrentTenantIdentifierResolver

一个简单的版本可能看起来像这样

@Component
class TenantIdentifierResolver implements CurrentTenantIdentifierResolver, HibernatePropertiesCustomizer {

	private String currentTenant = "unknown";

	public void setCurrentTenant(String tenant) {
		currentTenant = tenant;
	}

	@Override
	public String resolveCurrentTenantIdentifier() {
		return currentTenant;
	}

	@Override
	public void customize(Map<String, Object> hibernateProperties) {
		hibernateProperties.put(AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, this);
	}

	// empty overrides skipped for brevity
}

我想指出这个实现中的三件事

  1. 它有@Component 注解。这意味着它是一个 bean,并且可以根据你的需求进行注入或注入其他 bean。

  2. 它只为currentTenant 提供了一个简单的值。在实际应用程序中,你可以使用不同的作用域(例如request)或从其他适当作用域的 bean 获取值。

  3. 它通过实现HibernatePropertiesCustomizer 将自身注册到 Hibernate。在我看来,这不应该是必需的。你可以关注这个 Hibernate issue,看看 Hibernate 团队是否同意。

让我们测试一下所有这些对我们的 repository 和实体的行为有什么影响

@SpringBootTest
@TestExecutionListeners(listeners = {DependencyInjectionTestExecutionListener.class})
class ApplicationTests {

	static final String PIVOTAL = "PIVOTAL";
	static final String VMWARE = "VMWARE";

	@Autowired
	Persons persons;

	@Autowired
	TransactionTemplate txTemplate;

	@Autowired
	TenantIdentifierResolver currentTenant;

	@Test
	void saveAndLoadPerson() {

		Person adam = createPerson(PIVOTAL, "Adam");
		Person eve = createPerson(VMWARE, "Eve");

		assertThat(adam.getTenant()).isEqualTo(PIVOTAL);
		assertThat(eve.getTenant()).isEqualTo(VMWARE);

		currentTenant.setCurrentTenant(VMWARE);
		assertThat(persons.findAll()).extracting(Person::getName).containsExactly("Eve");

		currentTenant.setCurrentTenant(PIVOTAL);
		assertThat(persons.findAll()).extracting(Person::getName).containsExactly("Adam");
	}

	private Person createPerson(String schema, String name) {

		currentTenant.setCurrentTenant(schema);

		Person adam = txTemplate.execute(tx ->
				{
					Person person = Persons.named(name);
					return persons.save(person);
				}
		);

		assertThat(adam.getId()).isNotNull();
		return adam;
	}
}

如你所见,尽管我们从未明确设置租户,但 Hibernate 在幕后适当地设置了它。此外,findAll 测试包含了对已设置租户的过滤。但是它对所有查询变体都有效吗?Spring Data JPA 使用了几种不同的查询变体

  1. 基于 Criteria API 的查询。deleteAll 是其中一种情况,所以我们可以认为这种情况已经涵盖。Specifications、Query By Example 和 Query Derivation 都使用相同的。

  2. 一些查询由EntityManager 直接实现——最值得注意的是getById

  3. 如果用户提供了查询,它可能是一个 JPQL 查询。

  4. 一个原生 SQL 查询。

那么让我们测试一下我们测试中尚未涵盖的三种情况

@Test
void findById() {

	Person adam = createPerson(PIVOTAL, "Adam");
	Person vAdam = createPerson(VMWARE, "Adam");

	currentTenant.setCurrentTenant(VMWARE);
	assertThat(persons.findById(vAdam.getId()).get().getTenant()).isEqualTo(VMWARE);
	assertThat(persons.findById(adam.getId())).isEmpty();
}

@Test
void queryJPQL() {

	createPerson(PIVOTAL, "Adam");
	createPerson(VMWARE, "Adam");
	createPerson(VMWARE, "Eve");

	currentTenant.setCurrentTenant(VMWARE);
	assertThat(persons.findJpqlByName("Adam").getTenant()).isEqualTo(VMWARE);

	currentTenant.setCurrentTenant(PIVOTAL);
	assertThat(persons.findJpqlByName("Eve")).isNull();
}

@Test
void querySQL() {

	createPerson(PIVOTAL, "Adam");
	createPerson(VMWARE, "Adam");

	currentTenant.setCurrentTenant(VMWARE);
	assertThatThrownBy(() -> persons.findSqlByName("Adam"))
			.isInstanceOf(IncorrectResultSizeDataAccessException.class);
}

如你所见,JPQL 和EntityManager 都如预期般工作。

不幸的是,基于 SQL 的查询没有考虑租户。在编写多租户应用程序时应该注意这一点。

示例 2: 每个租户一个 Schema

为了将我们的数据分离到不同的 schema 中,我们仍然需要前面展示的CurrentTenantIdentifierResolver 实现。我们将实体恢复到没有租户 ID 的原始状态。现在,租户 ID 不在实体中,而是需要一个额外的基础设施,即MultiTenantConnectionProvider 的实现

@Component
class ExampleConnectionProvider implements MultiTenantConnectionProvider, HibernatePropertiesCustomizer {

	@Autowired
	DataSource dataSource;

	@Override
	public Connection getAnyConnection() throws SQLException {
		return getConnection("PUBLIC");
	}

	@Override
	public void releaseAnyConnection(Connection connection) throws SQLException {
		connection.close();
	}

	@Override
	public Connection getConnection(String schema) throws SQLException {
		Connection connection = dataSource.getConnection();
		connection.setSchema(schema);
		return connection;
	}

	@Override
	public void releaseConnection(String s, Connection connection) throws SQLException {
		connection.setSchema("PUBLIC");
		connection.close();
	}

	@Override
	public void customize(Map<String, Object> hibernateProperties) {
		hibernateProperties.put(AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER, this);
	}

	// empty overrides skipped for brevity
}

它负责提供使用正确 schema 的连接。请注意,我们还需要一种在没有定义租户或 schema 的情况下创建连接的方式,以便在应用程序启动期间访问元数据。再次,我们通过实现HibernatePropertiesCustomizer 注册了 bean。

注意,我们必须为所有数据库 schema 提供 schema 设置。所以我们的schema.sql 现在看起来像这样

create schema if not exists pivotal;
create schema if not exists vmware;

create sequence pivotal.person_seq start with 1 increment by 50;
create table pivotal.person (id bigint not null, name varchar(255), primary key (id));

create sequence vmware.person_seq start with 1 increment by 50;
create table vmware.person (id bigint not null, name varchar(255), primary key (id));

注意,public schema 是自动创建的,并且不包含任何表。

有了这个基础设施,我们可以测试行为。

@SpringBootTest
@TestExecutionListeners(listeners = {DependencyInjectionTestExecutionListener.class})
class ApplicationTests {

	public static final String PIVOTAL = "PIVOTAL";
	public static final String VMWARE = "VMWARE";
	@Autowired
	Persons persons;

	@Autowired
	TransactionTemplate txTemplate;

	@Autowired
	TenantIdentifierResolver currentTenant;

	@Test
	void saveAndLoadPerson() {

		createPerson(PIVOTAL, "Adam");
		createPerson(VMWARE, "Eve");

		currentTenant.setCurrentTenant(VMWARE);
		assertThat(persons.findAll()).extracting(Person::getName).containsExactly("Eve");

		currentTenant.setCurrentTenant(PIVOTAL);
		assertThat(persons.findAll()).extracting(Person::getName).containsExactly("Adam");
	}

	private Person createPerson(String schema, String name) {

		currentTenant.setCurrentTenant(schema);

		Person adam = txTemplate.execute(tx ->
				{
					Person person = Persons.named(name);
					return persons.save(person);
				}
		);

		assertThat(adam.getId()).isNotNull();
		return adam;
	}
}

租户不再设置在实体上,因为这个属性根本不存在。此外,由于连接控制数据访问,这种方法即使使用原生查询也有效。

示例 3: 每个租户一个数据库

最后一个变体是每个租户使用一个单独的数据库。Hibernate 的设置与前一个示例非常相似,但是MultiTenantConnectionProvider 的实现现在必须提供到不同数据库的连接。我决定以 Spring Data 特有的方式来做这件事。

连接提供者不需要做任何事情

@Component
public class NoOpConnectionProvider implements MultiTenantConnectionProvider, HibernatePropertiesCustomizer {

	@Autowired
	DataSource dataSource;

	@Override
	public Connection getAnyConnection() throws SQLException {
		return dataSource.getConnection();
	}

	@Override
	public void releaseAnyConnection(Connection connection) throws SQLException {
		connection.close();
	}

	@Override
	public Connection getConnection(String schema) throws SQLException {
		return dataSource.getConnection();
	}

	@Override
	public void releaseConnection(String s, Connection connection) throws SQLException {
		connection.close();
	}

	@Override
	public void customize(Map<String, Object> hibernateProperties) {
		hibernateProperties.put(AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER, this);
	}

	// empty overrides skipped for brevity
}

相反,主要的繁重工作由AbstractRoutingDataSource 的扩展来完成

@Component
public class TenantRoutingDatasource extends AbstractRoutingDataSource {

	@Autowired
	private TenantIdentifierResolver tenantIdentifierResolver;

	TenantRoutingDatasource() {

		setDefaultTargetDataSource(createEmbeddedDatabase("default"));

		HashMap<Object, Object> targetDataSources = new HashMap<>();
		targetDataSources.put("VMWARE", createEmbeddedDatabase("VMWARE"));
		targetDataSources.put("PIVOTAL", createEmbeddedDatabase("PIVOTAL"));
		setTargetDataSources(targetDataSources);
	}

	@Override
	protected String determineCurrentLookupKey() {
		return tenantIdentifierResolver.resolveCurrentTenantIdentifier();
	}

	private EmbeddedDatabase createEmbeddedDatabase(String name) {

		return new EmbeddedDatabaseBuilder()
				.setType(EmbeddedDatabaseType.H2)
				.setName(name)
				.addScript("manual-schema.sql")
				.build();
	}
}

这种方法即使没有 Hibernate 多租户功能也能工作。通过使用CurrentTenantIdentifierResolver,Hibernate 知道当前的租户。它要求连接提供者提供适当的连接,但连接提供者忽略租户信息,并依赖于AbstractRoutingDataSource 已经切换到正确的实际DataSource

测试看起来和行为与基于 schema 的变体完全相同——此处无需重复。

结论

Hibernate 的多租户功能与 Spring Data JPA 很好地集成。使用分区表时务必避免 SQL 查询。按数据库分离时,你可能使用AbstractRoutingDataSource 来实现不依赖于 Hibernate 的解决方案。

Spring Data Examples Git repository 中包含了本文基于的所有三种方法的示例项目

获取 Spring 电子报

订阅 Spring 电子报,保持连接

订阅

领先一步

VMware 提供培训和认证,助你加速进步。

了解更多

获取支持

Tanzu Spring 在一个简单的订阅中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将到来的活动

查看 Spring 社区中所有即将到来的活动。

查看全部