DJDaveMark DJDaveMark - 1 month ago 16
Java Question

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
    AnnotationSessionFactoryBean
    s 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());
}
}

Answer

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.

Comments