paint-brush
Saiba por que e como usar migrações de banco de dados relacionalby@artemsutulov
1,597
1,597

Saiba por que e como usar migrações de banco de dados relacional

Artem Sutulov7m2022/07/16
Read on Terminal Reader
Read this story w/o Javascript

Introdução Ao desenvolver serviços de back-end, é fácil criar problemas se a integração do banco de dados for implementada incorretamente. Atualmente, os desenvolvedores usam principalmente duas abordagens: Geração automática, por exemplo, JPA ou Hibernate - o banco de dados inicializa e mantém atualizado comparando classes e o estado atual do banco de dados; se forem necessárias alterações, elas se aplicam. Isso significa que na entidade Hibernate, adicionamos a nova coluna: @Column(name = "receive_notifications", nullable = false) private Boolean receiveNotifications; Depois de iniciar o aplicativo, vemos o erro nos logs e nenhuma nova coluna. Cada desenvolvedor requer um ambiente separado. Mas é melhor da próxima vez considerar as migrações porque isso aliviará as entidades Java, removerá o excesso de responsabilidade e o beneficiará com muito controle sobre o DDL. Você pode encontrar o exemplo totalmente funcional no GitHub.
featured image - Saiba por que e como usar migrações de banco de dados relacional
Artem Sutulov HackerNoon profile picture

Ao desenvolver serviços de back-end, é fácil criar problemas se a integração do banco de dados for implementada incorretamente. Este artigo apresentará algumas práticas recomendadas para trabalhar com bancos de dados relacionais em serviços modernos e também mostrará que gerar e manter esquemas atualizados automaticamente não é uma boa ideia.


Usarei Flyway para migrações de banco de dados, Spring Boot para fácil configuração e H2 como banco de dados de exemplo.


Não cobri informações básicas sobre o que são migrações e como elas funcionam. Aqui estão bons artigos da Flyway:


  • informações básicas sobre o que são migrações
  • como o Flyway funciona sob o capô .

O problema

Há muito tempo, os desenvolvedores inicializavam e atualizavam os bancos de dados aplicando scripts separadamente do aplicativo. No entanto, ninguém faz isso hoje em dia porque é difícil desenvolver e manter em bom estado, o que leva a sérios problemas.


Atualmente, os desenvolvedores usam principalmente duas abordagens:


  1. Geração automática, por exemplo, JPA ou Hibernate - o banco de dados inicializa e mantém atualizado comparando as classes e o estado atual do banco de dados; se forem necessárias alterações, elas se aplicam.

  2. Migrações de banco de dados - os desenvolvedores atualizam incrementalmente o banco de dados e as alterações são aplicadas automaticamente em uma inicialização, migrações de banco de dados.

    Além disso, se falarmos sobre Spring, há uma inicialização básica de banco de dados pronta para uso, mas é muito menos avançada do que seus análogos, como Flyway ou Liquibase.


Geração automática de hibernação

Para demonstrar como funciona, vamos usar um exemplo simples. Tabela de usuários com três campos - id , user_name , email :


Representação da tabela de usuários


Vamos dar uma olhada naquele gerado automaticamente pelo Hibernate.

Entidade de hibernação:

 @Entity @Table(name = "users") public class User { @Id @GeneratedValue private UUID id; @Column(name = "user_name", length = 64, nullable = false) private String userName; @Column(name = "email", length = 128, nullable = true) private String email; }

Para permitir manter o esquema atualizado, precisamos desta linha na configuração do Spring Boot e ele começa a fazer isso na inicialização:

 jpa.hibernate.ddl-auto=update

E logue do hibernate quando o aplicativo estiver iniciando:

Hibernate: create table users (id binary(255) not null, email varchar(128), user_name varchar(64) not null, primary key (id))


Após a geração automática, o id criado como binary com um tamanho máximo de 255 é muito porque o UUID consiste apenas em 36 caracteres. Portanto, precisamos usar o tipo UUID , no entanto, ele não é gerado dessa maneira. Pode ser corrigido adicionando esta anotação:

 @Column(name = "id", columnDefinition = "uuid")


No entanto, já estamos escrevendo a definição de SQL para a coluna, o que quebra a abstração de SQL para Java.


E vamos preencher a tabela com alguns usuários:

 insert into users (id, user_name, email) values ('297a848d-d406-4055-8a6f-4a4118a44001', 'Artem', null); insert into users (id, user_name, email) values ('921a9d42-bf14-4c3f-9893-60f79cdd0825', 'Antonio', '[email protected]');

Adicionando uma nova coluna

Vamos imaginar, por exemplo, que depois de algum tempo queremos adicionar notificações ao nosso aplicativo e, consequentemente, rastrear se um usuário deseja recebê-las. Portanto, decidimos adicionar uma coluna receive_notifications à tabela users e torná-la não anulável.


Tabela de usuários após adicionar uma nova coluna


Isso significa que na entidade Hibernate, adicionamos a nova coluna:

 @Column(name = "receive_notifications", nullable = false) private Boolean receiveNotifications;


Depois de iniciar o aplicativo, vemos o erro nos logs e nenhuma nova coluna. É porque a tabela não está vazia e precisamos definir um valor padrão para as linhas existentes:

Error executing DDL "alter table users add column receive_notifications boolean not null" via JDBC Statement


Podemos definir um valor padrão adicionando a definição de coluna SQL novamente:

 columnDefinition = "boolean default true"


E pelos logs do Hibernate, podemos ver que funcionou:

Hibernate: alter table users add column receive_notifications boolean default true not null


