領先一步
VMware 提供訓練和認證,以加速您的進展。
瞭解更多Hibernate 推出 多租戶功能 已經有一段時間了。它與 Spring 整合得很好,但關於如何實際設定它的資訊並不多,所以我認為一兩個或三個範例可能會有所幫助。
已經有一篇很棒的 部落格文章,但它有點過時,而且涵蓋了作者試圖解決的業務問題的許多細節。這種方法稍微隱藏了一些實際的整合,而這將是本文的重點。
不用擔心這篇文章中的程式碼。您可以在這篇部落格文章的末尾找到完整程式碼範例的連結。
假設您建置了一個應用程式。您想要自行託管它,並將該應用程式提供的服務提供給多家公司。但是不同公司的資料應該完全隔離。
您有多種選項可以實現這一點。最簡單的方法是多次部署您的應用程式,包括資料庫。雖然概念上很簡單,但一旦您需要服務的租戶數量超過幾個,管理起來就會變成一場噩夢。
相反地,您會希望有一個應用程式部署可以隔離資料。Hibernate 預期有三種方法可以做到這一點
您可以分割您的資料表。在這種情況下,分割意味著,除了正常的 ID 欄位之外,您的實體還具有一個 tenantId
,它也是主鍵的一部分。
您可以將不同租戶的資料儲存在不同但其他方面相同的結構描述中。
或者您可以為每個租戶建立一個資料庫。
當然,您可以夢想出不同的方案,讓最大的客戶獲得他們的資料庫,中型客戶獲得他們的結構描述,而所有其他客戶最終都落在分割區中,但為了這些範例,我堅持使用簡單的變體。
對於這些範例,我們可以使用單一簡單的實體
@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 取得應用程式設定,然後我們就可以引入租戶了。
對於這個範例,我們需要修改實體。它需要一個特殊的租戶 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
}
我想指出這個實作的三件事
它具有 @Component
註解。這表示它是一個 bean,可以被注入,或者在您的需求要求時注入其他 bean。
它只有一個簡單的 currentTenant
值。在實際應用程式中,您可以使用不同的範圍(例如 request
),或者從其他適當範圍的 bean 中取得值。
它實作 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 使用幾種不同的查詢變體
基於 Criteria API 的查詢。deleteAll
就是其中一個例子,因此我們可以認為這種情況已涵蓋。規格、Query By Example 和 Query Derivation 都使用相同的方法。
有些查詢直接由 EntityManager
實作 — 最值得注意的是 getById
。
如果使用者提供查詢,則它可能是 JPQL 查詢。
原生 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 的查詢沒有將租戶納入考量。在編寫多租戶應用程式時,您應該注意這一點。
為了將我們的資料分離到不同的結構描述中,我們仍然需要先前顯示的 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;
}
}
租戶不再在實體上設定,因為這個屬性甚至不存在。此外,由於連線控制資料存取,因此即使使用原生查詢,這種方法也有效。
最後一種變體為每個租戶使用單獨的資料庫。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 儲存庫 包含這篇文章所依據的 所有三種方法的範例專案。