Os navegadores do mercado suportam o banco de dados IndexedDB, especificado pela W3C. Todavia, realizar operações de persistência através de sua API é uma tarefa um tanto árdua. Neste artigo implementaremos o padrão de projeto Data Mapper para reduzir bastante a complexidade da API do IndexedDB. É necessário que o leitor tenha algum conhecimento desta API para que aproveite melhor este artigo.

A motivação para a escrita deste artigo veio de um coding dojo no qual o autor foi questionado por um dos participantes o motivo de não ter abordado o pattern Data Mapper em seu livro Cangaceiro JavaScript, uma aventura no sertão da programação. Como resposta lhe foi dito que nas mais de 500 páginas do livro inevitavelmente algum assunto teria que ficar de fora. Foi então que o participante perguntou se era possível implementá-lo durante o dojo, tarefa que foi aceita por este autor e implementada em 7 minutos.

É importante frisar que a implementação foi realizada em 7 minutos, porém as discussões sobre a API a ser criada levaram aproximadamente 20 minutos. Outro ponto é que apenas as operações de persistência save() e list() foram implementadas.

Agora que já sabemos os eventos que antecederam a escrita deste artigo, vamos ao problema a ser resolvido.

O problema

Nossa aplicação define as classes Person e Animal:

// app/person.js
export class Person {
    constructor(name) {
        this._name = name;
    }

    get name() {
        return this._name;
    }
}
// app/animal.js
export class Animal {
    constructor(name) {
        this._name = name;
    }

    get name() {
        return this._name
    }
}

Precisamos persistir instâncias dessas classes no IndexedDB, um banco de dados presente nos navegadores do mercado:

// app/app.js
import { Person } from './Person.js';
import { Animal } from './Animal.js';

const person = new Person('Flávio Almeida');
const animal = new Animal('Calopsita');
// como realizar a persistência no banco IndexedDB?

Podemos obter uma conexão do banco, criar transações e lidar com a persistência no módulo app/app.js, mas queremos uma solução que facilite a vida do desenvolvedor e que também possa ser reutilizada pela aplicação. Nesse sentido, podemos aplicar o padrão de projeto Data Mapper.

O padrão de projeto Data Mapper

Segundo a Wikipedia um Data Mapper é uma camada de acesso a dados que executa transferência bidirecional de dados entre um armazenamento de dados persistente e uma representação de dados na memória. Quanto maior a impedância entre esses dois mundos, maior trabalho o Data Mapper terá que realizar.

Impedância é a discrepância entre os dados em memória e os mesmos dados armazenados no banco.

Vamos criar uma API que isole a complexidade de persistência com o IndexedDB. Depois de pronta, ela funcionará dessa maneira:

// app/app.js
import { manager } from './manager.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';

(async () => {
    // configuração mínima
    await manager
        .setDbName('cangaceiro')
        .setDbVersion(2) 
        .register(
            { 
                clazz: Person,
                converter: data => new Person(data._name)
            },
            { 
                clazz: Animal,
                converter: data => new Animal(data._name)
            }
        );

    // criando instâncias
    const person = new Person('Flávio Almeida');
    const animal = new Animal('Calopsita');

    // persistindo dados
    await manager.save(person);
    await manager.save(animal);

    // buscando dados persistidos
    const persons = await manager.list(Person);
    persons.forEach(console.log);
    const animals = await manager.list(Animal);
    animals.forEach(console.log);

})().catch(console.log);

Sobre os métodos do objeto manager, nosso Data Mapper, temos:

  • setDbName: define o nome do banco de dados. Quando omitido, será adotado o nome “default”. Seu retorno será uma referência para a própria instância de manager.

  • setDbVersion: define a versão o banco. Quando informado uma versão superior a vigente, todas as stores serão destruídas e criadas novamente. Quando não informado, o valor 1 será o padrão utilizado. Seu retorno será uma referência para a própria instância de manager.

  • register: recebe uma lista de objetos de mapeamento com as propriedades clazz e converter. A primeira recebe a classe que terá uma store criada no banco. A segunda é a lógica de mapeamento dos dados retornados do banco para sua respectiva classe que será utilizado pelo método list(). As stores serão criadas apenas quando o banco for criado pela primeira vez ou quando a versão informada por setDbVersion for superior a versão vigente do banco. Seu retorno será uma Promise.

  • save: recebe a instância do objeto que desejamos persistir. Internamente identifica a classe a qual o objeto pertence para decidir em qual store deve ser salvo. Retorna uma Promise.

  • list: recebe como primeiro parâmetro a classe que representa a store que desejarmos obter todos os seus dados armazenados.