Porém, vamos imaginar que precisávamos que receive_notifications fosse algo mais complexo, por exemplo, true ou false, dependendo se o e-mail foi preenchido ou não. É impossível implementar essa lógica apenas com o Hibernate, então precisamos de migrações de qualquer maneira.


Valor padrão mais complexo para receive_notifications


Resumindo, as principais desvantagens da abordagem do esquema gerado e atualizado automaticamente:


  1. É Java primeiro e, consequentemente, não é flexível em termos de SQL, não é previsível, é orientado primeiro para Java e, às vezes, não faz as coisas do SQL da maneira que você espera. Você pode escrever algumas definições SQL para conduzi-lo, mas é limitado em comparação com SQL DDL puro.

  2. Às vezes é impossível atualizar as tabelas existentes e fazer algo com os dados, e de qualquer maneira precisamos de scripts SQL. Na maioria dos casos, termina com a atualização automática do esquema e mantém as migrações para atualizar os dados. É sempre mais fácil evitar gerar automaticamente e fazer tudo relacionado à camada de banco de dados nas migrações.


    Além disso, não é conveniente quando se trata de desenvolvimento paralelo porque não oferece suporte a controle de versão e é difícil dizer o que está acontecendo com o esquema.

Solução

Aqui está como fica sem gerar e atualizar automaticamente o esquema:


Script para inicializar o banco de dados:

recursos/db/migration/V1__db_initialization.sql

 create table if not exists users ( id uuid not null primary key, user_name varchar(64) not null, email varchar(128) );


Preenchendo banco de dados com alguns usuários:

recursos/db/migration/V2__users_some_data.sql

 insert into users (id, user_name, email) values ('297a848d-d406-4055-8a6f-4a4118a44001', 'Artem', null); insert into users (ID, USER_NAME, EMAIL) values ('921a9d42-bf14-4c3f-9893-60f79cdd0825', 'Antonio', '[email protected]');


Adicionando o novo campo e definindo o valor padrão não trivial para as linhas existentes:

resources/db/migration/V3__users_add_receive_notification.sql

 alter table users add column if not exists receive_notifications boolean; -- It's not a really safe with huge amount of data but good for the example update users set users.receive_notifications = email is not null; alter table users alter column receive_notifications set not null;


E nada nos impede de usar o hibernate, se assim o desejarmos. Nas configurações, precisamos definir esta propriedade:

 jpa.hibernate.ddl-auto=validate


Agora o Hibernate não irá gerar nada. Ele apenas verificará se a representação Java corresponde ao DB. Além disso, não precisamos mais misturar um pouco de Java e SQL para conduzir a geração automática do Hibernate, então ele pode ser conciso e sem responsabilidade extra:

 @Entity @Table(name = "users") public class User { @Id @Column(name = "id") @GeneratedValue private UUID id; @Column(name = "user_name", length = 64, nullable = false) private String userName; @Column(name = "email", length = 128, nullable = true) private String email; @Column(name = "receive_notifications", nullable = false) private Boolean receiveNotifications; }

Como usar o direito de migração

  1. Cada parte da migração deve ser idempotente, o que significa que, se a migração for aplicada várias vezes, o estado do banco de dados permanecerá o mesmo. Se ignorarmos isso, podemos acabar com erros após rollbacks ou não aplicar peças que levam a falhas. A idempotência na maioria dos casos pode ser facilmente alcançada adicionando verificações como if not exists / if exists como fizemos acima.
  2. Ao escrever algum DDL, é melhor adicionar o máximo razoavelmente possível em uma migração, não criar vários. O principal motivo é a legibilidade. É muito melhor se as alterações relacionadas, feitas em uma solicitação pull, estiverem em um arquivo.
  3. Não altere as migrações já existentes. É óbvio, mas necessário. Depois que a migração é gravada, mesclada e implantada, ela deve permanecer intocada. Algumas alterações relacionadas devem ser feitas em separado.
  4. Cada desenvolvedor requer um ambiente separado. Normalmente, é local. O motivo é que, se algumas migrações forem aplicadas a um ambiente compartilhado, elas serão seguidas por algumas falhas posteriormente devido à maneira como os instrumentos de migração funcionam.
  5. É conveniente ter alguns testes de integração que executam todas as migrações em um banco de dados de teste e verificam se tudo está funcionando. Pode ser muito útil em compilações que verificam a exatidão de um PR antes da fusão e muitos erros elementares podem ser evitados. Neste exemplo, há testes de integração que fazem isso de imediato.
  6. É melhor usar o padrão V{version+=1}__description.sql para nomear migrações em vez de usar V{datetime}__description.sql . O segundo é conveniente e ajudará a evitar conflitos de números de versão no desenvolvimento paralelo. Mas, às vezes, é melhor ter conflito de nomes do que aplicar migrações com êxito sem que os desenvolvedores controlem as versões.

Conclusão


Foi muita informação, mas espero que você ache útil. Se você usar a geração/atualização automática do esquema - observe atentamente o que está acontecendo com o esquema, pois ele pode se comportar de forma inesperada. E é sempre uma boa ideia adicionar o máximo de descrição possível para conduzi-lo.


Mas é melhor da próxima vez considerar as migrações porque isso aliviará as entidades Java, removerá o excesso de responsabilidade e o beneficiará com muito controle sobre o DDL.


Resumindo as melhores práticas:

  • Gravar migrações idempotentes.
  • Teste todas as migrações juntas em um banco de dados de teste escrevendo testes de integração.
  • Inclua alterações relacionadas em um arquivo.
  • Cada desenvolvedor precisa de seu próprio ambiente de banco de dados.
  • Dê uma olhada nas versões ao escrever migrações.
  • Não altere os já existentes.


Você pode encontrar o exemplo totalmente funcional no GitHub .