Tutoriel sur l'utilisation DBUnit pour la migration de données

| Ibrahima Coundoul (Soat) | Soat |

Les contenus des bases de données augmentent de plus en plus, et en complément des changements structurels et fréquents des bases de données, la migration de données est devenue un problème complexe. Aussi, avec les différents environnements (production, préproduction, développement, recette, intégration, etc.) et les différents composants d'une application (comptabilité, logistique, briques externes, etc.), la migration de données répondant à un certain critère de sélection vers une autre base est devenue un réel besoin.

Aussi dans le cas où nous n'avons pas de base de données physique, DbUnit permet de provisionner une base de données à partir de flux XML et donc de pouvoir tester les différents services d'une application.

Dans cet article issu d'une expérience sur un projet de migration de données, je présente l'utilitaire DbUnit, qui nous permet de migrer des données d'une base A vers une base B, ainsi que de tester tous nos services sans avoir de base de données physique.

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum Commentez .

- Titre : Tutoriel sur l'utilisation DBUnit pour la migration de données

- Auteurs : Ibrahima Coundoul (Soat), Soat

- Parution : 19 octobre 2016

- Licence : Copyright ® 2016 SOAT. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Migration de données

- I-A. Pourquoi ne pas faire de dump ?

- I-B. DbUnit

- I-B-1. Contexte

- I-B-2. Extraction de données

- I-B-2-a. Comment se fait l'exportation ?

- I-B-3. Injection de données

- I-B-4. Import et export d'un client

- I-B-5. Remarques

- I-B-5-a. Automatisation

- I-B-5-b. Avantages et Inconvénients

- II. Tests

- II-A. Le projet Hibernate JPA

- II-A-1. Modèle de données : Entity

- II-A-2. Fichier de persistance

- II-A-3. Le service à tester

- II-A-4. Tester le service

- III. Conclusion

- IV. Remerciements

I - Migration de données

I-A - Pourquoi ne pas faire de dump ?

Le dump d'une base prend beaucoup de temps, d'autant plus si elle est de taille imposante ou qu'elle se trouve à distance. De plus, si on a besoin de n'injecter que certaines données dans une autre base, on n'a pas besoin de dumper toute la base. Aussi, pourquoi devrions-nous faire un dump d'une base si une unique donnée a changé, alors qu'il y a des solutions pour extraire et injecter des données sélectionnées ?

I-B - DbUnit

DbUnit est une extension de JUnit se présentant sous la forme d'un puissant outil d'extraction et d'injection de données. Elle permet, entre autres, d'exporter une base de données (ou une partie) sous forme de fichier XML et de l'importer dans une autre base.

La dépendance Maven est :

1. <dependency>

2. <groupId>org.dbunit</groupId>

3. <artifactId>dbunit</artifactId>

4. <version>2.5.0</version>

5. </dependency>

I-B-1 - Contexte

L'application d'une société délivrant des contrats à ses clients comprend différents composants. Chaque contrat a des transactions bancaires (paiement, remboursement, annulation de transaction, etc.). La base de données source (base de production par exemple) a rapidement grossi et le but du projet est de pouvoir sélectionner quelques clients et de les injecter dans une autre base. La structure de la base est la suivante :

1. --

2. -- Structure de la table `t_client`

3. --

5. CREATE TABLE IF NOT EXISTS `t_client` (

6. `id` int(10) NOT NULL,

7. `name` varchar(50) NOT NULL,

8. `adresse` varchar(255) NOT NULL,

9. PRIMARY KEY (`id`)

10. ) ENGINE=InnoDB DEFAULT CHARSET=latin1;

12. --

13. -- Contenu de la table `t_client`

14. --

16. INSERT INTO `t_client` (`id`, `name`, `description`) VALUES

17. (100, 'Michel Besson', '89 quai Panhard et Levassor 75013'),

18. (200, 'Olivier Lecarme', '89 quai Panhard et Levassor 75013'),

19. (300, 'Carine Fédéle', '89 quai Panhard et Levassor 75013'),

20. (400, 'Cécile Dupond', '89 quai Panhard et Levassor 75013'),

21. (500, 'Bile Gate', '89 quai Panhard et Levassor 75013');

23. -- --------------------------------------------------------

25. -- --------------------------------------------------------

27. --

28. -- Structure de la table `t_transactions_bancaires`

29. --

