Skip to content

Commit

Permalink
Merge pull request #8545 from michael-schnell/master
Browse files Browse the repository at this point in the history
Hibernate ORM Multitenancy
  • Loading branch information
Sanne authored May 17, 2020
2 parents 6f3f416 + 24b81ab commit 8752232
Show file tree
Hide file tree
Showing 27 changed files with 1,586 additions and 23 deletions.
213 changes: 213 additions & 0 deletions docs/src/main/asciidoc/hibernate-orm.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -542,3 +542,216 @@ Datasource configuration is extremely simple, but is covered in a different guid
it's implemented by the Agroal connection pool extension for Quarkus.

Jump over to link:datasource[Quarkus - Datasources] for all details.

== Multitenancy

"The term multitenancy, in general, is applied to software development to indicate an architecture in which a single running instance of an application simultaneously serves multiple clients (tenants). This is highly common in SaaS solutions. Isolating information (data, customizations, etc.) pertaining to the various tenants is a particular challenge in these systems. This includes the data owned by each tenant stored in the database" (link:https://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#multitenacy[Hibernate User Guide]).

Quarkus currently supports the link:https://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#multitenacy-separate-database[separate database] and the link:https://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#multitenacy-separate-schema[separate schema] approach.

=== Writing the application

Let's start by implementing the `/{tenant}` endpoint. As you can see from the source code below it is just a regular JAX-RS resource:

[source,java]
----
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.persistence.EntityManager;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
@ApplicationScoped
@Produces("application/json")
@Consumes("application/json")
@Path("/{tenant}")
public class FruitResource {
@Inject
EntityManager entityManager;
@GET
@Path("fruits")
public Fruit[] getFruits() {
return entityManager.createNamedQuery("Fruits.findAll", Fruit.class)
.getResultList().toArray(new Fruit[0]);
}
}
----

In order to resolve the tenant from incoming requests and map it to a specific tenant configuration, you need to create an implementation for the `io.quarkus.hibernate.orm.runtime.tenant.TenantResolver` interface.

[source,java]
----
import javax.enterprise.context.ApplicationScoped;
import io.quarkus.arc.Arc;
import io.quarkus.arc.Unremovable;
import io.quarkus.hibernate.orm.runtime.tenant.TenantResolver;
import io.vertx.ext.web.RoutingContext;
@@RequestScoped
@Unremovable
public class CustomTenantResolver implements TenantResolver {
@Inject
RoutingContext context;
@Override
public String getDefaultTenantId() {
return "base";
}
@Override
public String resolveTenantId() {
String path = context.request().path();
String[] parts = path.split("/");
if (parts.length == 0) {
// resolve to default tenant config
return getDefaultTenantId();
}
return parts[1];
}
}
----

From the implementation above, tenants are resolved from the request path so that in case no tenant could be inferred, the default tenant identifier is returned.

=== Configuring the application

In general it is not possible to use the Hibernate ORM database generation feature in conjunction with a multitenancy setup.
Therefore you have to disable it and you need to make sure that the tables are created per schema.
The following setup will use the link:https://quarkus.io/guides/flyway[Flyway] extension to achieve this goal.

==== SCHEMA approach

The same data source will be used for all tenants and a schema has to be created for every tenant inside that data source.
CAUTION: Some databases like MariaDB/MySQL do not support database schemas. In these cases you have to use the DATABASE approach below.

[source,properties]
----
# Disable generation
quarkus.hibernate-orm.database.generation=none
# Enable SCHEMA approach and use default schema
quarkus.hibernate-orm.multitenant=SCHEMA
# You could use a non-default schema by using the following setting
# quarkus.hibernate-orm.multitenant-schema-datasource=other
# The default data source used for all tenant schemas
quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=quarkus_test
quarkus.datasource.password=quarkus_test
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/quarkus_test
# Enable Flyway configuration to create schemas
quarkus.flyway.schemas=base,mycompany
quarkus.flyway.locations=classpath:schema
quarkus.flyway.migrate-at-start=true
----

Here is an example of the Flyway SQL (`V1.0.0__create_fruits.sql`) to be created in the configured folder `src/main/resources/schema`.

[source,sql]
----
CREATE SEQUENCE base.known_fruits_id_seq;
SELECT setval('base."known_fruits_id_seq"', 3);
CREATE TABLE base.known_fruits
(
id INT,
name VARCHAR(40)
);
INSERT INTO base.known_fruits(id, name) VALUES (1, 'Cherry');
INSERT INTO base.known_fruits(id, name) VALUES (2, 'Apple');
INSERT INTO base.known_fruits(id, name) VALUES (3, 'Banana');
CREATE SEQUENCE mycompany.known_fruits_id_seq;
SELECT setval('mycompany."known_fruits_id_seq"', 3);
CREATE TABLE mycompany.known_fruits
(
id INT,
name VARCHAR(40)
);
INSERT INTO mycompany.known_fruits(id, name) VALUES (1, 'Avocado');
INSERT INTO mycompany.known_fruits(id, name) VALUES (2, 'Apricots');
INSERT INTO mycompany.known_fruits(id, name) VALUES (3, 'Blackberries');
----



==== DATABASE approach

For every tenant you need to create a named data source with the same identifier that is returned by the `TenantResolver`.

[source,properties]
----
# Disable generation
quarkus.hibernate-orm.database.generation=none
# Enable DATABASE approach
quarkus.hibernate-orm.multitenant=DATABASE
# Default tenant 'base'
quarkus.datasource.base.db-kind=postgresql
quarkus.datasource.base.username=quarkus_test
quarkus.datasource.base.password=quarkus_test
quarkus.datasource.base.jdbc.url=jdbc:postgresql://localhost:5432/quarkus_test
# Tenant 'mycompany'
quarkus.datasource.mycompany.db-kind=postgresql
quarkus.datasource.mycompany.username=mycompany
quarkus.datasource.mycompany.password=mycompany
quarkus.datasource.mycompany.jdbc.url=jdbc:postgresql://localhost:5433/mycompany
# Flyway configuration for the default datasource
quarkus.flyway.locations=classpath:database/default
quarkus.flyway.migrate-at-start=true
# Flyway configuration for the mycompany datasource
quarkus.flyway.mycompany.locations=classpath:database/mycompany
quarkus.flyway.mycompany.migrate-at-start=true
----

Following are examples of the Flyway SQL files to be created in the configured folder `src/main/resources/database`.

Default schema (`src/main/resources/database/default/V1.0.0__create_fruits.sql`):

[source,sql]
----
CREATE SEQUENCE known_fruits_id_seq;
SELECT setval('known_fruits_id_seq', 3);
CREATE TABLE known_fruits
(
id INT,
name VARCHAR(40)
);
INSERT INTO known_fruits(id, name) VALUES (1, 'Cherry');
INSERT INTO known_fruits(id, name) VALUES (2, 'Apple');
INSERT INTO known_fruits(id, name) VALUES (3, 'Banana');
----

Mycompany schema (`src/main/resources/database/mycompany/V1.0.0__create_fruits.sql`):

[source,sql]
----
CREATE SEQUENCE known_fruits_id_seq;
SELECT setval('known_fruits_id_seq', 3);
CREATE TABLE known_fruits
(
id INT,
name VARCHAR(40)
);
INSERT INTO known_fruits(id, name) VALUES (1, 'Avocado');
INSERT INTO known_fruits(id, name) VALUES (2, 'Apricots');
INSERT INTO known_fruits(id, name) VALUES (3, 'Blackberries');
----

=== Programmatically Resolving Tenants Connections

If you need a more dynamic configuration for the different tenants you want to support and don't want to end up with multiple entries in your configuration file, you can use the `io.quarkus.hibernate.orm.runtime.tenant.TenantConnectionResolver` interface to implement your own logic for retrieving a connection. Creating an application scoped bean that implements this interface will replace the current Quarkus default implementation `io.quarkus.hibernate.orm.runtime.tenant.DataSourceTenantConnectionResolver`. Your custom connection resolver would allow for example to read tenant information from a database and create a connection per tenant at runtime based on it.
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,24 @@ public class HibernateOrmConfig {
@ConfigItem
Optional<String> implicitNamingStrategy;

/**
* Defines the method for multi-tenancy (DATABASE, NONE, SCHEMA). The complete list of allowed values is available in the
* https://docs.jboss.org/hibernate/stable/orm/javadocs/org/hibernate/MultiTenancyStrategy.html[Hibernate ORM JavaDoc].
* The type DISCRIMINATOR is currently not supported. The default value is NONE (no multi-tenancy).
*
* @asciidoclet
*/
@ConfigItem
public Optional<String> multitenant;

/**
* Defines the name of the data source to use in case of SCHEMA approach. The default data source will be used if not set.
*
* @asciidoclet
*/
@ConfigItem
public Optional<String> multitenantSchemaDatasource;

/**
* Query related configuration.
*/
Expand Down Expand Up @@ -169,6 +187,8 @@ public boolean isAnyPropertySet() {
database.isAnyPropertySet() ||
jdbc.isAnyPropertySet() ||
log.isAnyPropertySet() ||
multitenant.isPresent() ||
multitenantSchemaDatasource.isPresent() ||
!cache.isEmpty();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

import org.eclipse.microprofile.metrics.Metadata;
import org.eclipse.microprofile.metrics.MetricType;
import org.hibernate.MultiTenancyStrategy;
import org.hibernate.annotations.Proxy;
import org.hibernate.boot.archive.scan.spi.ClassDescriptor;
import org.hibernate.bytecode.internal.bytebuddy.BytecodeProviderImpl;
Expand Down Expand Up @@ -105,6 +106,7 @@
import io.quarkus.hibernate.orm.runtime.dialect.QuarkusPostgreSQL10Dialect;
import io.quarkus.hibernate.orm.runtime.metrics.HibernateCounter;
import io.quarkus.hibernate.orm.runtime.proxies.PreGeneratedProxies;
import io.quarkus.hibernate.orm.runtime.tenant.DataSourceTenantConnectionResolver;
import io.quarkus.runtime.LaunchMode;
import io.quarkus.smallrye.metrics.deployment.spi.MetricBuildItem;
import net.bytebuddy.description.type.TypeDescription;
Expand Down Expand Up @@ -281,10 +283,23 @@ public void build(RecorderContext recorderContext, HibernateOrmRecorder recorder
}
PreGeneratedProxies proxyDefinitions = generatedProxies(entitiesToGenerateProxiesFor, compositeIndex,
generatedClassBuildItemBuildProducer);

// Multi tenancy mode (DATABASE, DISCRIMINATOR, NONE, SCHEMA)
MultiTenancyStrategy strategy = getMultiTenancyStrategy();
if (strategy == MultiTenancyStrategy.DISCRIMINATOR) {
// See https://hibernate.atlassian.net/browse/HHH-6054
throw new ConfigurationError("The Hibernate ORM multi tenancy strategy "
+ MultiTenancyStrategy.DISCRIMINATOR + " is currently not supported");
}

beanContainerListener
.produce(new BeanContainerListenerBuildItem(
recorder.initMetadata(allDescriptors, scanner, integratorClasses, serviceContributorClasses,
proxyDefinitions)));
proxyDefinitions, strategy)));
}

