Spring boot y conexiones dinámicas

24/03/2017

Una importante decisión que hay que tomar a la hora de desarrollar una apliación multitenant es la estrategia a utilizar en cuanto a persistencia, sobretodo si vamos a trabajar con una base de datos relacional. Hay principalmente tres estrategias:

  1. Una sola base de datos, 1 solo esquema, y utilizar un campo en las tablas para identificar al cliente.
  2. Una sola base de datos, 1 esquema por cliente.
  3. Varias bases de datos: una para cada cliente.


Cada una tiene sus pros y sus contras. Pero una ventaja de la primera estrategia es que la base de datos (y el esquema) es que siempre el mismo. Y esto te permite tener conexiones permanentes a la base de datos, y un pool de conexiones. Las otras dos estrategias pero, son un poco mas complicadas a la hora gestionar las conexiones. Y es que para cada request, habrá que, o bien cambiar de esquema, o bien de base de datos. El problema es que a priori no sabes a dónde tendras que connectar, cosa que te impide entre otras cosas usar un pool de conexiones. En estos casos necesitas conexiones dinámicas, de manera que se establezca una conexión al inicio del request.

Cómo se hace esto con Spring? Si has usado spring-data, sabrás que todo parte del concepto de DataSource (javax.sql.DataSource) , que es la propia conexión a la base de datos. Esta conexión se suele definir como un singleton que se inyecta a las clases que se encargan de la persistencia (Daos y repositorios). El problema viene porque este DataSource, esta conexión, es estático, en el sentido de que cuando se inicializa la aplicacion (el spring context), Spring creará el datasource, inicializará la conexión y ésta vivirá durante toda la aplicación.

Para solventar este problema, la solución es utilizar otro tipo de Datasource (cabe recordar que DataSource es sólo el interface, que tiene distintas implementaciones, como DriverManagerDatasource, que es uno de los que se suele utilizar). Este DataSource debería permitirnos que, cuando llegue la petición, connectar a la base de datos que queramos, y cuando finalize la petición, cierre la conexión:

 

AbstractRoutingDataSource


AbstractRoutingDataSource es una clase abstracta que podemos definir como nuestra implementación de DataSource, y que permite tener este tipo de conexiones dinámicas que necesitamos. El funcionamiento es el siguiente:

Por una parte, AbstractRoutingDatasource mantiene una lista de DataSources. Esta lista está indexada por una clave, en forma de Map:

private Map<Object, DataSource> resolvedDataSources;

Esta lista la podemos crear en cualquier momento usando el siguiente método:

setTargetDataSources(java.util.Map<java.lang.Object, java.lang.Object>)

Si invocamos este método a AbstractRoutingDatasource, le establecemos la lista de conexiones disponibles. Donde le pasamos una lista de Datasouces indexadas por una clave cualquiera. Una vez invocamos setTartgetDataSources(), hay que invocar el metodo afterPropertiesSet(), que generará el map interno resolvedDataSources.

Por otra parte, AbstractRoutingDatasource tiene un metodo determineCurrentLookupKey(), que spring utilizará para averiguar en un momento dado, qué conexión de la lista hay que usar. Como si fuese un multipexor, vaya: En base a un parámetro, una clave, usará un datasource u otro de los que tenga establecidos en cada momento, todo en tiempo de ejecución.

Pero, esta clave, de dónde se saca, y como encaja todo dentro del cliclo de un request?

El truco para generar y gestionar estas claves es el de mantener una variable ThreadLocal, que se inicialize en el momento en que se inicie el request: En base al request, estableces esta variable, que será la que se use en getCurrentLookupKey(). Al final, estas variables no son mas que las claves que hay en el map de AbstractRoutingDatasource.resolvedDataSources.

public abstract class ContextHolder {

    private static final Logger logger = LoggerFactory.getLogger(ContextHolder.class);
    private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>();

