Tema 4: Opciones avanzadas en el acceso a una BD

En este tema vamos a detallar las opciones avanzadas que se presentan en el paquete opcional javax.sql.

4.1. Fuentes de datos (DataSources)

Hasta el momento, cuando queríamos conectarnos a una base de datos, proporcionábamos su dirección URL. Si cambiábamos la dirección de la BD debíamos recompilar la aplicación. Si tenemos muchas aplicaciones en nuestro sistema la actualización puede ser costosa. Las fuentes de datos en JDBC permiten una utilización más flexible de las conexiones. Un objeto DataSource se gestiona de manera independiente de nuestra aplicación. La clase DataSource es una interfaz, por lo que no podemos instanciarla directamente. Debemos disponer de una clase que implemente dicha interfaz, usualmente desarrollada por el fabricante de la BD.

4.1.1. Uso de las fuentes de datos

Para hacer uso de las fuentes de datos debemos seguir dos fases. En la primera, haciendo uso de JDNI debemos asociar un objeto DataSource con una base de datos. El objeto DataSource contendrá los datos necesarios para la conexión a la BD. Este objeto lo almacenaremos en un servicio de directorios con JDNI. En principio podemos tener tantos objetos DataSource como queramos en nuestro sistema. Esta primera fase la suele llevar a cabo el servidor de aplicaciones. La segunda fase consiste en la conexión de un cliente a la base de datos. Cuando un cliente necesite conectarse a la base de datos, utilizará JDNI para buscar el objeto DataSource asociado y éste a su vez devolverá una conexión, tal como hacía la clase DriverManager anteriormente. En el código que se muestra a continuación se puede observar el código que implementará el cliente cuando quiera obtener una conexión.

// Se crea un contexto inicial (JDNI)
Context contexto = new InitialContex();
// JDNI nos proporciona el objeto DataSource
DataSource ds = (DataSource) context.lookup("jdbc/MySQL");
// Obtenemos la conexión del objeto DataSource
conexion = ds.getConnection();

Con esta abstracción en la conexión hemos conseguido que el cliente sólo conozca el nombre "lógico" de la BD, no su dirección URL. El cliente tampoco conoce los detalles del driver utilizado. Todo esto lo encapsula el objeto DataSource. Si queremos realizar algún cambio en nuestra BD (cambio de dominio, actualización, etc.) sólo tendremos que cambiar el objeto DataSource, no la aplicación cliente. A partir de este punto ya podemos hacer uso del API de JDBC estándar para realizar consultas a la BD.

4.2. Reserva de conexiones (ConnectionPool)

Cuando accedemos a una base de datos, el proceso de conexión es el más costoso temporalmente. En una aplicación de servidor, el número de conexiones puede llegar a ser enorme. Es por ello que se deba buscar un método para optimizar el coste temporal. Una técnica muy utilizada es la reutilización de los recursos costosos. En este caso el recurso costoso es la conexión, por lo que resulta lógico que se intente reducir el coste de conexión manteniendo un número predeterminado de conexiones creadas e ir sirviendo a las clientes que las soliciten. Cuando una aplicación termina con una conexión la libera y queda disponible para otra aplicación. También en este caso, al igual que en el caso de DataSource, es el servidor de aplicaciones el que se encarga de administrar y definir cuántas reservas tendremos en funcionamiento.

A pesar de ser una característica muy útil, la reserva de conexiones está aconsejada en el caso de que se cumpla lo siguiente:

Una aplicación que utilice el enfoque de reserva de conexiones debe seguir el siguiente orden:

4.3. Transacciones

Muchas veces, cuando tengamos que realizar una serie de acciones, queremos que todas se hayan realizado correctamente, o bien que no se realice ninguna de ellas, pero no que se realicen algunas y otras no.

Podemos ver esto mediante un ejemplo, en el que se va a hacer una reserva de vuelos para ir desde Alicante a Osaka. Para hacer esto tendremos que hacer trasbordo en dos aeropuertos, por lo que tenemos que reservar un vuelo Alicante-Madrid, un vuelo Madrid-Amsterdam y un vuelo Amsterdam-Osaka. Si cualquiera de estos tres vuelos estuviese lleno y no pudiésemos reservar, no queremos reservar ninguno de los otros dos porque no nos serviría de nada. Por lo tanto, sólo nos interesa que la reserva se lleve a cabo si podemos reservar los tres vuelos.

Una transacción es un conjunto de sentencias que deben ser ejecutadas como una unidad, de forma que si una de ellas no puede realizarse, no se llevará a cabo ninguna. Dicho de otra manera, las transacciones hacen que la BD pase de un estado consistente al siguiente.

