Search code examples
javahibernateapache-commons-dbcpspring-orm

Dynamic Multi-tenant WebApp (Spring Hibernate)


I've come up with a working dynamic multi-tenant application using:

  • Java 8
  • Java Servlet 3.1
  • Spring 3.0.7-RELEASE (can't change the version)
  • Hibernate 3.6.0.Final (can't change the version)
  • Commons dbcp2

This is the 1st time I've had to instantiate Spring objects myself so I'm wondering if I've done everything correctly or if the app will blow up in my face at an unspecified future date during production.

Basically, the single DataBase schema is known, but the database details will be specified at runtime by the user. They are free to specify any hostname/port/DB name/username/password.

Here's the workflow:

  • The user logs in to the web app then either chooses a database from a known list, or specifies a custom database (hostname/port/etc.).
  • If the Hibernate SessionFactory is built successfully (or is found in the cache), then it's persisted for the user's session using SourceContext#setSourceId(SourceId) then the user can work with this database.
  • If anybody choses/specifies the same database, the same cached AnnotationSessionFactoryBean is returned
  • The user can switch databases at any point.
  • When the user switches away from a custom DB (or logs off), the cached AnnotationSessionFactoryBeans are removed/destroyed

So will the following work as intended? Help and pointers are most welcome.

web.xml

<web-app version="3.1" ...>
  <context-param>
    <param-name>contextConfigLocation</param-name>
    <param-value>classpath:applicationContext.xml</param-value>
  </context-param>

  <listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
  </listener>
  <listener> <!-- Needed for SourceContext -->
    <listener-class>org.springframework.web.context.request.RequestContextListener</listener-class>
  </listener>
<web-app>

applicationContext.xml

<beans ...>
  <tx:annotation-driven />
  <util:properties id="db" location="classpath:db.properties" /> <!-- driver/url prefix -->
  <context:component-scan base-package="com.example.basepackage" />
</beans>

UserDao.java

@Service
public class UserDao implements UserDaoImpl {
    @Autowired
    private TemplateFactory templateFactory;

    @Override
    public void addTask() {
        final HibernateTemplate template = templateFactory.getHibernateTemplate();
        final User user = (User) DataAccessUtils.uniqueResult(
                template.find("select distinct u from User u left join fetch u.tasks where u.id = ?", 1)
        );

        final Task task = new Task("Do something");
        user.getTasks().add(task);

        TransactionTemplate txTemplate = templateFactory.getTxTemplate(template);
        txTemplate.execute(new TransactionCallbackWithoutResult() {
            @Override
            protected void doInTransactionWithoutResult(TransactionStatus status) {
                template.save(task);
                template.update(user);
            }
        });
    }
}

TemplateFactory.java

@Service
public class TemplateFactory {
    @Autowired
    private SourceSessionFactory factory;

    @Resource(name = "SourceContext")
    private SourceContext srcCtx; // session scope, proxied bean

    @Override
    public HibernateTemplate getHibernateTemplate() {
        LocalSessionFactoryBean sessionFactory = factory.getSessionFactory(srcCtx.getSourceId());

        return new HibernateTemplate(sessionFactory.getObject());
    }

    @Override
    public TransactionTemplate getTxTemplate(HibernateTemplate template) {
        HibernateTransactionManager txManager = new HibernateTransactionManager();
        txManager.setSessionFactory(template.getSessionFactory());

        return new TransactionTemplate(txManager);
    }
}

SourceContext.java

@Component("SourceContext")
@Scope(value="session", proxyMode = ScopedProxyMode.INTERFACES)
public class SourceContext {
    private static final long serialVersionUID = -124875L;

    private SourceId id;

    @Override
    public SourceId getSourceId() {
        return id;
    }

    @Override
    public void setSourceId(SourceId id) {
        this.id = id;
    }
}

SourceId.java

public interface SourceId {
    String getHostname();

    int getPort();

    String getSID();

    String getUsername();

    String getPassword();

    // concrete class has proper hashCode/equals/toString methods
    // which use all of the SourceIds properties above
}

SourceSessionFactory.java

@Service
public class SourceSessionFactory {
    private static Map<SourceId, AnnotationSessionFactoryBean> cache = new HashMap<SourceId, AnnotationSessionFactoryBean>();

    @Resource(name = "db")
    private Properties db;

    @Override
    public LocalSessionFactoryBean getSessionFactory(SourceId id) {
        synchronized (cache) {
            AnnotationSessionFactoryBean sessionFactory = cache.get(id);
            if (sessionFactory == null) {
                return createSessionFactory(id);
            }
            else {
                return sessionFactory;
            }
        }
    }

    private AnnotationSessionFactoryBean createSessionFactory(SourceId id) {
        AnnotationSessionFactoryBean sessionFactory = new AnnotationSessionFactoryBean();
        sessionFactory.setDataSource(new CutomDataSource(id, db));
        sessionFactory.setPackagesToScan(new String[] { "com.example.basepackage" });
        try {
            sessionFactory.afterPropertiesSet();
        }
        catch (Exception e) {
            throw new SourceException("Unable to build SessionFactory for:" + id, e);
        }

        cache.put(id, sessionFactory);

        return sessionFactory;
    }

    public void destroy(SourceId id) {
        synchronized (cache) {
            AnnotationSessionFactoryBean sessionFactory = cache.remove(id);
            if (sessionFactory != null) {
                if (LOG.isInfoEnabled()) {
                    LOG.info("Releasing SessionFactory for: " + id);
                }

                try {
                    sessionFactory.destroy();
                }
                catch (HibernateException e) {
                    LOG.error("Unable to destroy SessionFactory for: " + id);
                    e.printStackTrace(System.err);
                }
            }
        }
    }
}

CustomDataSource.java

public class CutomDataSource extends BasicDataSource { // commons-dbcp2
    public CutomDataSource(SourceId id, Properties db) {
        setDriverClassName(db.getProperty("driverClassName"));
        setUrl(db.getProperty("url") + id.getHostname() + ":" + id.getPort() + ":" + id.getSID());
        setUsername(id.getUsername());
        setPassword(id.getPassword());
    }
}

Solution

  • In the end I extended Spring's AbstractRoutingDataSource to be able to dynamically create datasources on the fly. I'll update this answer with the full code as soon as everything is working correctly. I have a couple of last things to sort out, but the crux of it is as follows:

    @Service
    public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
    
        // this is pretty much the same as the above SourceSessionFactory
        // but with a map of CustomDataSources instead of
        // AnnotationSessionFactoryBeans
        @Autowired
        private DynamicDataSourceFactory dataSourceFactory;
    
        // This is the sticky part. I currently have a workaround instead.
        // Hibernate needs an actual connection upon spring startup & there's
        // also no session in place during spring initialization. TBC.
        // @Resource(name = "UserContext") // scope session, proxy bean
        private UserContext userCtx; // something that returns the DB config
    
        @Override
        protected SourceId determineCurrentLookupKey() {
            return userCtx.getSourceId();
        }
    
        @Override
        protected CustomDataSource determineTargetDataSource() {
            SourceId id = determineCurrentLookupKey();
            return dataSourceFactory.getDataSource(id);
        }
    
        @Override
        public void afterPropertiesSet() {
            // we don't need to resolve any data sources
        }
    
        // Inherited methods copied here to show what's going on
    
    //  @Override
    //  public Connection getConnection() throws SQLException {
    //     return determineTargetDataSource().getConnection();
    //  }
    //
    //  @Override
    //  public Connection getConnection(String username, String password)
    //          throws SQLException {
    //      return determineTargetDataSource().getConnection(username, password);
    //  }
    }
    

    So I just wire up the DynamicRoutingDataSource as the DataSource for Spring's SessionFactoryBean along with a TransactionManager an all the rest as usual. As I said, more code to follow.