Migração de Banco de Dados sob Demanda
Nos últimos anos, os times agéis tem adotado a técnica chamada Evolutionary Database Design, que basicamente consiste em evoluir o schema do banco de dados gradualmente durante a fase de desenvolvimento e automatizar as mudanças através de scripts que geralmente rodam durante o processo de deploy da aplicação.
Já existe um grande número de bibliotecas que facilita esta automatização durante o deploy e que atende muito bem a maioria dos projetos.
Porém, um dos problemas de rodar os scripts durante o deployment é o downtime. A aplicação precisa ficar indisponível até que todas as migrações sejam executadas para posteriormente o deploy ser efetuado.
Dependendo da quantidade de dados e migrações, o downtime pode ser grande o suficiente para ser ser considerado inaceitável para algumas situações.
O objetivo deste artigo é oferecer uma alternativa que não precise necessariamente que a aplicação fique fora do ar.
Para exemplificar esta técnica, foi-se utilizada a seguinte tech stack: Java 8, Spring Data MongoDB e o MongoDB.
O fato de usar um banco noSQL como o MongoDB é essencial a execução desta técnica, pois trata-se de um banco de dados schemaless, o que nos permite ter na mesma coleção documentos com estruturas diferentes.
Digamos que existe uma coleção chamada people com a seguinte estrutura:
{
"_id": ObjectId("507f1f77bcf86cd799439011"),
"name": "Marcos Sampaio",
"telephone": "14159363485"
}
class Person {
UUID id;
String name;
String telephone;
}
Inicialmente o cliente achou que cada pessoa deveria ter apenas um telefone. Depois decidiu-se que ao invés de armazenar apenas um telefone, pode-se armazenar múltiplos.
Para atender a mudança, o time decidiu que deveria escrever uma migração para mudar o “schema lógico” e o estrutura ficará assim:
{
"_id": ObjectId("507f1f77bcf86cd799439011"),
"name": "Marcos Sampaio",
"telephones": ["14159363485"]
}
class Person {
UUID id;
String name;
List<String> telephones;
}
Como as coisas serão feitas “on the fly”, cada documento terá que guardar uma versão de si mesmo, para sabermos se a migração deverá ser aplicada. Então será adicionado o campo “migrationVersion” em cada um deles.
{
"_id": ObjectId("507f1f77bcf86cd799439011"),
"name": "Marcos Sampaio",
"telephones": ["14159363485"],
"migrationVersion": 1
}
A classe de domínio (Person) deve entender apenas a “ultima versão” do schema lógico de dados. Por tanto, ela é agnostica em relação ao campo migrationVersion e ao fato de existir múltiplas versões no banco.
Neste padrão, deve-se introduzir uma camada entre o repositório e o banco de dados. O objetivo desta camada é garantir a conversão do documento para o objeto de domínio, não importando em qual “versão” este documento esteja.
public class PersonMongodbListener extends AbstractMongoEventListener<Person> {
private MigrationOnTheFly migrationOnTheFly;
private int currentMigrationVersion;
public PersonMongodbListener(MigrationOnTheFly migrationOnTheFly, int currentMigrationVersion) {
this.migrationOnTheFly = migrationOnTheFly;
this.currentMigrationVersion = currentMigrationVersion;
}
@Override
public void onBeforeSave(BeforeSaveEvent<Person> event) {
this.migrationOnTheFly.migrateVersion(event.getDBObject());
}
@Override
public void onAfterLoad(AfterLoadEvent<Person> event) {
this.migrationOnTheFly.migrate(event.getDBObject());
}
}
O evento onAfterLoad é um evento que ocorre entre o processo de leitura do banco e a conversão do dbobject em um objeto de domínio. É nessa fase que será executada todas as migrações que ainda não foram aplicadas para aquele documento/dbobject pois o objeto de domínio só entende a estrutura final. É importante frisar que essas migrações não serão salvas no banco nesse momento.
No método onBeforeSave, atribui-se a versão final naquele documento. Como o objeto de domínio só entende a estrutura final, quando vamos salvar, faz sentido que sempre salve com migrationVersion={latest}.
Com isso, garantimos que toda vez que o objeto for lido, ele vai ser convertido para algo que o domínio entende e toda vez que for salvo, já estará no schema lógico final. Deste modo, a migração é feita de maneira suave, sem impactar o downtime do sistema e sem poluir as classes de domínio.
Abaixo segue a implementação da classe MigrationOnTheFly.
public class MigrationOnTheFly {
public static final String MIGRATION_VERSION_FIELD_NAME = "migrationVersion";
private SortedMap<Integer, Migration> migrations;
private int currentMigrationVersion;
public MigrationOnTheFly(SortedMap<Integer, Migration> migrations, int currentMigrationVersion) {
this.migrations = migrations;
this.currentMigrationVersion = currentMigrationVersion;
}
public void migrate(final DBObject dbObject) {
final Integer migrationVersion = (Integer) Optional
.ofNullable(dbObject.get(MIGRATION_VERSION_FIELD_NAME))
.orElse(0);
migrations.entrySet()
.stream()
.filter(input -> input.getKey() > migrationVersion)
.forEachOrdered(migrationEntry -> {
migrationEntry.getValue().migrate(dbObject);
});
}
public void migrateVersion(DBObject dbObject) {
dbObject.put(MIGRATION_VERSION_FIELD_NAME, currentMigrationVersion);
}
}
Porém nem tudo são flores.
Existe alguns cenários que também deve-se estar atento. Caso a aplicação faça alguma consulta que utiliza o campo migrado, deve-se alterar estas consultas para contemplar múltiplas versões.
Exemplo:
Antes da migração 1
db.people.find({
'telephone': '14159363485'
})
Depois da migração 1
db.people.find({
$or:[
{'telephone': '14159363485', 'migrationVersion': {$exists: false}},
{'telephones': {$in: ['14159363485']}, 'migrationVersion': {$gte: 1}}
]
})
Isto pode aumentar a complexidade a medida em aumenta o número de migrações e caso tenha mudanças em campos usados nas consultas.
Uma solução é, além de ter essa migração on the fly, ter um job seja iniciado após o deploy e faça as migrações em paralelo para garantir que todos os dados eventualmente estarão migrados.
Assim, pode-se remover as migrações antigas posteriormente e consequentemente a complexidade das consultas.
Uma coisa que deve-se estar atento em relação a este job é que ele pode acabar atualizando um dado esteja sendo salvo pelo usuário. Por isso, recomendo que o uso de optimistic locking nas entidades migradas.
Outra coisa a se considerar é, caso queira manter as duas versões da aplicação (antiga e a nova) rodando ao mesmo tempo durante um curto período, é importante que a migração não remova campos existentes para manter a retrocompatibilidade.
Neste link pode-se encontrar uma implementação simples deste padrão.