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