Um ponto a se destacar é que podemos usar a mesma conexão durante toda a aplicação sem que seja necessário fechá-la.

Agora que já sabemos até onde queremos chegar, vamos dar início a nossa implementação.

Implementando nosso manager

Vamos criar o módulo app/manager.js que terá em seu escopo as variáveis dbName e dbVersion, ambas inicializadas com valores padrões. Em seguida, definiremos a classe Manager com métodos acessadores para essas variáveis encapsuladas pelo módulo. No lugar de exportarmos a classe Manager, vamos exportar uma instância dessa classe:

// app/manager.js
// variáveis encapsuladas pelo módulo

let dbName = 'default';
let dbVersion = 1;

class Manager {

    setDbName(name) {
        dbName = name;
        // retorna a própria instância
        return this; 
    }

    setDbVersion(version) {
        dbVersion = version;
        // retorna a própria instância
        return this; 
    }
}
// exporta a instância da classe
export const manager = new Manager();

Declarar uma variável no escopo do módulo sem exportá-la com a instrução export torna sua visibilidade privada, desta forma, apenas a instância de manager exportada pelo módulo terá acesso à variável, como é o caso das variáveis dbName e dbVersion.

Um ponto a destacar é o return this dos métodos da classe que criamos até agora. É esse retorno que permitirá o encadeamento das chamadas desses métodos.

Vamos criar a página index.html que importará o módulo app/app.js utilizando o sistema de importação de módulos nativo do Chrome:

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Manager</title>
</head>
<body>
<!-- importou o módulo -->
<script type="module" src="app/app.js"></script>
</body>
</html>

É necessário servir a página através de um servidor Web de sua escolha, caso contrário o carregamento dos módulos não funcionará.

Vejamos como fica o uso inicial do nosso manager no módulo app/app.js:

// app/app.js
import { manager } from './manager.js';
// encadeando as chamadas de métodos
manager
.setDbName('cangaceiro')
.setDbVersion(2)

Todos os módulos que importarem nosso manager trabalharão com a mesma instância. Isso é importante, pois precisamos ter centralizado em um único local sua configuração. Nesse sentido, podemos dizer que manager implementa o padrão de projeto Singleton, porém, utilizando os próprios recursos da linguagem sem grandes mistérios como no pattern original.

Agora vamos partir para a implementação de um dos métodos mais importante da nossa API de persistência, o método register.

Implementando o método register

Vamos implementar agora a função register, uma dos mais importantes métodos do nosso manager:

// app/manager.js
let dbName = 'default';
let dbVersion = 1;
// novo dado!
const stores = new Map(); 

class Manager {

    setDbName(name) {
        dbName = name;
        return this;
    }

    setDbVersion(version) {
        dbVersion = version;
        return this;
    }
    // novo método
    register(...mappers) {
        mappers.forEach(mapper => 
            stores.set(
                mapper.clazz.name, 
                mapper.converter
            )
        );
    }
}

export const manager = new Manager();

É necessário que o leitor tenha algum conhecimento da API do IndexedDB, por mais ínfimo que seja para que compreenda com mais clareza as vantagens da abordagem aqui utilizada.

A função register, através do Rest Operator, recebe uma quantidade indefinida de objetos que chamaremos de mappers (mapeadores). Cada objeto identifica a classe e a lógica de conversão a ser aplicada toda vez que objetos dessa classe forem obtidos de uma store.

Objetos recuperados da store só possuem propriedades e nenhum método, por isso é importante definir a lógica de conversão dos dados trazidos do banco para sua respectiva classe.

Para cada item da lista de mappers recebida utilizaremos o valor da propriedade clazz como key e o valor da propriedade converter como seu valor no Map batizado de stores. Esse dado é importante, pois é através dele que saberemos quais stores serão criadas e qual lógica de conversão será utilizada ao obter seus dados.

Nosso app/app.js por enquanto estará assim:

// app/app.js
import { manager } from './manager.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';

manager
.setDbName('cangaceiro')
.setDbVersion(2) 
.register(
    { 
        clazz: Person,
        converter: data => new Person(data._name)
    },
    { 
        clazz: Animal,
        converter: data => new Animal(data._name)
    }
);

