paint-brush
Aprenda por qué y cómo usar las migraciones de bases de datos relacionalespor@artemsutulov
1,649 lecturas
1,649 lecturas

Aprenda por qué y cómo usar las migraciones de bases de datos relacionales

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

Demasiado Largo; Para Leer

Introducción Al desarrollar servicios de back-end, es fácil crear problemas si la integración de la base de datos se implementa incorrectamente. Hoy en día, los desarrolladores utilizan principalmente dos enfoques: Generación automática, por ejemplo, JPA o Hibernate: la base de datos se inicializa y se mantiene actualizada mediante la comparación de clases y el estado actual de la base de datos; si se necesitan cambios, se aplican. Eso significa que en la entidad de Hibernate, agregamos la nueva columna: @Column(name = "receive_notifications", anulable = false) private Boolean receiveNotifications; Después de iniciar la aplicación, vemos el error en los registros y ninguna columna nueva. Cada desarrollador requiere un entorno independiente. Pero es mejor considerar las migraciones la próxima vez porque aliviará las entidades de Java, eliminará el exceso de responsabilidad y lo beneficiará con mucho control sobre DDL. Puede encontrar el ejemplo completamente funcional en GitHub.
featured image - Aprenda por qué y cómo usar las migraciones de bases de datos relacionales
Artem Sutulov HackerNoon profile picture

Al desarrollar servicios de back-end, es fácil crear problemas si la integración de la base de datos se implementa incorrectamente. Este artículo le dirá algunas de las mejores prácticas para trabajar con bases de datos relacionales en servicios modernos y también le mostrará que generar y mantener esquemas actualizados automáticamente no es una buena idea.


Usaré Flyway para migraciones de bases de datos, Spring Boot para una fácil configuración y H2 como base de datos de ejemplo.


No cubrí información básica sobre qué son las migraciones y cómo funcionan. Aquí hay buenos artículos de Flyway:


  • información básica sobre qué son las migraciones
  • cómo funciona Flyway bajo el capó .

El problema

Hace mucho tiempo, los desarrolladores inicializaban y actualizaban las bases de datos aplicando scripts por separado de la aplicación. Sin embargo, hoy en día nadie lo hace porque es difícil de desarrollar y mantener en un estado adecuado, lo que genera graves problemas.


Hoy en día, los desarrolladores utilizan principalmente dos enfoques:


  1. Generación automática, por ejemplo, JPA o Hibernate : la base de datos se inicializa y se mantiene actualizada mediante la comparación de clases y el estado actual de la base de datos; si se necesitan cambios, se aplican.

  2. Migraciones de bases de datos: los desarrolladores actualizan la base de datos de forma incremental y los cambios se aplican automáticamente al inicio, migraciones de bases de datos.

    Además, si hablamos de Spring, hay una inicialización básica de la base de datos lista para usar, pero es mucho menos avanzada que sus análogos, como Flyway o Liquibase.


Generación automática de Hibernate

Para demostrar cómo funciona, usemos un ejemplo simple. Tabla de usuarios con tres campos: id , nombre de user_name , email :


Representación de la tabla de usuarios


Echemos un vistazo al generado automáticamente por Hibernate.

Entidad de hibernación:

 @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 mantener el esquema actualizado, necesitamos esta fila en la configuración de Spring Boot y comienza a hacerlo al inicio:

 jpa.hibernate.ddl-auto=update

Y registre desde hibernación cuando la aplicación se esté iniciando:

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


Después de la generación automática, creó una id como binary con un tamaño máximo de 255, es demasiado porque UUID consta solo de 36 caracteres. Por lo tanto, debemos usar el tipo UUID en su lugar; sin embargo, no se genera de esta manera. Se puede arreglar agregando esta anotación:

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


Sin embargo, ya estamos escribiendo la definición de SQL en la columna, lo que rompe la abstracción de SQL a Java.


Y vamos a llenar la tabla con algunos usuarios:

 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]');

Agregar una nueva columna

Imaginemos, por ejemplo, que después de un tiempo queremos agregar notificaciones a nuestra aplicación y, en consecuencia, rastrear si un usuario desea recibirlas. Así que decidimos agregar una columna receive_notifications a la tabla de usuarios y hacerla no anulable.


Tabla de usuarios después de agregar una nueva columna


Eso significa que en la entidad de Hibernate, agregamos la nueva columna:

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


Después de iniciar la aplicación, vemos el error en los registros y ninguna columna nueva. Es porque la tabla no está vacía y necesitamos establecer un valor predeterminado para las filas existentes:

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


Podemos establecer un valor predeterminado agregando nuevamente la definición de la columna SQL:

 columnDefinition = "boolean default true"


Y de los registros de Hibernate, podemos ver que funcionó:

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


