领先一步
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 中包含了本文基于的所有三种方法的示例项目。