IndexedDB, implementando a persistência com o pattern Data Mapper
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.
Prólogo
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()
elist()
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 valor1
será o padrão utilizado. Seu retorno será uma referência para a própria instância demanager
. -
register: recebe uma lista de objetos de mapeamento com as propriedades
clazz
econverter
. A primeira recebe a classe que terá umastore
criada no banco. A segunda é a lógica de mapeamento dos dados retornados do banco para sua respectiva classe que será utilizado pelo métodolist()
. Asstores
serão criadas apenas quando o banco for criado pela primeira vez ou quando a versão informada porsetDbVersion
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 demanager
exportada pelo módulo terá acesso à variável, como é o caso das variáveisdbName
edbVersion
.
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 quemanager
implementa o padrão de projetoSingleton
, 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 demanager.register
. Além disso, lidamos com qualquer exceção através().catch(console.log);
. Outra alternativa era usarmos a instruçãotry/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.
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.