Pero para hacer esto encontramos un problema. Pensemos en nuestro ejemplo de la reserva de vuelos, en la que necesitaremos realizar las siguientes inserciones (reservas):

try {
	stmt.executeUpdate("INSERT 
		INTO RESERVAS(pasajero, origen, destino)
		VALUES('Paquito', 'Alicante', 'Madrid')");
	stmt.executeUpdate("INSERT 
		INTO RESERVAS(pasajero, origen, destino)
		VALUES('Paquito', 'Madrid', 'Amsterdam')");
	stmt.executeUpdate("INSERT 
		INTO RESERVAS(pasajero, origen, destino)
		VALUES('Paquito', 'Amsterdam', 'Osaka')");
} catch(SQLException e) {
	// ¿Dónde ha fallado? ¿Qué hacemos ahora?
}

En este caso, vemos que si falla la reserva de uno de los tres vuelos obtendremos una excepción, pero en ese caso, ¿cómo podremos saber dónde se ha producido el fallo y hasta qué acción debemos deshacer? Con la excepción lo único que sabemos es que algo ha fallado, pero no sabremos dónde ha sido, por lo que de esta forma no podremos saber hasta qué acción debemos deshacer.

Para hacer esto de una forma limpia asegurando la consistencia de los datos, utilizaremos las operaciones de commit y rollback.

Cuando realicemos cambios en la base de datos, estos cambios se harán efectivos en ella de forma persistente cuando realicemos la operación commit. En el modo de operación que hemos visto hasta ahora, por defecto tenemos activado el modo auto-commit, de forma que siempre que ejecutamos alguna sentencia se realiza commit automáticamente. Sin embargo, en el caso de las transacciones con múltiples sentencias, no nos interesará hacer estos cambios persistentes hasta haber comprobado que todos los cambios se pueden hacer de forma correcta. Para ello desactivaremos este modo con:

con.setAutoCommit(false); 

Al desactivar este modo, una vez hayamos hecho las modificaciones de forma correcta, deberemos hacerlas persistentes mediante la operación commit llamando de forma explícita a:

con.commit();

Si por el contrario hemos obtenido algún error, no queremos que esas modificaciones se lleven a cabo finalmente en la BD, por lo que podremos deshacerlas llamando a:

con.rollback();

Por lo tanto, la operación rollback deshará todos los cambios que hayamos realizado para los que todavía no hubiésemos hecho commit para hacerlos persistentes, permitiéndonos de esta forma implementar estas transacciones de forma atómica.

Nuestro ejemplo de la reserva de vuelos debería hacerse de la siguiente forma:

try {

	con.setAutoCommit(false);

	stmt.executeUpdate("INSERT 
		INTO RESERVAS(pasajero, origen, destino)
		VALUES('Paquito', 'Alicante', 'Madrid')");
	stmt.executeUpdate("INSERT 
		INTO RESERVAS(pasajero, origen, destino)
		VALUES('Paquito', 'Madrid', 'Amsterdam')");
	stmt.executeUpdate("INSERT 
		INTO RESERVAS(pasajero, origen, destino)
		VALUES('Paquito', 'Amsterdam', 'Osaka')");
	// Hemos podido reservar los tres vuelos correctamente
	con.commit();
} catch(SQLException e) {
	// Alguno de los tres ha fallado, deshacemos los cambios
	try {
		con.rollback();
	} catch(SQLException e) {};
}

Una característica relacionada con las transacciones es la concurrencia en el acceso a la BD. Dicho de otra forma, qué sucede cuando varios usuarios se encuentran accediendo a la vez a los mismos datos y pretenden modificarlos. Un ejemplo sencillo: tenemos una tienda y dos usuarios están accediendo al mismo disco, del cual sólo queda una unidad. El primero de los usuarios consulta el disponible, comprueba que existe una unidad y lo introduce en su cesta de la compra. El otro usuario en ese preciso momento también está consultando el disponible, también le aparece una unidad y también intenta introducirlo en su cesta de la compra. Al segundo usuario el sistema no debería dejarle actualizar los datos que está manejando el primero.

La concurrencia es manejada por los distintos SGBD de manera distinta. PostGres permite nivel de lectura confirmada. En este nivel, si una transacción lee una fila de la BD y otra transacción cambia la misma fila, en el momento que la primera transacción intente leer de nuevo se actualiza el valor. Si las dos intentan modificar la misma fila la segunda se bloqueará hasta que la primera finalice la transacción. Esto último hay que tenerlo en cuenta para evitar bloqueos. Para saber el nivel concurrencia permitido por el SGBD podemos utilizar el siguiente método de la clase Connection:

int con.getTransactionIsolation();

Los valores más comunes que puede devolver este método son: Connection.TRANSACTION_NONE (no soporta transacciones), Connection.TRANSACTION_READ_UNCOMMITTED (soporta transacciones en su nivel más bajo), Connection.TRANSACTION_READ_COMMITTED (el nivel de PostGres antes comentado). MySQL incorpora transacciones en su última versión (4.0). Sin embargo, su funcionamiento es diferente a PostGres. Si una aplicación actualiza una fila y dentro de su transacción otra aplicación intenta modificarla, MySQL permite la actualización y no se produce ningún problema. Sin embargo, si la primera transacción no realiza commit (debido a algún error) al intentar deshacer los cambios se produce una excepción, pues no puede recuperar el estado anterior a la transacción ya que la segunda aplicación cambio la fila.

4.3.1. Puntos de almacenamiento (savepoint)

Los puntos de almacenamiento permiten definir puntos de control en el flujo de una transacción. Comentaremos aquí la definición y cómo podemos utilizarlos. Sin embargo, ni MySQL ni PostGres los tienen implementados. Los puntos de almacenamiento los genera un objeto Connection. Permiten marcar uno o más lugares de una transacción para, si algo falla, poder deshacer las acciones realizadas a partir de uno de esos puntos marcados. Los métodos a utilizar son los siguientes:

SavePoint con.setSavePoint("Punto de control");
SavePoint con.setSavePoint();

Este método permite marcar un punto de almacenamiento. También disponemos de una forma de deshacer los cambios producidos a partir de un punto de almacenamiento:

con.rollback (SavePoint sp);

En el caso que de una excepción se produzca, podemos consultar el punto de almacenamiento para determinar si las acciones anteriores a la definición del punto de control se han realizado con éxito. El siguiente código muestra un ejemplo de utilización.

SavePoint sp = null;
boolean realizar_commit = false;
try {
	insertarTabla1 (datos);
	realizar_commit = true;
	sp = con.setSavePoint();
	insertarTabla2 (datos2);
} catch {
	// Si sp es null es que falló la primera inserción
	if (sp==null) {
		con.rollback();
	} 
	else {
		con.rollback(sp);
	}
}
finally {
	if (realizar_commit) {
		con.commit();
	}
}

Vamos a analizar con detalle el código. Se declaran dos variables. La primera es el punto de almacenamiento. La segunda es una variable de control que nos va a permitir saber si se realizó la primera acción en la base de datos. El código dentro de la sentencia try llama a dos métodos, insertarTabla1 e insertarTabla2. Estos métodos se encargan de actualizar la base de datos, con la particularidad de que la primera inserción es más importante que la segunda. Además, si la primera no ha tenido éxito se deshace toda la transacción, pero si ha tenido éxito y la segunda no, podemos deshacer sólo la segunda. En este caso es cuando tiene utilidad los puntos de almacenamiento. Hemos colocado un punto de almacenamiento después de la llamada a la primera inserción. Si se ha producido una excepción, sabemos que si el punto de almacenamiento creado es nulo entonces la excepción se produjo en la primera inserción. Si no es nulo, la primera inserción finalizó con éxito y la segunda provocó la excepción. Por ello llamamos al método roolback con el punto de almacenamiento como parámetro. Esta acción provoca que se deshagan las acciones realizadas en la segunda inserción. Por último, en el bloque finally, realizamos commit si se ha realizado la primera inserción con éxito.

 

4.4. RowSet

Un RowSet es un componente que se ajusta a JavaBean y que encapsula el acceso a bases de datos incluido el resultado. Permite olvidarnos de los objetos Connection, ResultSet, Statement, etc. Todas las funcionalidades de estas clases las implementa RowSet. Rowset es un interfaz, por lo que debe ser implementada antes de instanciarse.

La clase mantiene los métodos utilizados por el ResultSet para acceder a los datos y a los registros. Disponemos de next, previous, getXXX, updateXXX tal como los usábamos en ResultSet. Los métodos descritos a continuación sirven para configurar el acceso a una base de datos:

//Utilizamos una de las implementaciones como ejemplo
CachedRowSet crs = new CachedRowSet ();
// Asignamos la URL de nuestra base de datos
crs.setUrl ("jdbc:mysql://localhost/bd");
// Asignamos el usuario y la contraseña
crs.setUsername ("miguel");
crs.setPassword ("miguel");

A partir de este momento ya podemos utilizar el RowSet y realizar consultas a la BD. Debemos tener en cuenta que debemos cargar el driver de la base de datos tal como lo hacíamos anteriormente:

Class.forName ("org.gjt.mm.mysql.Driver");

Para ejecutar un comando, primero asignamos el comando y después lo ejecutamos.

crs.setCommand ("Select * from vuelo");
crs.execute ();

Ya podemos movernos tal como lo hacíamos en el ResultSet. También podemos utilizar los métodos updateXXX para actualizar los datos del RowSet. Una vez realizada la llamada a updateRow debemos realizar una llamada al método acceptChanges. Este método se encargará de actualizar los datos en la fuente de datos. Esto último dependerá si lo soporta el driver. También permite la preparación de sentencias, tal como se hacía con las sentencias preparadas. Sin embargo, aquí no es necesario el uso de objetos adicionales.

crs.setCommand ("select numero from vuelo where aero_inic=?");
crs.setString (1, "ALC");

Sun ha desarrollado tres ejemplos de implementaciones que serán las que veamos en esta sección: CachedRowSet, JdbcRowSet y WebRowSet.

4.4.1. CachedRowSet

Esta implementación permite mantener en memoria los resultados obtenidos de una consulta. Por ello es desaconsejable su uso cuando la consulta genera demasiados datos. Permite el scrolling y la actualización, a pesar de que el driver no los soporte. Esta es una de las ventajas fundamentales de esta implementación. Otra de sus funcionalidades es la posibilidad de recibir datos de fuentes distintas a las bases de datos (hojas de cálculo, ficheros de texto tabulados, etc.). Cuando invocamos al método execute se realiza una conexión a la base de datos, se recibe la información resultado de realizar la consulta y se cierra la conexión. Si realizamos una actualización de los datos el objeto realiza una nueva conexión y actualiza los datos.

4.4.2. JdbcRowSet

Esta implementación es la más sencilla de las tres. Simplemente es una capa por encima de ResultSet para que parezca y pueda ser utilizado como un JavaBean.

4.4.3. WebRowSet

El objetivo principal de esta implementación es la lectura y escritura de datos en formato XML. En una aplicación cliente-servidor, la parte del servidor es la que se encarga de consultar la base de datos y crear un objeto WebRowSet para a continuación crear un fichero XML que es el que se le envía al cliente. El cliente recibe los datos en formato XML, pero los puede manejar como si fuera un resultado de una consulta. Cuando actualiza algún dato y llama al método acceptChanges la información se envía de vuelta al servidor en formato XML y el servidor es el que se encarga de escribir en la base de datos. Esta implementación tiene sentido cuando trabajamos detrás de cortafuegos y/o proxys, debido a que las conexiones directas a base de datos no son siempre posibles. Esta clase dispone de dos métodos para leer y escribir datos en XML, ambos asociados a objetos que nos permiten leer y escribir.

java.io.FileWriter FW = new java.io.FileWriter("ejemplo.xml");
crs.writeXML (FW);

Cuando llamamos al método writeXML el contenido del RowSet se escribe en el fichero ejemplo.xml en formato XML. De la misma forma disponemos del método readXML asociado a un objeto de la clase java.io.FileReader. El método de escritura no funciona en la actual implementación.

4.4.4. Manejo de eventos

Otro característica importante de los RowSet son los eventos. Podemos definir una clase que actúe de escuchante, para que cuando se produzca un evento se realicen una serie de acciones definidas en el escuchante. El API JDBC especifica la interfaz javax.sql.RowSetListener. La interfaz permite especificar tres métodos, asociados a tres eventos distintos:

public void cursorMoved (RowSetEvent evento)
public void rowChanged (RowSetEvent evento)
public void rowSetChanged (RowSetEvent evento)

El primer método se invocará cuando el cursor se mueva. El cursor es un elemento que indica al objeto RowSet en qué fila se encuentra dentro del conjunto de resultado. Dentro de este método podemos realizar las acciones que queramos: notificar el evento a otro objeto, realizar alguna comprobación, cambiar la base de datos, etc. El segundo método se invocará cuando se cambie una fila y el tercero cuando cambie el RowSet. A continuación se muestra un ejemplo de definición de uno de los métodos:

import javax.sql.*;
import java.io.*;


public class Escuchante implements RowSetListener {
	public void cursorMoved (RowSetEvent evento) {
		System.out.println("Se ha movido el cursor");
	}
.
.
.

Para asociar el escuchante a una clase RowSet debemos hacer uso de la anterior clase:

Escuchante escucha = new Escuchante();
//crs es un objeto de tipo RowSet
crs.addRowSetListener(escucha);

Podemos añadir tantos escuchantes como queramos.