抢先一步
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 问题,看看 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
就是一个例子,所以我们可以认为这种情况已经涵盖了。规范、查询示例和查询派生都使用相同的。
某些查询由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 示例 Git 仓库 中包含了本文所基于的 三种方法的示例项目。