Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Multi-Tenancy Using JPA, Spring, and Hibernate (Part 2)

DZone's Guide to

Multi-Tenancy Using JPA, Spring, and Hibernate (Part 2)

If you want to support multi-tenancy with Spring in your stack, you should know how Spring actually knows the tenants, then dive right in.

Free Resource

Try Okta to add social login, MFA, and OpenID Connect support to your Java app in minutes. Create a free developer account today and never build auth again.

To understand how tenants work in Spring, it is important to first understand what multi-tenancy is and how it is implemented in JPA.

As I said in part 1, Hibernate/JPA knows the current tenant through the CurrentTenantIdentifierResolver, which is resolved by Spring. So the real question here is, “How does Spring know what tenants exist?” The answer to this question has a lot of different ways to be implemented, which is why it’s difficult to find a good reference.

In our case, before implementing it, we will set the basis to know how tenants are defined:

  • Each tenant has a property file that describes values for a dataSource in the form: database.<tenantId>.properties. We will call it a tenant-file in this post.
  • Each <tenantId> written in a filename is what identifies a tenantId. In this way, we don’t need to duplicate the information. If you need tenantX, just create a filename like database.tenantX.properties
  • There is a Map<String, DataSource>, the key will be the tenantId string, this map will be initialized by getting the properties from the tenant-files.
  • There is a default datasource called defaultDatasource. It contains default properties, so if a property is not specified in the tenant-file, we will get it from the default datasource. In this way, we can set a collection of properties as default and avoid property duplication.

As you can see from above, tenants are acquired from a tenant-file. Now let’s continue with our implementation.

Our Spring Implementation to Get Tenants

DataSourceLookup is a Spring interface that allows us to look up DataSources by name, we chose it since we want to find DataSource by TenantId. I found in the JavaDoc from Spring a class named MapDataSourceLookup, which has a Map<String,DataSource> and implements DataSourceLookup.

I decided to implement my own class so I could get the tenants from the properties file in a folder:

/**
 * It lookup the correct datasource to use, we have one per tenant
 * 
 * The tenant datasource has default properties from database.properties and
 * also properties in database.{tenantId}.properties whose properties override
 * the default ones.
 * 
 * @author jm
 *
 */
@Component(value = "dataSourceLookup")
public class MultiTenantDataSourceLookup extends MapDataSourceLookup {

  Log logger = LogFactory.getLog(getClass());

  private String tenantDbConfigs = TENANT_DB_CONFIGS; // For testing
  private String tenantDbConfigsOverride = TENANT_DB_CONFIGS_OVERRIDE; // For production
  private String tenantRegex = TENANT_REGEX;

  @Autowired
  public MultiTenantDataSourceLookup(BoneCPDataSource defaultDataSource) {
    super();

    try {
      initializeDataSources(defaultDataSource);
    } catch (IOException e) {
      e.printStackTrace();
    }

  }

  /**
   * It initialize all the datasources. If multitenancy is activated it also
   * add dataSources for different tenants on tenantDbConfigs or
   * tenantDbConfigsOverride
   * 
   * @param tenantResolver
   * @throws IOException
   */
  private void initializeDataSources(BoneCPDataSource defaultDataSource) throws IOException {
    //Get the path where server is stored, we will save configurations there,
    //so if we redeploy it will not be deleted
    String catalinaBase = System.getProperties().getProperty("catalina.base");

    logger.info("MultiTenancy configuration: ");
    logger.info("---------------------------------------------------");

    // Add the default tenant and datasource
    addDataSource(DEFAULT_TENANTID, defaultDataSource);
    logger.info("Configuring default tenant: DefaultTenant - Properties: " + defaultDataSource.toString());

    // Add the other tenants
    logger.info("-- CLASSPATH TENANTS --");
    addTenantDataSources(defaultDataSource, tenantDbConfigs);
    logger.info("-- GLOBAL TENANTS --");
    addTenantDataSources(defaultDataSource, "file:" + catalinaBase + tenantDbConfigsOverride);
    logger.info("---------------------------------------------------");
  }

  /**
   * Add Tenant datasources based on the default properties on
   * defaultDataSource and the configurations in dbConfigs.
   * 
   * @param defaultDataSource
   * @param dbConfigs
   */
  private void addTenantDataSources(BoneCPDataSource defaultDataSource, String dbConfigs) {
    // Add the custom tenants and datasources
    Pattern tenantPattern = Pattern.compile(this.tenantRegex);
    PathMatchingResourcePatternResolver fileResolver = new PathMatchingResourcePatternResolver();

    InputStream dbProperties = null;

    try {
      Resource[] resources = fileResolver.getResources(dbConfigs);
      for (Resource resource : resources) {
        // load properties
        Properties props = new Properties(defaultDataSource.getClientInfo());
        dbProperties = resource.getInputStream();
        props.load(dbProperties);

        // Get tenantId using the filename and pattern
        String tenantId = getTenantId(tenantPattern, resource.getFilename());

        // Add new datasource with own configuration per tenant
        -BoneCPDataSource customDataSource = createTenantDataSource(props, defaultDataSource);
        addDataSource(tenantId, customDataSource); // It replace if tenantId was already there.

        logger.info("Configured tenant: " + tenantId + " - Properties: " + customDataSource.toString());

      }
    } catch (FileNotFoundException fnfe) {
      logger.warn("Not tenant configurations or path not found: " + fnfe.getMessage());
    } catch (IOException ioe) {
      logger.error("Error getting the tenants: " + ioe.getMessage());
    } finally {
      if (dbProperties != null) {
        try {
          dbProperties.close();
        } catch (IOException e) {
          logger.error("Error closing a property tenant: " + dbProperties.toString());
          e.printStackTrace();
        }
      }
    }
  }

