領先一步
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 是其中一種情況,因此我們可以認為這種情況已覆蓋。Specifications、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 的原始狀態。租戶 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;
}
}
租戶不再設定在實體上,因為此屬性甚至不存在。此外,由於連線控制著資料訪問,因此這種方法即使在原生查詢中也有效。
最後一個變體是為每個租戶使用單獨的資料庫。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 倉庫 包含了本文所基於的 所有三種方法的示例專案。