如何在 Spring Boot 应用程序中将 Hibernate 的多租户功能与 Spring Data JPA 集成

工程 | Jens Schauder | 2022 年 7 月 31 日 | ...

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

已经有了一篇优秀的博文,但它有点过时了,并且涵盖了许多作者试图解决的业务问题的细节。这种方法隐藏了部分实际集成,这将是本文的重点。

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

多租户是什么意思?

假设您构建了一个应用程序。您想自己托管它,并向多家公司提供应用程序提供的服务。但是不同公司的数据应该干净地分离。

您可以选择不同的方法来实现这一点。最简单的方法是多次部署您的应用程序,包括数据库。虽然概念上很简单,但是当您需要服务数十个租户时,这将成为管理的噩梦。

相反,您希望一个应用程序部署来分离数据。Hibernate 预计有三种方法可以做到这一点

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

  2. 您可以在不同的但其他方面相同的模式中存储不同租户的数据。

  3. 或者您每个租户可以拥有一个数据库。

当然,您可以想出不同的方案,其中最大的客户获得他们的数据库,中型客户获得他们的模式,而所有其他客户最终都进入分区,但我坚持使用这些示例中的简单变体。

示例 0:没有租户。

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

@Entity
public class Person {

	@Id
	@GeneratedValue
	private Long id;

	private String name;

	// getter and setter skipped for brevity.
}

由于我们想使用 Spring Data JPA,所以我们有一个名为Persons的存储库

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 问题,看看 Hibernate 团队是否同意。

让我们测试一下所有这些对我们的存储库和实体行为的影响

@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就是一个例子,所以我们可以认为这种情况已经涵盖了。规范、查询示例和查询派生都使用相同的。

  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:每个租户一个模式。

为了将我们的数据分离到不同的模式中,我们仍然需要前面显示的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
}

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

请注意,我们必须为所有数据库模式提供模式设置。因此,我们的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));

请注意,公共模式是自动创建的,不包含任何表。

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

@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

测试的外观和行为与基于模式的变体完全相同——无需在此重复。

结论

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

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

获取 Spring 新闻通讯

关注 Spring 新闻通讯

订阅

抢先一步

VMware 提供培训和认证,助您快速提升技能。

了解更多

获取支持

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

了解更多

即将举行的活动

查看 Spring 社区中所有即将举行的活动。

查看全部