31. CREATE TABLE IF NOT EXISTS `t_transactions_bancaires` (

32. `id` int(10) NOT NULL,

33. `name` varchar(50) NOT NULL,

34. `amount` double NOT NULL,

35. `contrat_fk` int(10) NOT NULL,

36. PRIMARY KEY (`id`),

37. KEY `contrat_fk_ind` (`contrat_fk`)

38. ) ENGINE=InnoDB DEFAULT CHARSET=latin1;

40. --

41. -- Contenu de la table `t_transactions_bancaires`

42. --

44. INSERT INTO `t_transactions_bancaires` (`id`, `name`, `amount`, `contrat_fk`) VALUES

45. (1, 'Paiement de ''option Sport', 10, 41),

46. (10, 'Remboursement', 22, 41),

47. (11, 'Remboursement pour fidélité', 32, 31);

49. -- --------------------------------------------------------

51. --

52. -- Structure de la table `t_contrat`

53. --

55. CREATE TABLE IF NOT EXISTS `t_contrat` (

56. `id` int(10) NOT NULL,

57. `name` varchar(50) NOT NULL,

58. `description` varchar(255) NOT NULL,

59. `client_fk` int(10) NOT NULL,

60. PRIMARY KEY (`id`),

61. KEY `client_fk_ind` (`client_fk`)

62. ) ENGINE=InnoDB DEFAULT CHARSET=latin1;

64. --

65. -- Contenu de la table `t_contrat`

66. --

68. INSERT INTO `t_contrat` (`id`, `name`, `description`, `client_fk`) VALUES

69. (31, 'Abonnement Télé', 'Description du contrat', 400),

70. (32, 'Abonnement Box TV', 'Chaines Sport, Documentaires etc..', 400),

71. (41, 'Abonnement Video PLAY', 'description du contrat', 200),

72. (71, 'Location', 'Location de divers biens', 300);

74. --

75. -- Contraintes pour les tables exportées

76. --

78. --

79. -- Contraintes pour la table `t_transactions_bancaires`

80. --

81. ALTER TABLE `t_transactions_bancaires`

82. ADD CONSTRAINT `t_transactions_bancaires_ibfk_1` FOREIGN KEY (`contrat_fk`) REFERENCES `t_contrat` (`id`) ON DELETE CASCADE;

84. --u

85. -- Contraintes pour la table `t_contrat`

86. --

87. ALTER TABLE `t_contrat`

88. ADD CONSTRAINT `t_contrat_ibfk_1` FOREIGN KEY (`client_fk`) REFERENCES `t_client` (`id`) ON DELETE CASCADE;

Les bases cibles ne seront peuplées qu'avec les données dont elles ont besoin. Chaque action est gérée par une classe Java : DataExtractorMain.java et DataInjectorMain.java.

I-B-2 - Extraction de données

Pour extraire une donnée, on a besoin d'une connexion (JDBC, par exemple) vers la base source. Pour l'injection, on a besoin d'une connexion vers la base cible. Dans tous les cas, on utilise Maven pour la gestion des dépendances.

L'extraction est gérée par ce service. Dans ce dernier, il y a trois exemples d'extraction de données, comme on peut les voir sur les commentaires dans la classe :

- export de données partielles de la base de données (fichier généré partial_data.xml) ;

- export de toute la base (fichier généré full_data.xml) ;

- export des tables dépendantes : export de la table T et de toutes les tables ayant une clé primaire qui est une étrangère sur la table T (fichier généré dependents.xml).

Le test génère les fichiers XML suivants : partial_data.xml, full_data.xml et dependents.xml. Ci-dessous le contenu de full_data.xml :

1. <?xml version='1.0' encoding='UTF-8'?>

2. <dataset>

3. <t_client id="100" name="Michel Besson" adresse="89 quai Panhard et Levassor 75013"/>

4. <t_client id="200" name="Olivier Lecarme" adresse="89 quai Panhard et Levassor 75013"/>

5. <t_client id="300" name="Carine Dubois" adresse="89 quai Panhard et Levassor 75013"/>

6. <t_client id="400" name="Lydie Dupond" adresse="89 quai Panhard et Levassor 75013"/>

7. <t_client id="500" name="Bile Gate" adresse="89 quai Panhard et Levassor 75013"/>

8. <t_contrat id="31" name="Abonnement TV VOD" description="Description du contrat" client_fk="400"/>

9. <t_contrat id="32" name="Abonnement Box TV" description="Chaines Sport, Documentaires etc.." client_fk="400"/>

