如何在 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 社群中所有即將到來的活動。

檢視全部