Hibernate提供多租户功能已经有一段时间了,它与Spring很好地集成,但关于如何实际设置它的信息并不多,所以我认为一个或两个或三个例子可以帮助我们。
已经有一篇很好的博客文章,但它有点过时了,它涵盖了很多作者试图解决的商业问题的具体内容。 这种方法隐藏了一些实际的集成,这将是本文的重点。
不要担心这篇文章中的代码。你可以在这篇博文的末尾找到完整的代码例子的链接。
多租户是什么意思?
想象一下,你建立了一个应用程序。但不同公司的数据应该被干净地分开。
你有不同的选择来实现这一点。 最简单的是多次部署你的应用程序,包括数据库。 虽然在概念上很简单,但一旦你有超过几个租户需要服务,这就是一个管理的噩梦。
Hibernate预计有三种方法可以做到这一点。
-
你可以对你的表进行分区。在这种情况下,分区意味着,除了正常的ID字段外,你的实体还有一个
tenantId,这也是主键的一部分。 -
你可以将不同租户的数据存储在不同的但相同的模式中。
-
或者你可以为每个租户建立一个数据库。
当然,你可以想出不同的方案,最大的客户得到他们的数据库,中等规模的客户得到他们的模式,而所有其他的客户则在分区中结束,但在这些例子中我坚持使用简单的变体。
例子0:没有租户
对于这些例子,我们可以使用一个简单的实体。
@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;
}
}
我们可以通过start.spring.io,让应用程序得到设置,然后我们就可以准备引入租户了。
示例1:分区数据
对于这个例子,我们需要修改实体。 它需要一个特殊的租户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的查询并没有考虑到租户。 在编写多租户应用程序时,你应该注意到这一点。
例2:每个租户的模式
为了将我们的数据分成不同的模式,我们仍然需要前面所示的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;
}
}
租户不再被设置在实体上,因为这个属性甚至不存在。 另外,由于连接控制了数据访问,这种方法甚至在本地查询中也能工作。
例3:每个租户的数据库
最后的变体是每个租户使用一个单独的数据库。 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资源库包含了本文所基于的三种方法的示例项目。