10. <t_contrat id="41" name="Abonnement Video PLAY" description="description du contrat" client_fk="200"/>

11. <t_contrat id="71" name="Location" description="Location de divers biens" client_fk="300"/>

12. <t_transactions_bancaires id="1" name="Paiement option Sport" amount="10.0" contrat_fk="41"/>

13. <t_transactions_bancaires id="10" name="Remboursement" amount="22.0" contrat_fk="41"/>

14. <t_transactions_bancaires id="11" name="Remboursement pour promotion" amount="32.0" contrat_fk="31"/>

15. </dataset></pre>

16. <pre>

I-B-2-a - Comment se fait l'exportation ?

Exemple : export de données partielles de la base de données

Tout part de l'interface IDataSet qui représente une collection de tables. La classe QueryDataSet est une implémentation de cette interface et définit donc la méthode d'ajout de table (addTable()). On crée donc une instance de cette classe QueryDataSet à partir de la connexion, puis on appelle la méthode addTable() pour sélectionner et ajouter les données souhaitées. La dernière étape est la génération du fichier XML à partir de cette instance, et pour cela, on appelle la méthode write() de la classe FlatXmlDataSet qui génère le fichier XML.

Nous allons donc pouvoir injecter ces données dans une autre base.

I-B-3 - Injection de données

Pour cela, nous avons besoin d'une connexion à la base source et au fichier XML qui sera mise en œuvre par la classe DataInjectorMain.java.

Ainsi, notre base cible B qui était vide se retrouve remplie avec les données extraites :

I-B-4 - Import et export d'un client

Dans cet exemple pratique, nous voulons exporter un client avec toutes ses dépendances (détails, contrats et transactions bancaires) et l'injecter dans une base Z. DbUnit fournit également des services pour le faire.

Les traces de l'exécution :

Le fichier exporté, data_client_200.xml, se retrouve donc avec le contenu suivant :

1. <?xml version='1.0' encoding='UTF-8'?>

2. <dataset>

3. <t_client id="200" name="Olivier Lecarme" adresse="89 quai Panhard et Levassor 75013"/>

4. <t_contrat id="41" name="Abonnement Video PLAY" description="description du contrat" client_fk="200"/>

5. <t_transactions_bancaires id="1" name="Paiement option Sport" amount="10.0" contrat_fk="41"/>

6. <t_transactions_bancaires id="10" name="Remboursement" amount="22.0" contrat_fk="41"/>

7. </dataset>

Dans la base Z, on voit que le client est bien injecté avec ses contrats et ses transactions bancaires :

Comment se fait l'importation ?

C'est le même principe que dans la classe d'injection. Partant du fichier XML des données extraites, on crée une instance de IDatSet pour représenter cet ensemble de données. Ce dernier est décoré par une instance de l'interface ReplacementDataSet que l'on passera à la méthode execute() de la classe DatabaseOperation qui fera un nettoyage puis une insertion des données.

I-B-5 - Remarques

I-B-5-a - Automatisation

De la même façon que nous pouvons extraire et injecter un client, nous pouvons aussi extraire toute une liste de clients via un service de recherche d'abonnés. Une fois ces abonnés trouvés, nous pourrons appeler la couche d'extraction pour extraire les abonnés. En plus de Maven pour la gestion des dépendances et Spring pour la gestion des cycles de vie des objets, nous pouvons aussi utiliser Jenkins pour l'automatisation des tâches (recherche, extraction et injection) et donc pouvoir approvisionner nos bases automatiquement.

I-B-5-b - Avantages et Inconvénients

Avec DbUnit, on arrive à extraire et injecter des données très volumineuses dans différentes bases, ce qui en fait un très bon outil pour la migration de données. Mais pour pouvoir l'utiliser, il faut que la base de données soit bien structurée (présence de clé primaire, clé étrangère, relation, etc.). Si ce n'est pas le cas, il faudra commencer par résoudre ce problème.

II - Tests

Le module Spring DbUnit offre une intégration entre le framework de test Spring et DbUnit. Il permet de charger et décharger des bases de données en utilisant de simples annotations pour insérer, supprimer ou modifier des données, permettant ainsi de tester les services de l'application.

Dans cette partie de l'article, nous allons voir comment faire pour tester un projet Hibernate JPA en utilisant une base de données Hypersonic en mémoire.

Dépendances Maven