Excelente, mas no ato de realizarmos o registro precisamos criar a conexão com o banco.

Obtendo uma conexão

Vamos criar uma função responsável pela criação da conexão. Ela viverá no escopo do módulo manager.js e apenas a instância de Manager poderá acessá-la.

Como a obtenção de uma conexão é uma operação assíncrona, retornaremos uma Promise como resposta, reduzindo assim a complexidade de termos que trabalhar com callbacks.

Vamos criar a variável conn, também encapsulada pelo módulo manager.js. Ela guardará uma referência para a conexão criada. Seu valor será atribuído através da função createConnection:

let dbName = 'default';
let dbVersion = 1;
const stores = new Map(); 
// nova variável
let conn = null;

const createConnection = () => 
    new Promise((resolve, reject) => {
    
    });

// definição da classe omitido

Vamos solicitar à função indexedDB.open() a abertura da conexão com o banco usando as configurações definidas em dbName e dbVersion. Porém, não temos a garantia de que a conexão foi efetuada, motivo pelo qual precisamos lidar com os eventos onupgradeneeded, onsuccess e onerror:

let dbName = 'default';
let dbVersion = 1;
const stores = new Map(); 
let conn = null;

const createConnection = () => 
    new Promise((resolve, reject) => {
        // requisitamos a abertura, um evento assíncrono!
        const request = indexedDB.open(dbName, dbVersion);

        request.onupgradeneeded = e => {};

        request.onsuccess = e => {};

        request.onerror = e => {};
    });

// código posterior omitido

O evento onupgradeneeded disponibiliza uma conexão transacional que nos permite criar todas as stores do banco. Utilizaremos as chaves do nosso Map stores como nome das stores:

let dbName = 'default';
let dbVersion = 1;
const stores = new Map(); 
let conn = null;

const createConnection = () => 
    new Promise((resolve, reject) => {

        const request = indexedDB.open(dbName, dbVersion);

        request.onupgradeneeded = e => {

            const transactionalConn = e.target.result;
            // utilizando for...of e destructuring ao mesmo tempo
            // para ter acesso ao valor da key
            for (let [key, value] of stores) {
                const store = key;
                // se já existe, apagamos
                if(transactionalConn.objectStoreNames.contains(store)) 
                    transactionalConn.deleteObjectStore(store);
                transactionalConn.createObjectStore(store, { autoIncrement: true });
            }     
        };

        request.onsuccess = e => {
            conn = e.target.result; // guarda uma referência para a conexão
            resolve(); // tudo certo, resolve a Promise!
        }
        // lida com erros, retornando uma mensagem de alto nível
        request.onerror = e => {
            console.log(e.target.error);
            reject('Não foi possível obter a conexão com o banco');
        }; 
    });
// código posterior omitido

A estratégia utilizada durante um eventual upgrade do banco é derrubar todas as stores para em seguida criá-las novamente.

Excelente, agora precisamos chamar a função createConnection através do método register de Manager. Vamos tornar o método async para que possamos utilizar a instrução await com a Promise retornada por createConnection:

// app/manager.js
// código anterior omitido

class Manager {

    setDbName(name) {
        dbName = name;
        return this;
    }

    setDbVersion(version) {
        dbVersion = version;
        return this;
    }
    // tornou o método async
    async register(...mappers) {
        mappers.forEach(mapper => 
            stores.set(mapper.clazz.name, mapper.converter));
        // utilizou a instrução await, só é possível porque
        // register é um método async
        await createConnection();
    }
}

export const manager = new Manager();

Agora, em app/app.js, criaremos um wrapper async para que possamos utilizar a instrução await com o método register:

// app/app.js
import { manager } from './manager.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';
// wrapper async
(async () => {
    // instrução await!
    await manager
        .setDbName('cangaceiro')
        .setDbVersion(2) 
        .register(
            { 
                clazz: Person,
                converter: data => new Person(data._name)
            },
            { 
                clazz: Animal,
                converter: data => new Animal(data._name)
            }
        );

})().catch(console.log); // captura possíveis erros

Toda função async retorna uma Promise, independente se retornamos uma ou não. É por isso que precisamos usar await na chamada de manager.register. Além disso, lidamos com qualquer exceção através ().catch(console.log);. Outra alternativa era usarmos a instrução try/catch no bloco do código, porém o autor preferiu a primeira abordagem por ser menos verbosa, além do tempo do dojo ter sido curto!