Sin embargo, imaginemos que necesitamos que receive_notifications sea algo más complejo, por ejemplo, verdadero o falso, dependiendo de si el correo electrónico está lleno o no. Es imposible implementar esa lógica solo con Hibernate, por lo que necesitamos migraciones de todos modos.


Valor predeterminado más complejo para recibir_notificaciones


En resumen, los principales inconvenientes del enfoque de esquema generado y actualizado automáticamente:


  1. Primero es Java y, en consecuencia, no es flexible en términos de SQL, no es predecible, está orientado primero a Java y, a veces, no hace las cosas de SQL de la manera que espera. Puede escribir algunas definiciones de SQL para realizarlo, pero es limitado en comparación con SQL DDL puro.

  2. A veces es imposible actualizar las tablas existentes y hacer algo con los datos, y de todos modos necesitamos secuencias de comandos SQL. En la mayoría de los casos, termina con la actualización automática del esquema y manteniendo las migraciones para actualizar los datos. Siempre es más fácil evitar generar y hacer automáticamente todo lo relacionado con la capa de la base de datos en las migraciones.


    Además, no es conveniente cuando se trata de desarrollo paralelo porque no admite el control de versiones y es difícil saber qué sucede con el esquema.

Solución

Así es como se ve sin generar y actualizar automáticamente el esquema:


Script para inicializar DB:

recursos/db/migración/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) );


Llenando la base de datos con algunos usuarios:

recursos/db/migración/V2__usuarios_algunos_datos.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]');


Agregar el nuevo campo y establecer el valor predeterminado no trivial en las filas existentes:

recursos/db/migración/V3__usuarios_añadir_recibir_notificación.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;


Y nada nos impide usar hibernate si así lo elegimos. En las configuraciones, necesitamos establecer esta propiedad:

 jpa.hibernate.ddl-auto=validate


Ahora Hibernate no generará nada. Solo verificará si la representación de Java coincide con DB. Además, ya no necesitamos mezclar algo de Java y SQL para realizar la generación automática de Hibernate, por lo que puede ser conciso y sin responsabilidad adicional:

 @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; }

Cómo usar las migraciones correctamente

  1. Cada parte de la migración debe ser idempotente, lo que significa que si la migración se aplica varias veces, el estado de la base de datos permanece igual. Si ignoramos eso, podemos terminar con errores después de retrocesos o no aplicar piezas que conducen a fallas. La idempotencia en la mayoría de los casos se puede lograr fácilmente agregando controles como if not exists / if exists como hicimos anteriormente.
  2. Al escribir algo de DDL, es mejor agregar tanto como sea razonablemente posible en una migración, no crear varias. La razón principal es la legibilidad. Es mucho mejor si los cambios relacionados, realizados en una solicitud de extracción, están en un archivo.
  3. No cambie las migraciones ya existentes. Es obvio pero necesario. Una vez que se escribe, fusiona e implementa la migración, debe permanecer intacta. Algunos cambios relacionados deben hacerse en uno separado.
  4. Cada desarrollador requiere un entorno independiente. Por lo general, es uno local. La razón es que si se aplican algunas migraciones a un entorno compartido, más adelante se producirán algunos errores debido a la forma en que funcionan los instrumentos de migración.
  5. Es conveniente tener algunas pruebas de integración que ejecuten todas las migraciones en una base de datos de prueba y comprueben si todo funciona. Puede ser realmente útil en compilaciones que verifican la corrección de un PR antes de fusionarse y se pueden evitar muchos errores elementales. En este ejemplo, hay pruebas de integración que hacen esa verificación de forma inmediata.
  6. Es mejor usar el patrón V{version+=1}__description.sql para nombrar las migraciones en lugar de usar V{datetime}__description.sql . El segundo es conveniente y ayudará a evitar conflictos de números de versión en el desarrollo paralelo. Pero a veces, es mejor tener un conflicto de nombres que aplicar migraciones con éxito sin que los desarrolladores controlen las versiones.

Conclusión


Era mucha información, pero espero que les sea útil. Si usa la generación/actualización automática del esquema, observe de cerca lo que está sucediendo con el esquema porque puede comportarse de manera inesperada. Y siempre es una buena idea agregar tanta descripción como sea posible para llevarla a cabo.


Pero es mejor considerar las migraciones la próxima vez porque aliviará las entidades de Java, eliminará el exceso de responsabilidad y lo beneficiará con mucho control sobre DDL.


Para resumir las mejores prácticas:

  • Escribir migraciones idempotentes.
  • Pruebe todas las migraciones juntas en una base de datos de prueba escribiendo pruebas de integración.
  • Incluya los cambios relacionados en un archivo.
  • Cada desarrollador necesita su propio entorno de base de datos.
  • Eche un vistazo de cerca a las versiones cuando escriba migraciones.
  • No cambies los ya existentes.


Puede encontrar el ejemplo completamente funcional en GitHub .