Les principales dépendances utilisées par le projet sont les suivantes : DBunit, Spring, Slf4j. Vous retrouverez la liste exhaustive dans le pom.xml.

II-A - Le projet Hibernate JPA

II-A-1 - Modèle de données : Entity

Le projet à tester étant du Hiberate JPA, nous pouvons définir l'entité Customer comme suit :

1. package fr.soat.icoundoul.dbunit.entity;

3. import javax.persistence.Entity;

4. import javax.persistence.Id;

5. import javax.persistence.NamedQueries;

6. import javax.persistence.NamedQuery;

8. @Entity

9. @NamedQueries({ @NamedQuery(name = "Customer.find", query = "SELECT p from Customer p where p.firstName like :name " + "or p.lastName like :name") })

10. public class Customer {

12. @Id

13. private int id;

15. private String title;

17. private String firstName;

19. private String lastName;

21. }

II-A-2 - Fichier de persistance

Le fichier XML de persistance est le suivant :

1. <persistence xmlns="http://java.sun.com/xml/ns/persistence"

2. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

3. xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"

4. version="2.0">

5. <persistence-unit name="pagingDatabase"

6. transaction-type="RESOURCE_LOCAL">

7. <provider>org.hibernate.ejb.HibernatePersistence</provider>

8. <class>fr.soat.icoundoul.dbunit.entity.Customer</class>

9. <properties>

10. <property name="hibernate.dialect" value="org.hibernate.dialect.HSQLDialect" />

11. <property name="hibernate.hbm2ddl.auto" value="create-drop" />

12. <property name="hibernate.show_sql" value="true" />

13. <property name="hibernate.cache.provider_class" value="org.hibernate.cache.HashtableCacheProvider" />

14. </properties>

15. </persistence-unit>

16. </persistence>

II-A-3 - Le service à tester

Le service à tester est celui de gestion des clients (customer), qui définit la méthode Search, permettant de trouver un client comme suit :

1. package fr.soat.icoundoul.dbunit.service;

3. import java.util.List;

5. import javax.persistence.EntityManager;

6. import javax.persistence.PersistenceContext;

7. import javax.persistence.Query;

9. import org.springframework.stereotype.Service;

10. import org.springframework.transaction.annotation.Transactional;

12. import fr.soat.icoundoul.dbunit.entity.Customer;

14. @Service

15. @Transactional

16. public class CustomerService {

18. @PersistenceContext

19. private EntityManager entityManager;

21. @SuppressWarnings("unchecked")

22. public List<Customer> find(String name) {

23. Query query = entityManager.createNamedQuery("Customer.find");

24. query.setParameter("name", "%" + name + "%");

25. return query.getResultList();

26. }

27. }

II-A-4 - Tester le service

Afin de nous assurer que notre service fonctionne bien, nous devons le tester en créant un test JUnit comme suit :

1. package fr.soat.icoundoul.dbunit.test;

3. import static junit.framework.Assert.assertEquals;

5. import java.util.List;

7. import org.junit.Test;

8. import org.junit.runner.RunWith;

9. import org.springframework.beans.factory.annotation.Autowired;

10. import org.springframework.test.context.ContextConfiguration;

11. import org.springframework.test.context.TestExecutionListeners;

12. import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

13. import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;

15. import com.github.springtestdbunit.DbUnitTestExecutionListener;

17. import fr.soat.icoundoul.dbunit.entity.Customer;

18. import fr.soat.icoundoul.dbunit.service.CustomerService;

20. @RunWith(SpringJUnit4ClassRunner.class)

21. @ContextConfiguration(locations = { "/applicationContext.xml" })

22. @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, DbUnitTestExecutionListener.class })

23. public class CustomerServiceTest {

25. @Autowired

26. private CustomerService customerService;

28. @Test

29. public void testFind() throws Exception {

30. List<Customer> customerList = customerService.find("hil");

31. assertEquals(1, customerList.size());

32. assertEquals("Phillip", customerList.get(0).getFirstName());

33. }

34. }

Comme le test utilise le lanceur de Spring SpringJUnit4ClassRunner, nous devons définir le fichier du contexte de l'application (applicationContext.xml) dans lequel nous indiquons les Beans que Spring doit scanner, ainsi que la configuration de Hibernate pour la base de données à monter en mémoire. Le fichier est le suivant :

1. <?xml version="1.0" encoding="UTF-8"?>

2. <beans xmlns="http://www.springframework.org/schema/beans"

3. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