Recarregando a página index.html no Chrome, na aba Application -> Storage -> IndexedDB podemos constatar a criação do banco.

Abra outra aba no Chrome para que possa verificar os dados mais atualizados, pois este autor constatou que o Chrome não realiza o refresh automático da aba Application -> Storage -> IndexedDB.

stores criadas no banco cangaceiro

Agora vamos implementar o método save.

Implementando o método save

O método save da nossa classe Manager também retornará uma Promise, pois a operação de persistência também é uma operação assíncrona:

// app/manager.js
// código anterior omitido 
class Manager {
    // métodos anteriores omitidos
    save(object) {
        
        return new Promise((resolve, reject) => {

            // falhando rapidamente, "fail fast"
            if(!conn) return reject('Você precisa registrar o banco antes de utilizá-lo');
            
            // obtem o nome da store através do nome da classe
            const store = object.constructor.name;
            
            const request = conn
                .transaction([store],"readwrite")
                .objectStore(store)
                .add(object);
            
            // resolve a Promise no sucesso
            request.onsuccess = () => resolve();

            request.onerror = e => {
                console.log(e.target.error);
                reject('Não foi possível persistir o objeto');
            };
        });
    }
} 

Vamos analisar o código anterior. A Promise retornada verifica se a variável conn possui algum valor e caso não possua, rejeitamos a Promise imediatamente. Esse fail fast é importante, pois sinalizará para o desenvolvedor que ele deve realizar antes o registro do banco.

O restante do código é padrão da API do IndexedDB, todavia vale lembrar que precisamos solicitar uma transação de escrita para a store que desejamos armazenar nosso objeto. Aliás, obtemos o nome da store do objeto a ser persistido através de object.constructor.name:

Agora chegou a hora de implementarmos o método list(), aquele que retornará os dados persistidos.

Implementando o método list

Assim como o método save(), o método list() retornará uma Promise. Ele receberá como parâmetro uma classe e, a partir dela, extrairá o nome da sua respectiva store. Sabemos que o nome da store é a key do Map stores. É através dessa key que temos acesso a lógica de conversão, aquela que sabe converter os dados retornados da store para uma instância da sua respectiva classe.

// app/manager.js
// código anterior omitido 
class Manager {
    // métodos anterior omitidos
    list(clazz) {
        
        return new Promise((resolve, reject) => {
            // Identifica a store
            const store = clazz.name;
            // Cria uma transação de escrita
            const transaction = conn
                .transaction([store],'readwrite')
                .objectStore(store); 
            
            const cursor = transaction.openCursor();
            // Converter da store
            const converter = stores.get(store);
            // Array que receberá os dados convertidos
            // com auxílio do nosso converter
            const list = [];
            // Será chamado uma vez para cada 
            // objeto armazenado no banco
            cursor.onsuccess = e => {
    
                const current = e.target.result;
                // Se for null, não há mais dados
                 if(current) {
                     list.push(converter(current.value));
                     // vai para o próximo registro
                     current.continue();
                } else resolve(list);
            };
    
            cursor.onerror = e => {
                console.log(target.error);
                reject(`Não foi possível lista os dados da store ${store}.`);
            };  
        });    
    }
}

Excelente, nosso módulo app/app.js ficará assim:

import { manager } from './manager.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';

(async () => {
    await manager
        .setDbName('cangaceiro')
        .setDbVersion(2) 
        .register(
            { 
                clazz: Person,
                converter: data => new Person(data._name)
            },
            { 
                clazz: Animal,
                converter: data => new Animal(data._name)
            }
        );
        
    const person = new Person('Flávio Almeida');
    const animal = new Animal('Calopsita');

    await manager.save(person);
    await manager.save(animal);

    const persons = await manager.list(Person);
    persons.forEach(console.log);

    const animals = await manager.list(Animal);
    animals.forEach(console.log);

})().catch(console.log);

Melhor organizado do que se tivéssemos utilizado diretamente a API do IndexedDB. Você pode conferir o código completo deste artigo no meu github.

Conclusão

O padrão de projeto Data Mapper pode ser aplicado não apenas com o IndexedDB, mas qualquer meio de persistência, inclusive banco de dados utilizados por API.

E você? Já utilizou alguma biblioteca que faz uso desse padrão? Como lidava com persistência no IndexedDB? Deixe sua opinião.