IndexedDB, implementando a persistência com o pattern Active Record
No artigo “IndexedDB, implementando a persistência com o pattern Data Mapper” implementamos o padrão de projeto Data Mapper para persistir objetos com o IndexedDB. Desta vez, implementaremos a solução de persistência com o pattern Active Record.
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 Active Record.
O padrão de projeto Active Record
Segundo Martin Fowler:
“Um objeto traz dados e comportamento. Grande parte desses dados é persistente e precisa ser armazenado em um banco de dados. O Active Record usa a abordagem mais óbvia, colocando lógica de acesso a dados no objeto de domínio. Desta forma, todas as pessoas sabem como ler e escrever seus dados de e para o banco de dados”. - Patterns of Enterprise Application Architecture.
Vamos criar uma API que implemente o pattern Active Record com a finalidade de isolar a complexidade de persistência com o IndexedDB. Depois de pronta, ela funcionará dessa maneira:
// app/app.js
import { activeRecord } from './active-record.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';
(async () => {
await activeRecord({
name: 'cangaceiro',
version: 2,
mappers: [
{
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');
// As instâncias sabem se persistir
await person.save();
await animal.save();
// Operações que não operam diretamente em instâncias
// são invocadas através de métodos estáticos da classe
const persons = await Person.list();
persons.forEach(console.log);
const animals = await Animal.list();
animals.forEach(console.log);
})().catch(console.log);
O padrão Active Record tem uma forte relação com bancos relacionais. Todavia, nada nos impede de ampliarmos seu uso para bancos noSQL ou orientados a documentos. Por mais que a estrutura de dados desses bancos seja semelhante ao JSON, ainda há impedância (discrepância) entre os dados armazenados no banco (dado) e os mesmos dados em memória (dado e comportamento).
A função activeRecord receberá um objeto com as configurações necessárias para nossa solução de persistência e retornará uma Promise. Sobre as propriedades de configuração temos:
- name: define o nome do banco. Quando omitida será adotado como nome a string “default”.
- version: define a versão do banco. Quando omitida será adotado
1como versão. - mappers: contém uma lista de objetos de mapeamento com as propriedades
clazzeconverter. A primeira recebe a classe que terá umastorecriada no banco. A segunda é a lógica de mapeamento dos dados retornados do banco para sua respectiva classe que será utilizada pelo métodolist().
Internamente, activeRecord criará as stores do banco, inclusive é durante a chamada desta função que o prototype das classes definidas na lista de mappers será modificado para incluir a operação de persistência save(). Já o método list() será adicionado como método estático na própria classe.
Diferente do post deste mesmo autor sobre a implementação de uma solução de persistência através do pattern Data Mapper, o autor decidiu abdicar do pattern
Builderutilizado para atribuir as configurações de persistência. O motivo é que passar um objeto como configuração é muito menos verboso e cumpre seu papel indicando as propriedades de configuração.
Sobre os métodos de persistências temos:
-
save: persiste a instância do objeto no qual o método foi invocado. Internamente identifica a classe a qual o objeto pertence para decidir em qual
storedeve ser salvo. Retorna uma Promise. -
list: método estático da classe que internamente identifica sua respectiva
storeretornando os dados persistidos. Faz uso doconverterpara guiar o processo de transformação dos dados armazenados em instâncias da classe.
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 a função activeRecord
Vamos criar o módulo app/active-record.js que terá em seu escopo o objeto dbConfig. Suas propriedades serão inicializadas com valores padrões. Em seguida, definiremos a função activeRecord que terá acesso ao objeto dbConfig. A função activeRecord será o único artefato exportado pelo módulo active-record.js:
// app/active-record.js
// configuração do banco encapsuladas no módulo
const dbConfig = {
name: 'default',
version: 1,
stores: new Map()
};
export const activeRecord = ({ name, version, mappers }) => {
// passou as configurações para o objeto dbConfig
dbConfig.version = version;
dbConfig.name = name;
// processa a lista de mappers
mappers.forEach(mapper =>
dbConfig.stores.set(mapper.clazz.name, mapper.converter)
);
};
Atribuímos às propriedades dbConfig.version e dbConfig.name a versão do banco e seu nome respectivamente. Por fim, para cada item da lista de mappers recebida utilizaremos o valor da propriedade clazz.name como key e o valor da propriedade converter como seu valor no Map dbConfig.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.
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.
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>Active Record</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 da função activeRecord no módulo app/app.js:
// app/app.js
import { activeRecord } from './active-record.js';
activeRecord({
name: 'cangaceiro',
version: 2,
mappers: [
{
clazz: Person,
converter: data => new Person(data._name)
},
{
clazz: Animal,
converter: data => new Animal(data._name)
}
]
});
Todos os módulos que importarem nosso
activeRecordtrabalharão com a mesma instância.
Excelente, mas no ato de passarmos as configurações para a função activeRecord 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 active-record.js e apenas o objeto activeRecord 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 propriedade conn no objeto dbConfig. Ela guardará uma referência para a conexão criada. Seu valor será atribuído através da função createConnection:
// app/active-record.js
const dbConfig = {
name: 'default',
version: 1,
stores: new Map(),
conn: null // nova propriedade
};
const createConnection = () =>
new Promise((resolve, reject) => {
});
// definição da função activeRecord omitida
Vamos solicitar à função indexedDB.open() a abertura da conexão com o banco usando as configurações definidas em dbConfig.name e dbConfig.version. 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:
// app/active-record.js
const dbConfig = {
name: 'default',
version: 1,
stores: new Map(),
conn: null
};
const createConnection = () =>
new Promise((resolve, reject) => {
// requisitamos a abertura, um evento assíncrono!
const request = indexedDB.open(dbConfig.name, dbConfig.version);
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:
//app/active-record.js
const dbConfig = {
name: 'default',
version: 1,
stores: new Map(),
conn: null
};
const createConnection = () =>
new Promise((resolve, reject) => {
const request = indexedDB.open(dbConfig.name, dbConfig.version);
request.onupgradeneeded = e => {
const transactionalConn = e.target.result;
// utilizando for...of e destructuring ao mesmo tempo
// para ter acesso facilitado à key do Map
for (let [classKey] of dbConfig.stores) {
const store = classKey;
// se já existe, apagamos a store
if(transactionalConn.objectStoreNames.contains(store))
transactionalConn.deleteObjectStore(store);
transactionalConn.createObjectStore(store, { autoIncrement: true });
}
};
request.onsuccess = e => {
dbConfig.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 da função ActiveRecord. Vamos tornar a função async para que possamos utilizar a instrução await com a Promise retornada por createConnection:
// app/active-record.js
// código anterior omitido
// tornou a função async
export const activeRecord = async ({ name, version, mappers }) => {
dbConfig.version = version;
dbConfig.vame = name;
mappers.forEach(mapper =>
dbConfig.stores.set(mapper.clazz.name, mapper.converter)
);
// awaitter na função createConnection
await createConnection();
}
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 { activeRecord } from './active-record.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';
// wrapper async
import { activeRecord } from './active-record.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';
(async () => {
await activeRecord({
name: 'cangaceiro',
version: 2,
mappers: [
{
clazz: Person,
converter: data => new Person(data._name)
},
{
clazz: Animal,
converter: data => new Animal(data._name)
}
]
});
})().catch(console.log);
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 a função save
A função save da nossa classe ActiveRecord também retornará uma Promise, pois a operação de persistência também é uma operação assíncrona:
// app/active-record.js
const dbConfig = {
name: 'default',
version: 1,
stores: new Map(),
conn: null
};
const createConnection = () =>
// código omitido
);
const save = async function() {
return new Promise((resolve, reject) => {
if(!dbConfig.conn) return reject('Você precisa registrar o banco antes de utilizá-lo');
const object = this;
const store = this.constructor.name;
const request = dbConfig.conn
.transaction([store],"readwrite")
.objectStore(store)
.add(object);
request.onsuccess = () => resolve();
request.onerror = e => {
console.log(e.target.error);
reject('Não foi possível persistir o objeto');
};
});
};
// função activeRecord omitida
Vamos analisar o código anterior. A Promise retornada verifica se a variável dbConfig.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.
Agora chegou a hora de implementarmos a função list(), aquela que retornará os dados persistidos.
Implementando o método list
Assim como a função save(), a função 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/active-record.js
// código anterior omitido
const list = async function() {
return new Promise((resolve, reject) => {
const store = this.name;
const transaction = dbConfig.conn
.transaction([store],'readwrite')
.objectStore(store);
const cursor = transaction.openCursor();
const converter = dbConfig.stores.get(store);
const list = [];
cursor.onsuccess = e => {
const current = e.target.result;
if(current) {
list.push(converter(current.value));
current.continue();
} else resolve(list);
};
cursor.onerror = e => {
console.log(target.error);
reject(`Não foi possível lista os dados da store ${store}.`);
};
});
}
Ótimo, para concluirmos o módulo active-record.js, precisamos adicionar a função save como método no prototype das classes que representarão nossas stores para que esteja disponível em todas as instâncias dessas classes. Já o a função list() será adicionada na própria classe, como método estático. Realizamos os passos que faltam na função activeRecord:
// app/active-record.js
// código anterior omitido
export const activeRecord = async ({ name, version, mappers }) => {
dbConfig.version = version;
dbConfig.name = name;
mappers.forEach(mapper => {
dbConfig.stores.set(mapper.clazz.name, mapper.converter);
// Adiciona save ao proprotype da classe para
// que esteja disponível em todas as instâncias
// da classe.
mapper.clazz.prototype.save = save;
// Adiciona list diretamente na classe como método estático
mapper.clazz.list = list;
});
await createConnection();
}
Excelente, nosso módulo app/app.js ficará assim:
import { activeRecord } from './active-record.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';
(async () => {
await activeRecord({
name: 'cangaceiro',
version: 2,
mappers: [
{
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');
// salva o método da instância
await person.save();
await animal.save();
// chama o método estático
const persons = await Person.list();
persons.forEach(console.log);
const animals = await Animal.list();
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 Active Record 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.