4. xmlns:context="http://www.springframework.org/schema/context"

5. xmlns:tx="http://www.springframework.org/schema/tx"

6. xsi:schemaLocation="

7. http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd

8. http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd

9. http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-3.0.xsd">

11. <context:component-scan base-package="fr.soat.icoundoul.dbunit" />

12. <tx:annotation-driven transaction-manager="transactionManager" />

14. <bean id="dataSource"

15. class="org.springframework.jdbc.datasource.DriverManagerDataSource">

16. <property name="driverClassName" value="org.hsqldb.jdbcDriver" />

17. <property name="url" value="jdbc:hsqldb:mem:paging" />

18. <property name="username" value="sa" />

19. <property name="password" value="" />

20. </bean>

22. <bean id="entityManagerFactory"

23. class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">

24. <property name="dataSource" ref="dataSource" />

25. <property name="jpaDialect">

26. <bean class="org.springframework.orm.jpa.vendor.HibernateJpaDialect"/>

27. </property>

28. </bean>

30. <bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager">

31. <property name="entityManagerFactory" ref="entityManagerFactory" />

32. </bean>

33. </beans>

En lançant le test, on obtient le résultat suivant :

Ce test a échoué et cela est normal, car notre base de données en mémoire ne contient aucune donnée. Pour nous assurer que notre service fonctionne, nous devons insérer des données. C'est là que DbUnit intervient. Il permet de peupler la base en utilisant un fichier XML. Appelons le sampleData.xml. Ce dernier sera chargé lors de l'exécution du test en utilisant l'annotation @DatabaseSetup. Nous devons donc créer ce fichier XML et modifier notre test comme suit :

1. <?xml version="1.0" encoding="UTF-8"?>

2. <dataset>

3. <Customer id="0" title="Mr" firstName="Phillip" lastName="Webb"/>

4. <Customer id="1" title="Mr" firstName="Fred" lastName="Bloggs"/>

5. </dataset>

Le test devient :

En relançant le test, nous obtenons le résultat attendu soit :

Le test d'insertion de service a donc bien fonctionné. Nous pouvons aussi tester une suppression de données. Pour cela, on met à jour le service CustomerService en ajoutant une méthode de suppression comme suit :

1. public void remove(int personId) {

2. Customer customer = entityManager.find(Customer.class, personId);

3. entityManager.remove(customer);

4. }

Pour tester cette méthode, nous devons utiliser l'annotation @ExpectedDatabase, qui permet de vérifier les données attendues après la suppression. Comme pour l'annotation @DatabaseSetup, nous passons en paramètre le fichier XML représentant l'état de la base après la suppression.

Dans notre test ci-dessous, nous voulons supprimer le Customer avec l'id 1. Donc, il ne devrait rester en base que le customer avec l'id 0, d'où le fichier (appelons-le expectedData.xml) à passer à l'annotation @ExpectedDatabase :

1. <?xml version="1.0" encoding="UTF-8"?>

2. <dataset>

3. <Customer id="0" title="Mr" firstName="Phillip" lastName="Webb"/>

4. </dataset>

Le test testRemove() est comme suit :

1. @RunWith(SpringJUnit4ClassRunner.class)

2. @ContextConfiguration(locations = { "/applicationContext.xml" })

3. @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, DbUnitTestExecutionListener.class })

4. public class CustomerServiceTest {

6. @Autowired

7. private CustomerService customerService;

9. @Test

10. @DatabaseSetup("/sampleData.xml")

11. public void testFind() throws Exception {

12. List<Customer> customerList = customerService.find("hil");

13. assertEquals(1, customerList.size());

14. assertEquals("Phillip", customerList.get(0).getFirstName());

15. }

17. @Test

18. @DatabaseSetup("/sampleData.xml")

19. @ExpectedDatabase("/expectedData.xml")

20. public void testRemove() throws Exception {

21. customerService.remove(1);

22. }

23. }

En lançant ce test, on obtient le résultat attendu soit :

III - Conclusion

Nous avons vu dans cet article que DbUnit permet d'effectuer la migration de données. Ainsi nous pouvons distribuer les données d'une base sur différentes autres bases de données, en n'injectant dans chacune des bases que les données nécessaires.

IV - Remerciements

Cet article a été publié avec l'aimable autorisation de la société Soat.

Nous tenons à remercier milkoseck pour sa relecture orthographique et Mickael Baron pour la mise au gabarit.