paint-brush
Découvrez pourquoi et comment utiliser les migrations de bases de données relationnellespar@artemsutulov
1,649 lectures
1,649 lectures

Découvrez pourquoi et comment utiliser les migrations de bases de données relationnelles

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

Trop long; Pour lire

Lors du développement de services backend, il est facile de créer des problèmes si l'intégration de la base de données est mal implémentée. De nos jours, les développeurs utilisent principalement deux approches : Génération automatique, par exemple, JPA ou Hibernate - la base de données s'initialise et se tient à jour en comparant les classes et l'état actuel de la base de données ; si des changements sont nécessaires, ils s'appliquent. Cela signifie que dans l'entité Hibernate, nous ajoutons la nouvelle colonne : @Column(name = "receive_notifications", nullable = false) private Boolean receiveNotifications ; Après avoir démarré l'application, nous voyons l'erreur dans les journaux et aucune nouvelle colonne. Chaque développeur a besoin d'un environnement distinct. Mais il vaut mieux la prochaine fois envisager des migrations car cela soulagera les entités Java, supprimera les responsabilités excessives et vous bénéficiera d'un grand contrôle sur DDL. Vous pouvez trouver l'exemple entièrement fonctionnel sur GitHub.
featured image - Découvrez pourquoi et comment utiliser les migrations de bases de données relationnelles
Artem Sutulov HackerNoon profile picture

Lors du développement de services backend, il est facile de créer des problèmes si l'intégration de la base de données est mal implémentée. Cet article vous expliquera quelques bonnes pratiques pour travailler avec des bases de données relationnelles dans des services modernes et vous montrera également que la génération et la mise à jour automatiques du schéma ne sont sans doute pas une bonne idée.


J'utiliserai Flyway pour les migrations de bases de données, Spring Boot pour une configuration facile et H2 comme exemple de base de données.


Je n'ai pas couvert les informations de base sur ce que sont les migrations et comment elles fonctionnent. Voici de bons articles de Flyway :


  • informations de base sur ce que sont les migrations
  • comment Flyway fonctionne sous le capot .

Le problème

Il y a longtemps, les développeurs initialisaient et mettaient à jour les bases de données en appliquant des scripts séparément de l'application. Cependant, personne ne le fait de nos jours car il est difficile de se développer et de se maintenir dans un état correct, ce qui entraîne de graves problèmes.


De nos jours, les développeurs utilisent principalement deux approches :


  1. Génération automatique, par exemple, JPA ou Hibernate - la base de données s'initialise et se tient à jour en comparant les classes et l'état actuel de la base de données ; si des changements sont nécessaires, ils s'appliquent.

  2. Migrations de base de données - les développeurs mettent progressivement à jour la base de données et les modifications s'appliquent automatiquement au démarrage, migrations de base de données.

    De plus, si nous parlons de Spring, il existe une initialisation de base de données de base prête à l'emploi, mais elle est beaucoup moins avancée que ses analogues tels que Flyway ou Liquibase.


Hibernate génération automatique

Pour montrer comment cela fonctionne, utilisons un exemple simple. Table users avec trois champs - id , user_name , email :


Représentation de la table des utilisateurs


Jetons un œil à celui généré automatiquement par Hibernate.

Hiberner l'entité :

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

Pour activer la mise à jour du schéma, nous avons besoin de cette ligne dans la configuration de Spring Boot et il commence à le faire au démarrage :

 jpa.hibernate.ddl-auto=update

Et connectez-vous depuis l'hibernation au démarrage de l'application :

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


Après la génération automatique, il a créé un id en tant que binary avec une taille maximale de 255, c'est trop car l' UUID ne se compose que de 36 caractères. Nous devons donc utiliser le type UUID à la place, cependant, il ne génère pas de cette façon. Il peut être corrigé en ajoutant cette annotation :

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


Cependant, nous écrivons déjà la définition SQL dans la colonne, ce qui rompt l'abstraction de SQL vers Java.


Et remplissons le tableau avec quelques utilisateurs :

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

Ajout d'une nouvelle colonne

Imaginons, par exemple, qu'après un certain temps, nous souhaitions ajouter des notifications à notre application, et par conséquent suivre si un utilisateur souhaite les recevoir. Nous avons donc décidé d'ajouter une colonne receive_notifications aux utilisateurs de la table et de la rendre non nulle.


Table des utilisateurs après l'ajout d'une nouvelle colonne


Cela signifie que dans l'entité Hibernate, nous ajoutons la nouvelle colonne :

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


Après avoir démarré l'application, nous voyons l'erreur dans les journaux et aucune nouvelle colonne. C'est parce que la table n'est pas vide et nous devons définir une valeur par défaut pour les lignes existantes :

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


Nous pouvons définir une valeur par défaut en ajoutant à nouveau une définition de colonne SQL :

 columnDefinition = "boolean default true"


Et à partir des journaux Hibernate, nous pouvons voir que cela a fonctionné :

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


Cependant, imaginons que nous ayons besoin que receive_notifications soit quelque chose de plus complexe, par exemple vrai ou faux, selon que l'e-mail est rempli ou non. Il est impossible d'implémenter cette logique uniquement avec Hibernate, nous avons donc besoin de migrations de toute façon.