    public static void setClient(String context) {
        contextHolder.set(context);
    }
    public static String getClient() {
        return contextHolder.get();
    }
}

Esta clase ContextHolder es solo un wrapper, un helper class que permite establecer la clave de forma local al thread que procesa el request utilizando el método setClient(). La clase se declara abstracta porque no tiene sentido crear instancias de ella: todos sus métodos y propiedades son estáticas.

Ahora se trataria de que, antes de procesar un request, poder invocar a setClient() para establecer la clave que indique a que base de datos queremos conectar dentro de ese request. Para ello usamos un HanderInterceptor, que vendria a ser algo parecido a un servlet filter:

@Component
public class DatabaseSwitchInterceptor extends HandlerInterceptorAdapter {

    @Autowired
    private CustomRoutingDataSource customRoutingDataSource;

    private static final Logger logger = LoggerFactory
            .getLogger(DatabaseSwitchInterceptor.class);

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) throws Exception {
        String hostname = request.getServerName();
        ContextHolder.setClient(hostname);
        return true;
    }
}


En este ejemplo, el filtro se queda con el nombre del dominio, y este nombre es el que se establece en la variable ThreadLocal, mediante la llamada a ContextHolder.setClient().

Por otra parte, hay que hacer que nuestro AbstractRoutingDatasource lea la clave de esta variable TheadLocal que encapsulamos mediante la clase ContextHolder:

@Component
public class CustomRoutingDataSource extends AbstractRoutingDataSource {

    private org.slf4j.Logger logger = LoggerFactory.getLogger(CustomRoutingDataSource.class);

    @Autowired
    DataSourceMap dataSources;

    @Autowired
    private Environment env;

    @Override
    protected Object determineCurrentLookupKey() {
        String key = ContextHolder.getClient();
        if (!dataSources.getDataSourceMap().containsKey(key)) {
            DriverManagerDataSource ds = new DriverManagerDataSource();
            ds.setDriverClassName("com.mysql.jdbc.Driver");
            ds.setPassword(env.getProperty("spring.datasource.password"));
            ds.setUsername(env.getProperty("spring.datasource.username"));
            ds.setUrl("jdbc:mysql://localhost/" + key);
            dataSources.addDataSource(key, ds);
            setDataSources(dataSources);
            afterPropertiesSet();
        }
        return key;
    }
    @Autowired
    public void setDataSources(DataSourceMap dataSources) {
        setTargetDataSources(dataSources.getDataSourceMap());
    }
}


De esta forma, cuando llegue una petición, el filtro establecerá la variable TheadLocal que usamos como clave mediante la clase ContextHolder, en base al criterio que escojamos (yo aquí he usado el nombre del dominio, però al tener acceso al ServletRequest, podemos usar cualquier otro criterio).

Cuando un DAO quiera hacer una consulta, se llamará al DataSource.getConnection(), el cual usará el método determinteCurrentLookupKey() para averiguar que clave usar para indexar el Map de conexiones que tiene guardadas. Y en nuestro método determineCurrentLookupKey() hemos usado el ContextHolder para leer la variable que indica el nombre de la base de datos, nombre que hemos establecido al principio del ciclo de vida del request. Este DataSourceMap está aislado en su propia clase, y no es más que un wrapper a un Map concurrente:

@Component
public class DataSourceMap {


    private static final Logger logger = LoggerFactory
            .getLogger(DataSourceMap.class);

    private Map<Object, Object> dataSourceMap = new ConcurrentHashMap<>();

    public void addDataSource(String session, DataSource dataSource) {
        this.dataSourceMap.put(session, dataSource);
    }

    public Map<Object, Object> getDataSourceMap() {
        return dataSourceMap;
    }


}


Señalar que si en el proceso, se intenta acceder a una clave no existe, significa que hay que añadir el DatasSource a la lista. Para ello, se crea un nuevo DriverManagerDatasource y se invoca setTargetDataSources() para establecer la nueva lista de conexiones disponibles.