如何在 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 是其中一種情況,因此我們可以認為這種情況已覆蓋。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:每個租戶一個模式。

要將我們的資料分離到不同的模式中,我們仍然需要前面展示的 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 Examples Git 倉庫 包含了本文所基於的 所有三種方法的示例專案

獲取 Spring 新聞通訊

透過 Spring 新聞通訊保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助您加速進步。

瞭解更多

獲得支援

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案,只需一份簡單的訂閱。

瞭解更多

即將舉行的活動

檢視 Spring 社群所有即將舉行的活動。

檢視所有