領先一步
VMware 提供培訓和認證,助你加速進步。
瞭解更多Hibernate 很久以前就提供了多租戶(Multitenant)功能。它與 Spring 整合得很好,但是關於如何實際設定的資訊不多,所以我認為一個或兩個或三個例子可能會有所幫助。
已經有一篇很棒的部落格文章,但它有點過時,並且涵蓋了很多作者試圖解決的業務問題的具體細節。這種方法隱藏了一點實際的整合細節,而這正是本文的重點。
不用擔心本文中的程式碼。你可以在這篇部落格文章的末尾找到完整程式碼示例的連結。
想象一下你正在構建一個應用程式。你希望自己託管它,並向多家公司提供應用程式提供的服務。但不同公司的資料應該乾淨地分開。
你有很多不同的方法來實現這一點。最簡單的是多次部署你的應用程式,包括資料庫。雖然概念簡單,但一旦你需要服務不止少數幾個租戶,這會成為管理的噩夢。
相反,你想要一個能夠分離資料的應用程式部署。Hibernate 提供了三種方式來實現這一點
你可以對你的表進行分割槽。在這種情況下,分割槽意味著除了正常的 ID 欄位外,你的實體還有一個tenantId
,它也是主鍵的一部分。
你可以將不同租戶的資料儲存在單獨但其他方面相同的 schema 中。
或者你可以為每個租戶設定一個數據庫。
當然,你可以設想不同的方案,比如最大的客戶擁有自己的資料庫,中等規模的客戶擁有自己的 schema,而所有其他客戶則最終在分割槽中,但對於這些例子,我堅持簡單的變體。
對於這些示例,我們可以使用一個簡單的實體
@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 設定應用程式,然後就可以引入租戶了。
對於這個例子,我們需要修改實體。它需要一個特殊的租戶 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 團隊是否同意。
讓我們測試一下所有這些對我們的 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 使用了幾種不同的查詢變體
基於 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 的查詢沒有考慮租戶。在編寫多租戶應用程式時應該注意這一點。
為了將我們的資料分離到不同的 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;
}
}
租戶不再設定在實體上,因為這個屬性根本不存在。此外,由於連線控制資料訪問,這種方法即使使用原生查詢也有效。
最後一個變體是每個租戶使用一個單獨的資料庫。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 中包含了本文基於的所有三種方法的示例專案。