Valeur par défaut plus complexe pour receive_notifications


En résumé, les principaux inconvénients de l'approche des schémas générés et mis à jour automatiquement :


  1. C'est Java d'abord et par conséquent pas flexible en termes de SQL, non prévisible, orienté sur Java d'abord, et parfois ne fait pas les choses SQL comme vous l'attendez. Vous pouvez écrire des définitions SQL pour le mener, mais c'est limité par rapport au DDL SQL pur.

  2. Parfois, il est impossible de mettre à jour les tables existantes et de faire quelque chose avec les données, et nous avons de toute façon besoin de scripts SQL. Dans la plupart des cas, cela se termine par une mise à jour automatique du schéma et la conservation des migrations pour la mise à jour des données. Il est toujours plus facile d'éviter de générer et de faire automatiquement tout ce qui concerne la couche de base de données dans les migrations.


    De plus, ce n'est pas pratique lorsqu'il s'agit de développement parallèle car il ne prend pas en charge la gestion des versions et il est difficile de dire ce qui se passe avec le schéma.

La solution

Voici à quoi cela ressemble sans générer et mettre à jour automatiquement le schéma :


Script d'initialisation de la base de données :

ressources/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) );


Remplissage de la base de données avec certains utilisateurs :

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


Ajout du nouveau champ et définition de la valeur par défaut non triviale sur les lignes existantes :

ressources/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;


Et rien ne nous empêche d'utiliser l'hibernation si nous le choisissons. Dans les configurations, nous devons définir cette propriété :

 jpa.hibernate.ddl-auto=validate


Hibernate ne générera plus rien. Il vérifiera uniquement si la représentation Java correspond à DB. De plus, nous n'avons plus besoin de mélanger du Java et du SQL pour effectuer la génération automatique d'Hibernate, il peut donc être concis et sans responsabilité supplémentaire :

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

Comment utiliser correctement les migrations

  1. Chaque élément de migration doit être idempotent, ce qui signifie que si la migration s'applique plusieurs fois, l'état de la base de données reste le même. Si nous ignorons cela, nous pouvons nous retrouver avec des erreurs après des restaurations ou ne pas appliquer des éléments qui conduisent à des échecs. Dans la plupart des cas, l'idempotence peut être facilement obtenue en ajoutant des vérifications telles que if not exists / if exists , comme nous l'avons fait ci-dessus.
  2. Lors de l'écriture de certains DDL, il est préférable d'en ajouter autant que raisonnablement possible dans une migration, et non d'en créer plusieurs. La principale raison est la lisibilité. C'est bien mieux si les modifications associées, effectuées dans une demande d'extraction, se trouvent dans un seul fichier.
  3. Ne modifiez pas les migrations déjà existantes. C'est une évidence mais obligatoire. Une fois la migration écrite, fusionnée et déployée, elle doit rester intacte. Certaines modifications connexes doivent être effectuées dans un document distinct.
  4. Chaque développeur a besoin d'un environnement distinct. En général, c'est local. La raison en est que si certaines migrations sont appliquées à un environnement partagé, elles seront suivies par des échecs plus tard en raison du fonctionnement des instruments de migration.
  5. Il est pratique d'avoir des tests d'intégration qui exécutent toutes les migrations sur une base de données de test et vérifient si tout fonctionne. Cela peut être très pratique dans les versions qui vérifient l'exactitude d'un PR avant la fusion et de nombreuses erreurs élémentaires peuvent être évitées. Dans cet exemple, il existe des tests d'intégration qui effectuent cette vérification prête à l'emploi.
  6. Il est préférable d'utiliser le modèle V{version+=1}__description.sql pour nommer les migrations au lieu d'utiliser V{datetime}__description.sql . Le second est pratique et aidera à éviter les conflits de numéros de version dans le développement parallèle. Mais parfois, il est préférable d'avoir un conflit de noms plutôt que d'appliquer avec succès des migrations sans que les développeurs ne contrôlent les versions.

Conclusion


C'était beaucoup d'informations, mais j'espère que vous les trouverez utiles. Si vous utilisez la génération/mise à jour automatique du schéma, examinez de près ce qui se passe avec le schéma car il peut se comporter de manière inattendue. Et c'est toujours une bonne idée d'ajouter autant de description que possible pour le mener.


Mais il vaut mieux la prochaine fois envisager des migrations car cela soulagera les entités Java, supprimera les responsabilités excessives et vous permettra de mieux contrôler DDL.


Pour résumer les bonnes pratiques :

  • Ecrire migrations idempotentes.
  • Testez toutes les migrations ensemble sur une base de données de test en écrivant des tests d'intégration.
  • Inclure les modifications associées dans un seul fichier.
  • Chaque développeur a besoin de son propre environnement de base de données.
  • Examinez attentivement les versions lors de l'écriture des migrations.
  • Ne modifiez pas ceux qui existent déjà.


Vous pouvez trouver l'exemple entièrement fonctionnel sur GitHub .