如何在 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 issue,看看 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 就是其中一個例子,因此我們可以認為這種情況已涵蓋。規格、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:每個租戶一個結構描述。

為了將我們的資料分離到不同的結構描述中,我們仍然需要先前顯示的 CurrentTenantIdentifierResolver 實作。我們將實體還原到其原始狀態,而沒有租戶 ID。現在,我們需要額外的基礎架構,即 MultiTenantConnectionProvider 的實作,而不是實體中的租戶 ID

@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));

請注意,public 結構描述是自動建立的,並且不包含任何資料表。

有了這個基礎架構,我們可以測試行為。

@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 Examples Git 儲存庫 包含這篇文章所依據的 所有三種方法的範例專案

取得 Spring 電子報

隨時掌握 Spring 電子報的最新資訊

訂閱

領先一步

VMware 提供訓練和認證,以加速您的進展。

瞭解更多

取得支援

Tanzu Spring 在一個簡單的訂閱中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位檔案。

瞭解更多

即將到來的活動

查看 Spring 社群中所有即將到來的活動。

檢視全部