如何在Spring Boot应用中把Hibernates的多租户功能与Spring Data JPA整合起来

133 阅读7分钟

Hibernate提供多租户功能已经有一段时间了,它与Spring很好地集成,但关于如何实际设置它的信息并不多,所以我认为一个或两个或三个例子可以帮助我们。

已经有一篇很好的博客文章,但它有点过时了,它涵盖了很多作者试图解决的商业问题的具体内容。 这种方法隐藏了一些实际的集成,这将是本文的重点。

不要担心这篇文章中的代码。你可以在这篇博文的末尾找到完整的代码例子的链接。

多租户是什么意思?

想象一下,你建立了一个应用程序。但不同公司的数据应该被干净地分开。

你有不同的选择来实现这一点。 最简单的是多次部署你的应用程序,包括数据库。 虽然在概念上很简单,但一旦你有超过几个租户需要服务,这就是一个管理的噩梦。

Hibernate预计有三种方法可以做到这一点。

  1. 你可以对你的表进行分区。在这种情况下,分区意味着,除了正常的ID字段外,你的实体还有一个tenantId ,这也是主键的一部分。

  2. 你可以将不同租户的数据存储在不同的但相同的模式中。

  3. 或者你可以为每个租户建立一个数据库。

当然,你可以想出不同的方案,最大的客户得到他们的数据库,中等规模的客户得到他们的模式,而所有其他的客户则在分区中结束,但在这些例子中我坚持使用简单的变体。

例子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
}

我想指出这个实现的三点。

  1. 它有一个@Component 注解。这意味着它是一个Bean,可以根据你的要求被注入或得到其他Bean的注入。

  2. 它只有一个简单的值currentTenant 。在实际应用中,你要么使用一个不同的作用域(比如request ),要么从其他适当作用域的Bean那里获得这个值。

  3. 它实现了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使用了一些不同的查询变体。

  1. 基于Criteria API的查询。deleteAll 是一种情况,所以我们可以认为这种情况已经涵盖了。规格,查询实例,和查询衍生都使用了相同的。

  2. 有些查询是由EntityManager 直接实现的--最明显的是getById

  3. 如果用户提供一个查询,它可能是一个JPQL查询。

  4. 一个本地的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资源库包含了本文所基于的三种方法的示例项目