private MultiTenancyStrategy getMultiTenancyStrategy() {
return MultiTenancyStrategy.valueOf(hibernateConfig.multitenant.orElse(MultiTenancyStrategy.NONE.name()));
}

private PreGeneratedProxies generatedProxies(Set<String> entityClassNames, IndexView combinedIndex,
Expand Down Expand Up @@ -407,9 +422,16 @@ void registerBeans(BuildProducer<AdditionalBeanBuildItem> additionalBeans, Combi
return;
}

List<Class<?>> unremovableClasses = new ArrayList<>();
unremovableClasses.add(JPAConfig.class);
unremovableClasses.add(TransactionEntityManagers.class);
unremovableClasses.add(RequestScopedEntityManagerHolder.class);
if (getMultiTenancyStrategy() != MultiTenancyStrategy.NONE) {
unremovableClasses.add(DataSourceTenantConnectionResolver.class);
}

additionalBeans.produce(AdditionalBeanBuildItem.builder().setUnremovable()
.addBeanClasses(JPAConfig.class, TransactionEntityManagers.class,
RequestScopedEntityManagerHolder.class)
.addBeanClasses(unremovableClasses.toArray(new Class<?>[unremovableClasses.size()]))
.build());

if (descriptors.size() == 1) {
Expand Down Expand Up @@ -444,9 +466,12 @@ public void build(HibernateOrmRecorder recorder,
if (!hasEntities(jpaEntities, nonJpaModels)) {
return;
}

MultiTenancyStrategy strategy = MultiTenancyStrategy
.valueOf(hibernateConfig.multitenant.orElse(MultiTenancyStrategy.NONE.name()));
buildProducer.produce(new BeanContainerListenerBuildItem(
recorder.initializeJpa(capabilities.isCapabilityPresent(Capabilities.TRANSACTIONS))));
recorder.initializeJpa(capabilities.isCapabilityPresent(Capabilities.TRANSACTIONS), strategy,
hibernateConfig.multitenantSchemaDatasource.orElse(null))));

// Bootstrap all persistence units
for (PersistenceUnitDescriptorBuildItem persistenceUnitDescriptor : descriptors) {
buildProducer.produce(new BeanContainerListenerBuildItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ private EntityManagerFactoryBuilder getEntityManagerFactoryBuilderOrNull(String
persistenceUnitName,
standardServiceRegistry /* Mostly ignored! (yet needs to match) */,
runtimeSettings,
validatorFactory, cdiBeanManager);
validatorFactory, cdiBeanManager, recordedState.getMultiTenancyStrategy());
}

log.debug("Found no matching persistence units");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.Collection;
import java.util.List;

import org.hibernate.MultiTenancyStrategy;
import org.hibernate.boot.archive.scan.spi.Scanner;
import org.hibernate.integrator.spi.Integrator;
import org.hibernate.jpa.boot.internal.ParsedPersistenceXmlDescriptor;
Expand Down Expand Up @@ -40,11 +41,25 @@ public void callHibernateFeatureInit(boolean enabled) {
Hibernate.featureInit(enabled);
}

public BeanContainerListener initializeJpa(boolean jtaEnabled) {
/**
* Initializes the JPA configuration to be used at runtime.
*
* @param jtaEnabled Should JTA be enabled?
* @param strategy Multitenancy strategy to use.
* @param multiTenancySchemaDataSource Data source to use in case of {@link MultiTenancyStrategy#SCHEMA} approach or
* {@link null} in case the default data source.
*
* @return
*/
public BeanContainerListener initializeJpa(boolean jtaEnabled, MultiTenancyStrategy strategy,
String multiTenancySchemaDataSource) {
return new BeanContainerListener() {
@Override
public void created(BeanContainer beanContainer) {
beanContainer.instance(JPAConfig.class).setJtaEnabled(jtaEnabled);
JPAConfig instance = beanContainer.instance(JPAConfig.class);
instance.setJtaEnabled(jtaEnabled);
instance.setMultiTenancyStrategy(strategy);
instance.setMultiTenancySchemaDataSource(multiTenancySchemaDataSource);
}
};
}
Expand All @@ -70,12 +85,12 @@ public void created(BeanContainer beanContainer) {
public BeanContainerListener initMetadata(List<ParsedPersistenceXmlDescriptor> parsedPersistenceXmlDescriptors,
Scanner scanner, Collection<Class<? extends Integrator>> additionalIntegrators,
Collection<Class<? extends ServiceContributor>> additionalServiceContributors,
PreGeneratedProxies proxyDefinitions) {
PreGeneratedProxies proxyDefinitions, MultiTenancyStrategy strategy) {
return new BeanContainerListener() {
@Override
public void created(BeanContainer beanContainer) {
PersistenceUnitsHolder.initializeJpa(parsedPersistenceXmlDescriptors, scanner, additionalIntegrators,
additionalServiceContributors, proxyDefinitions);
additionalServiceContributors, proxyDefinitions, strategy);
}
};
}
Expand Down
Loading

0 comments on commit 8752232

Please sign in to comment.