Este artículo versa sobre la tecnología IndexedDB, que resuelve una pieza importante del puzzle de aplicaciones: el almacenamiento y recuperación de datos de usuario en el lado del cliente.
¿Qué es IndexedDB?
Una IndexedDB es básicamente un almacén de datos persistente gestionado por el navegador, es decir, una base de datos del lado del cliente. Igual que ocurre con las bases de datos relacionales habituales, mantiene índices de los registros que almacena y los desarrolladores pueden utilizar el API IndexedDB de JavaScript para recuperar registros utilizando claves o moverse a lo largo de un índice. Cada base de datos se define por su "origen", o sea, el dominio del sitio web que genera la base de datos.IndexedDB es también un ejemplo muy ilustrativo de cómo evolucionan los estándares. Por medio de grupos de trabajo de estándares y el HTML5 Labs (un sitio web que publica prototipos de implementación de diversas especificaciones de HTML5 que podemos probar y comentar), IndexedDB pronto va a estar lista para su uso en los principales sitios web.
Si aún no conoces IndexedDB, puedes empezar por aquí:
- Demo en IETestDrive
- Guía de Desarrollo en MSDN
- Especificaciones del W3C
Configurando nuestro entorno de desarrollo
Empecemos con una instalación:- Descarga el prototipo pulsando en el enlace "Download the Protoype now!" desde esta página.
- Descomprime el archivo descargado.
- Si ejecutas una versión de Windows de 32 bits, ejecuta vcredist_x86.exe.
- Registra la dll "sqlcejse40.dll" ejecutando el siguiente comando desde la línea de comandos con privilegios de administrador:
regsvr32 sqlcejse40.dll
La Preliminar de Plataforma de Internet Explorer 10 ya viene con soporte para IndexedDB. También puedes hacerte con alguna de las últimas versiones de Google Chrome o Firefox y con esto tenemos ya todo lo necesario.
Creación de una aplicación de anotaciones offline
Vamos a crear una aplicación web para tomar notas con capa de datos en el lado del cliente:Desde el punto de vista del modelo de datos, no puede ser más simple. La aplicación permite al usuario escribir notas de texto y etiquetarlas con ciertas palabras clave. Cada nota tendrá un identificador exclusivo que le sirve de clave y, aparte del texto de la nota, se asociará con una serie de cadenas de texto que son las etiquetas.
Este es un ejemplo de objeto de nota representado en la notación concreta de objeto en JavaScript:
var note = {
id: 1,
text: "Note text.",
tags: ["sample", "test"]
};
Vamos a crear un objeto NotesStore con la siguiente interfaz:
var NotesStore = {
init: function(callback) {
},
addNote: function(text, tags, callback) {
},
listNotes: function(callback) {
}
};
Se entiende perfectamente lo que hace cada método. Todas las llamadas a métodos se ejecutan de manera síncrona (es decir, cuando los resultados se devuelven en forma de callbacks), y en los casos en que el resultado se devuelve al llamador, la interfaz acepta una referencia a un callback al cual se invoca con el resultado. Vamos a ver qué necesitamos para implementar este objeto de manera eficiente utilizando una base de datos indexada.
Pruebas con IndexedDB
El objeto básico con el que trabajamos realmente cuando hablamos del API IndexedDB se llama indexedDB. Podemos verificar si este objeto existe para determinar si el navegador soporta o no la funcionalidad IndexedDB. Por ejemplo:
if(window["indexedDB"] === undefined) {
// nope, no IndexedDB!
} else {
// yep, we're good to go!
}
Si no, podemos utilizar también la librería JavaScript llamada Modernizr para comprobar si tenemos soporte para IndexedDB:
if(Modernizr.indexeddb) {
// yep, go indexeddb!
} else {
// bleh! No joy!
}
Peticiones asíncronas
Las llamadas asíncronas al API funcionan mediante lo que denominamos objetos "request". Cuando se hace una llamada asíncrona a un API, debería devolver una referencia a un objeto "request" que expone dos eventos: onsuccess y onerror.El aspecto típico de una llamada sería así:
var req = someAsyncCall();
req.onsuccess = function() {
// handle success case
};
req.onerror = function() {
// handle error
};
Cuando empieces a trabajar en serio con el API indexedDB, puede que te resulte algo complicado mantener bajo control todos los callbacks. Para hacerlo todo algo más sencillo, voy a definir y emplear una pequeña rutina de utilidad que abstrae el patrón "request":
var Utils = {
errorHandler: function(cb) {
return function(e) {
if(cb) {
cb(e);
} else {
throw e;
}
};
},
request: function (req, callback, err_callback) {
if (callback) {
req.onsuccess = function (e) {
callback(e);
};
}
req.onerror = errorHandler(err_callback);
}
};
Ahora puedo escribir las llamadas asíncronas de esta forma:
Utils.request(someAsyncCall(), function(e) {
// handle completion of call
});
Creación y apertura de la base de datos
La creación y apertura de una base de datos se hace con el método open del objeto indexedDB.Esta es una implementación del método init del objeto NoteStore:
var NotesStore = {
name: "notes-db",
db: null,
ver: "1.0",
init: function(callback) {
var self = this;
callback = callback || function () { };
Utils.request(window.indexedDB.open("open", this.name), function(e) {
self.db = e.result;
callback();
});
},
...
El método open abre la base de datos si existe ya. Si no existe, la genera. Podemos considerar que este objeto representa la conexión a la base de datos. Cuando se destruye este objeto, la conexión a la base de datos se termina.
Ahora que ya existe la base de datos, vamos a crear el resto de objetos de la misma, pero antes tenemos que conocer algunos constructos importantes de IndexedDB.
Almacenes de objetos (Object stores)
Los "object stores" de IndexedDB son el equivalente a las tablas en las bases de datos relacionales. Todos los datos se guardan en estos almacenes de objetos y hacen las veces de unidad primaria de almacenamiento.Cada base de datos puede contener múltiples almacenes de objetos, y cada uno de ellos contiene una colección de registros. Cada registro es un simple par clave-valor. Las claves deben identificar de manera exclusiva a un registro particular y se pueden generar automáticamente. Los registros de un almacén de objetos se clasifican de forma automática por sus claves, en sentido ascendente. Y finalmente, los almacenes de objetos se pueden crear y borrar solo dentro del contexto de transacciones de "cambio de versión" (lo veremos más adelante).
Claves y valores
Cada registro en el almacén de objetos se identifica de forma unívoca con una clave. Las claves pueden ser arrays, cadenas, fechas o números. Sólo a título de comparación, los arrays son de mayor tamaño que las cadenas de texto, que a su vez son más grandes que las fechas y éstas, mayores que los números.Las claves pueden ser claves "en línea" o no. El término "en línea" se refiere a que le indicamos a IndexedDB que la clave de un registro concreto forma parte del propio valor del objeto. En nuestro ejemplo de registro de notas, por ejemplo, cada objeto de nota tiene una propiedad id que contiene el identificador único de una nota concreta. Es un ejemplo de clave "en línea", es decir, que la clave forma parte del valor del objeto.
Siempre que la clave sea "en línea" tenemos que indicar una "ruta de clave", una cadena que indica cómo debe extraerse el valor de la clave desde el valor del objeto.
La ruta de clave para los objetos "notes", por ejemplo, es la cadena "id" puesto que la clave se puede extraer de las instancias de las notas accediendo a la propiedad "id". Pero este esquema nos permite guardar el valor de la clave a cualquier profundidad dentro de la jerarquía de miembros del objeto. Veamos el siguiente ejemplo de instancia de objeto:
var product = {
info: {
name: "Towel",
type: "Indispensable hitchhiker item",
},
identity: {
server: {
value: "T01"
},
client: {
value: "TC01"
},
},
price: "Priceless"
};
En este caso, la ruta a la clave podría ser:
identity.client.value
Versiones de base de datos
Las bases de datos IndexedDB llevan asociada una cadena de texto con el indicador de versión. Esta cadena se puede utilizar en las aplicaciones web para saber si la base de datos que existe en un cliente concreto está conforme a la estructura más reciente o no.Es muy útil a la hora de hacer cambios en el modelo de datos de la base de datos y cuando se desea propagar tales cambios a los clientes actuales que tienen una versión anterior del modelo de datos. Basta con cambiar el número de versión en la nueva estructura y comprobar este valor la siguiente vez que el usuario ejecute la aplicación. Si es preciso, actualizaremos la estructura, migraremos los datos y cambiaremos el número de versión.
Los cambios en el número de versión se pueden hacer dentro del contexto de una transacción de "cambio de versión", pero antes de meternos en esto, vamos a ver brevemente en qué consisten las "transacciones".
Transacciones
Igual que en el caso de las bases de datos relacionales, IndexedDB realiza todas sus operaciones de E/S dentro del contexto de transacciones. Las transacciones se crean por medio de objetos de conexión y permiten el acceso y modificación de los datos de manera perdurable y atómica. Tenemos dos atributos esenciales en los objetos de transacción:- Ámbito: El ámbito determina qué partes de la base de datos pueden verse afectadas por la transacción. Esto sobre todo ayuda a la implementación de IndexedDB a definir el nivel de aislamiento que debe aplicar durante el tiempo de vida de la transacción. Podemos imaginar el ámbito como una sencilla lista de tablas (o "almacenes de objetos", como se las conoce aquí), que van a participar en la transacción.
- Modo: El modo de la transacción indica el tipo de operación de E/S que se admite en ella. El modo puede ser:
- Solo-lectura: Solo se admiten operaciones de lectura sobre los objetos incluidos en el ámbito de la transacción.
- Lectura/escritura: Admite operaciones de lectura y escritura sobre los objetos dentro del ámbito de la transacción.
- Cambio de versión: El modo de "cambio de versión" ("version change") nos permite efectuar operaciones de lectura y escritura, así como la creación y borrado de almacenes de objetos e índices.
- El momento en que se han completado
- Si se han interrumpido
- Si han caducado (time-out)
Creación de un almacén de objetos
Nuestra base de datos de almacén de objetos va a contener solo un almacén de objetos, donde vamos a guardar la lista de notas. Como decía anteriormente, los almacenes de objetos solo se pueden crear dentro del contexto de una transacción de tipo "cambio de versión".Vamos a ello, extendiendo el método init del objeto NotesStore para incluir la creación del almacén de objetos. He remarcado con negrita la parte del código que cambia.
var NotesStore = {
name: "notes-db",
store_name: "notes-store",
store_key_path: "id",
db: null,
ver: "1.0",
init: function (callback) {
var self = this;
callback = callback || function () { };
Utils.request(window.indexedDB.open("open", this.name), function (e) {
self.db = e.result;
// if the version of this db is not equal to
// self.version then change the version
if (self.db.version !== self.version) {
Utils.request(self.db.setVersion(self.ver), function (e2) {
var txn = e2.result;
// create object store
self.db.createObjectStore(self.store_name,
self.store_key_path,
true);
txn.commit();
callback();
});
} else {
callback();
}
});
},
...
Los almacenes de objetos se crean por medio de una llamada al método createObjectStore en el objeto de base de datos. El primer parámetro es el nombre del almacén de objetos. Va seguido por la cadena que identifica la ruta de la clave y finalmente un flag booleano que indica si la clave debe generarla automáticamente la base de datos a medida que se añadan nuevos registros.
Inserción de datos en los almacenes de objetos
Se pueden añadir nuevos registros a un almacén de objetos por medio de llamadas al método put del almacén de objetos. Se puede recuperar una referencia a la instancia del almacén de objetos desde el objeto de transacción. Vamos a implementar el método addNote para nuestro objeto NotesStore y vamos a ver cómo se añade un nuevo registro:
...
addNote: function (text, tags, callback) {
var self = this;
callback = callback || function () { };
var txn = self.db.transaction(null, TransactionMode.ReadWrite);
var store = txn.objectStore(self.store_name);
Utils.request(store.put({
text: text,
tags: tags
}), function (e) {
txn.commit();
callback();
});
},
...
El método se puede subdividir en los pasos siguientes:
- Invocación al método transaction del objeto de base de datos para iniciar una nueva transacción. EL primer parámetro lleva la lista de nombres de almacenes de objetos que van a participar en la transacción. Si el valor que pasamos es null, todos los almacenes de objetos de la base de datos se consideran dentro del ámbito. El segundo parámetro indica el modo de la transacción. Es simplemente una constante numérica que hemos declarado así:
- Después de crear la transacción, obtenemos una referencia al almacén de objetos en cuestión desde el método objectStore del objeto de transacción.
- Ahora que tenemos a mano el almacén de objetos, añadir un nuevo registro consiste simplemente en hacer una llamada asíncrona al método put del objeto de almacén, pasándole el nuevo objeto de datos que se debe insertar en el este. Es importante recordar que no le vamos a pasar un valor para el campo id del nuevo objeto de nota. Al haber indicado como "true" el parámetro de auto-generación de la clave en el momento de creación del almacén de objetos, nuestra implementación de IndexedDB se encargará de asignar automáticamente un identificador único al nuevo registro.
- Cuando la llamada asíncronas "put" termina de manera correcta, procedemos a confirmar la transacción con una sentencia "commit".
// IndexedDB transaction mode constants
var TransactionMode = {
ReadWrite: 0,
ReadOnly: 1,
VersionChange: 2
};
Consultas con cursores
La forma en que IndexedDB enumera los registros almacenados en un almacén de objetos es por medio de un objeto "cursor". Un cursor nos permite realizar procesos iterativos sobre los registros de objeto de almacén o un índice. Un cursor tiene, como propiedades más destacadas:- Un rango de registros, bien en un índice o en un almacén de objetos.
- Un origen, o referencia al índice o el almacén de objetos sobre el cual realiza la iteración el cursor.
- Una posición que informa del punto en el cual se encuentra el cursor dentro del rango de registros.
listNotes: function (callback) {
var self = this,
txn = self.db.transaction(null, TransactionMode.ReadOnly),
notes = [],
store = txn.objectStore(self.store_name);
Utils.request(store.openCursor(), function (e) {
var cursor = e.result,
iterate = function () {
Utils.request(cursor.move(), function (e2) {
// if "result" is true then we have data else
// we have reached end of line
if (e2.result) {
notes.push(cursor.value);
// recursively get next record
iterate();
}
else {
// we are done retrieving rows; invoke callback
txn.commit();
callback(notes);
}
});
};
// set the ball rolling by calling iterate for the first row
iterate();
});
},
Veamos los pasos que hemos seguido en esta implementación:
- Primero obtenemos un objeto de transacción llamando al método transaction del objeto de base de datos. En esta ocasión le indicamos que necesitamos una transacción en modo "read-only".
- Después obtenemos una referencia al almacén de objetos con el método objectStore del objeto de transacción.
- A continuación se lanza una llamada asíncrona al API openCursor del almacén de objetos. Lo que más complica el proceso aquí es que cada una de las iteraciones sobre un registro del cursor es una operación asíncrona ella misma. Para que el código no naufrague en un mar de callbacks, definimos una función local llamada iterate para encapsular la lógica de la iteración sobre cada uno de los registros del cursor.
- Esta función iterate realiza la llamada asíncrona al método move del objeto cursor y se invoca de manera recursiva a sí misma de nuevo en el callback si detecta que aún hay más filas disponibles para leer. Una vez que se han extraído todas las filas del cursor, se hace una llamada final al método callback pasado por el llamador utilizando como parámetro los datos obtenidos.
¡Podemos ir más lejos todavía!
A pesar de lo que puedas pensar ahora, no hemos visto, ni mucho menos, el API en toda su extensión. Tan solo hemos revisado:- Las opciones disponibles hoy día para implementar un almacén de datos en el lado del cliente
- Los aspectos más relevantes del API IndexedDB, como son:
- Cómo comprobar si el navegador lo soporta
- Gestionar las llamadas asíncronas al API
- Crear y abrir bases de datos
- Las partes más importantes del API, como son los almacenes de objetos, los pares clave/valor, control de versiones y transacciones
- Cómo se crean almacenes de objetos
- Cómo se insertan nuevos registros en los almacenes de objetos
- Cómo se utilizan los cursores para movernos por los registros de los almacenes
Y ahora, si tienes ganas de seguir adelante, el documento de especificación del W3C es una buena referencia y ¡es tan escueto que se lee perfectamente! Yo te recomiendo que pruebes esta nueva tecnología, ya que tener acceso a una base de datos funcional en el lado del cliente nos abre todo un mundo de nuevos escenarios para nuestras aplicaciones web.
Otro recurso muy interesante es el Ejemplo de IndexedDB/AppCache en el sitio web IE Test Drive. Este ejemplo describe un escenario donde estas dos especificaciones se complementan mutuamente para conseguir una experiencia de usuario avanzada, ¡aunque no esté conectado a Internet! El ejemplo hace también una demostración de nuevas funcionalidades de IE10, como las transiciones y transformaciones 3D basadas en CSS3.
Rajasekharan Vengalil
Evangelista Desarrollador en Microsoft