  /**
   * Create a datasource with tenant properties, if a property is not found in Properties 
   * it takes the property from the defaultDataSource
   *
   * @param defaultDataSource a default datasource
   * @return a BoneCPDataSource based on tenant and default properties
   */
  private BoneCPDataSource createTenantDataSource(Properties tenantProps, BoneCPDataSource defaultDataSource)tenantProps)
  {
    BoneCPDataSource customDataSource = new BoneCPDataSource();

    //url, username and password must be unique per tenant so there is not default value
    customDataSource.setJdbcUrl(tenantProps.getProperty("database.url")); 
    customDataSource.setUsername(tenantProps.getProperty("database.username")); 
    customDataSource.setPassword(tenantProps.getProperty("database.password"));
    //These has default values in defaultDataSource
    customDataSource.setDriverClass(tenantProps.getProperty("database.driverClassName", defaultDataSource.getDriverClass()));
    customDataSource.setIdleConnectionTestPeriodInMinutes(Long.valueOf(tenantProps.getProperty(
      "database.idleConnectionTestPeriod",String.valueOf(defaultDataSource.getIdleConnectionTestPeriodInMinutes()))));
    customDataSource.setIdleMaxAgeInMinutes(Long.valueOf(tenantProps.getProperty(
      "database.idleMaxAge", String.valueOf(defaultDataSource.getIdleMaxAgeInMinutes()))));
    customDataSource.setMaxConnectionsPerPartition(Integer.valueOf(tenantProps.getProperty(
      "database.maxConnectionsPerPartition", String.valueOf(defaultDataSource.getMaxConnectionsPerPartition()))));
    customDataSource.setMinConnectionsPerPartition(Integer.valueOf(tenantProps.getProperty(
      "database.minConnectionsPerPartition", String.valueOf(defaultDataSource.getMinConnectionsPerPartition()))));
    customDataSource.setPartitionCount(Integer.valueOf(tenantProps.getProperty(
      "database.partitionCount", String.valueOf(defaultDataSource.getPartitionCount()))));
    customDataSource.setAcquireIncrement(Integer.valueOf(tenantProps.getProperty(
      "database.acquireIncrement", String.valueOf(defaultDataSource.getAcquireIncrement()))));
    customDataSource.setStatementsCacheSize(Integer.valueOf(tenantProps.getProperty(
      "database.statementsCacheSize",String.valueOf(defaultDataSource.getStatementCacheSize()))));
    customDataSource.setReleaseHelperThreads(Integer.valueOf(tenantProps.getProperty(
      "database.releaseHelperThreads", String.valueOf(defaultDataSource.getReleaseHelperThreads()))));customDataSource.setDriverClass(tenantProps.getProperty("database.driverClassName"));

    return customDataSource;
  }

  /**
   * Get the tenantId from filename using the pattern
   * 
   * @param tenantPattern
   * @param filename
   * @return tenantId
   * @throws IOException
   */
  private String getTenantId(Pattern tenantPattern, String filename) throws IOException {
    Matcher matcher = tenantPattern.matcher(filename);
    boolean findMatch = matcher.matches();
    if (!findMatch) {
      throw new IOException("Error reading tenant name in the filename");
    }
    return matcher.group(1);
  }

}


As there are comments in the code, I will focus just on the important parts.

Implementation Details

Firstly, we have some constants:

  • tenantDbConfigs: The directory used in development where tenant-files are located inside a folder of the project.
  • tenantDbConfigsOverride: The directory in production where tenant-files are located outside the project. In this way, if the application is redeployed it’s not deleted..
  • tenantRegex: Regular expression that we use to iterate about different tenant-files and get the tenantId.

It is in the constructor where we initialize the datasources, the method initializeDataSources(DataSource defaultDataSource) will put the defaultDataSource for the default tenant in the map firstly and then the others.

All the magic to get the tenants happens in addTenantDataSources(DataSource defaultDatasource, String folder). Basically, what we do is to use a regular expression to get the properties from each tenant in a folder. We also get the tenantId from the filename and override the DefaultDataSource properties with the ones in the property file of each tenantId. Finally, we add the entry to the map as <tenantId, DataSource>.

In this class we have two different processes:

  • Add the datasources for each tenant. These datasources will be used for Hibernate.
  • Get the properties for each tenant

The ideal should be to separate these processes: “Add datasources” and “get tenant properties.” This can be done by creating a new singleton class, PropertyManager, and using it in our MultiTenantDataSourceLookup. In this way, you could have every access to any property centralized in the PropertyManager. But for the sake of shortening and understanding this topic, I decided to post it in this way.

In future posts, we will talk about:

  • Hibernate schema export in multi-tenancy.
  • Different ways to save the tenant id in an HTTP session.

I hope that you find the article informative. If you have any questions please don’t hesitate to ask.

Build and launch faster with Okta’s user management API. Register today for the free forever developer edition!

Topics:
hibernate ,jpa ,multitenancy ,spring ,tutorial ,java

Published at DZone with permission of Jose Manuel García Maestre, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}