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:
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:
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.
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.
Para demostrar cómo funciona, usemos un ejemplo simple. Tabla de usuarios con tres campos: id
, nombre de user_name
, email
:
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]');
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.
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.
En resumen, los principales inconvenientes del enfoque de esquema generado y actualizado automáticamente:
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.
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.
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; }
if not exists
/ if exists
como hicimos anteriormente.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.
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:
Puede encontrar el ejemplo completamente funcional en GitHub .