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
1
como versão. - mappers: contém 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á 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
Builder
utilizado 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
store
deve ser salvo. Retorna uma Promise. -
list: método estático da classe que internamente identifica sua respectiva
store
retornando os dados persistidos. Faz uso doconverter
para 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
activeRecord
trabalharã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.