Conception de base de données pour les révisions?


125

Nous avons une exigence dans le projet de stocker toutes les révisions (Historique des modifications) pour les entités dans la base de données. Actuellement, nous avons 2 propositions conçues pour cela:

par exemple pour l'entité "Employé"

Conception 1:

-- Holds Employee Entity
"Employees (EmployeeId, FirstName, LastName, DepartmentId, .., ..)"

-- Holds the Employee Revisions in Xml. The RevisionXML will contain
-- all data of that particular EmployeeId
"EmployeeHistories (EmployeeId, DateModified, RevisionXML)"

Conception 2:

-- Holds Employee Entity
"Employees (EmployeeId, FirstName, LastName, DepartmentId, .., ..)"

-- In this approach we have basically duplicated all the fields on Employees 
-- in the EmployeeHistories and storing the revision data.
"EmployeeHistories (EmployeeId, RevisionId, DateModified, FirstName, 
      LastName, DepartmentId, .., ..)"

Y a-t-il une autre façon de faire cela?

Le problème avec le "Design 1" est que nous devons analyser XML à chaque fois que vous avez besoin d'accéder à des données. Cela ralentira le processus et ajoutera également des limitations telles que nous ne pouvons pas ajouter de jointures sur les champs de données de révision.

Et le problème avec le "Design 2" est que nous devons dupliquer chaque champ sur toutes les entités (nous avons environ 70 à 80 entités pour lesquelles nous voulons maintenir des révisions).



1
FYI: Juste au cas où cela pourrait aider. Le serveur SQL 2008 et supérieur a une technologie qui montre l'historique des changements sur la table..visitez simple-talk.com/sql/learn-sql-server/ ... pour en savoir plus et je suis sûr que DB comme Oracle aura également quelque chose comme ça.
Durai Amuthan.H

Notez que certaines colonnes peuvent stocker elles-mêmes XML ou JSON. Si ce n'est pas le cas maintenant, cela pourrait se produire à l'avenir. Mieux vaut vous assurer que vous n'avez pas besoin d'imbriquer ces données les unes dans les autres.
jakubiszon

Réponses:


38
  1. Ne pas mettre tout dans une table avec un attribut discriminateur IsCurrent. Cela ne fait que causer des problèmes sur toute la ligne, nécessite des clés de substitution et toutes sortes d'autres problèmes.
  2. Design 2 a des problèmes avec les changements de schéma. Si vous modifiez la table Employés, vous devez modifier la table EmployeeHistories et tous les sprocs associés qui vont avec. Double potentiellement votre effort de changement de schéma.
  3. Le design 1 fonctionne bien et s'il est fait correctement, il ne coûte pas cher en termes de performances. Vous pouvez utiliser un schéma xml et même des index pour surmonter d'éventuels problèmes de performances. Votre commentaire sur l'analyse du xml est valide, mais vous pouvez facilement créer une vue à l'aide de xquery - que vous pouvez inclure dans les requêtes et y joindre. Quelque chose comme ça...
CREATE VIEW EmployeeHistory
AS
, FirstName, , DepartmentId

SELECT EmployeeId, RevisionXML.value('(/employee/FirstName)[1]', 'varchar(50)') AS FirstName,

  RevisionXML.value('(/employee/LastName)[1]', 'varchar(100)') AS LastName,

  RevisionXML.value('(/employee/DepartmentId)[1]', 'integer') AS DepartmentId,

FROM EmployeeHistories 

25
Pourquoi dites-vous de ne pas tout stocker dans une table avec le déclencheur IsCurrent. Pourriez-vous me donner des exemples où cela poserait problème.
Nathan W

@Simon Munro Qu'en est-il d'une clé primaire ou d'une clé en cluster? Quelle clé pouvons-nous ajouter dans la table d'historique de Design 1 pour accélérer la recherche?
gotqn

Je suppose qu'un simple SELECT * FROM EmployeeHistory WHERE LastName = 'Doe'résultat dans une analyse complète de la table . Ce n'est pas la meilleure idée pour mettre à l'échelle une application.
Kaii

54

Je pense que la question clé à poser ici est «Qui / Qu'est-ce qui va utiliser l'histoire»?

S'il s'agit principalement de rapports / d'historique lisible par l'homme, nous avons mis en œuvre ce schéma dans le passé ...

Créez une table appelée 'AuditTrail' ou quelque chose qui a les champs suivants ...

[ID] [int] IDENTITY(1,1) NOT NULL,
[UserID] [int] NULL,
[EventDate] [datetime] NOT NULL,
[TableName] [varchar](50) NOT NULL,
[RecordID] [varchar](20) NOT NULL,
[FieldName] [varchar](50) NULL,
[OldValue] [varchar](5000) NULL,
[NewValue] [varchar](5000) NULL

Vous pouvez ensuite ajouter une colonne «LastUpdatedByUserID» à toutes vos tables qui doit être définie à chaque fois que vous effectuez une mise à jour / insertion sur la table.

Vous pouvez ensuite ajouter un déclencheur à chaque table pour intercepter toute insertion / mise à jour qui se produit et créer une entrée dans cette table pour chaque champ modifié. Étant donné que la table est également fournie avec le 'LastUpdateByUserID' pour chaque mise à jour / insertion, vous pouvez accéder à cette valeur dans le déclencheur et l'utiliser lors de l'ajout à la table d'audit.

Nous utilisons le champ RecordID pour stocker la valeur du champ clé de la table en cours de mise à jour. S'il s'agit d'une clé combinée, nous faisons simplement une concaténation de chaînes avec un '~' entre les champs.

Je suis sûr que ce système peut avoir des inconvénients - pour les bases de données fortement mises à jour, les performances peuvent être affectées, mais pour mon application Web, nous obtenons beaucoup plus de lectures que d'écritures et il semble fonctionner plutôt bien. Nous avons même écrit un petit utilitaire VB.NET pour écrire automatiquement les déclencheurs en fonction des définitions de table.

Juste une pensée!


5
Pas besoin de stocker la NewValue, car elle est stockée dans la table auditée.
Petrus Theron

17
À proprement parler, c'est vrai. Mais - lorsqu'il y a un certain nombre de modifications dans le même champ sur une période de temps, le stockage de la nouvelle valeur facilite les requêtes telles que `` montre-moi toutes les modifications apportées par Brian '' car toutes les informations sur une mise à jour sont conservées dans un enregistrement. Juste une pensée!
Chris Roberts

1
Je pense que sysnamepeut être un type de données plus approprié pour les noms de table et de colonne.
Sam

2
@Sam utilisant sysname n'ajoute aucune valeur; cela pourrait même être déroutant ... stackoverflow.com/questions/5720212/…
Jowen

19

L' article History Tables du blog Database Programmer pourrait être utile - couvre certains des points soulevés ici et discute du stockage des deltas.

Éditer

Dans l' essai History Tables , l'auteur ( Kenneth Downs ) recommande de conserver un tableau historique d'au moins sept colonnes:

  1. Horodatage du changement,
  2. Utilisateur qui a effectué le changement,
  3. Un jeton pour identifier l'enregistrement qui a été modifié (où l'historique est conservé séparément de l'état actuel),
  4. Que la modification soit une insertion, une mise à jour ou une suppression,
  5. L'ancienne valeur,
  6. La nouvelle valeur,
  7. Le delta (pour les modifications des valeurs numériques).

Les colonnes qui ne changent jamais ou dont l'historique n'est pas requis ne doivent pas être suivies dans la table d'historique pour éviter les ballonnements. Le stockage du delta pour les valeurs numériques peut faciliter les requêtes ultérieures, même s'il peut être dérivé des anciennes et des nouvelles valeurs.

La table d'historique doit être sécurisée, les utilisateurs non-système ne pouvant pas insérer, mettre à jour ou supprimer des lignes. Seule une purge périodique doit être prise en charge pour réduire la taille globale (et si cela est autorisé par le cas d'utilisation).


14

Nous avons mis en place une solution très similaire à la solution suggérée par Chris Roberts, et cela fonctionne plutôt bien pour nous.

La seule différence est que nous ne stockons que la nouvelle valeur. L'ancienne valeur est après tout stockée dans la ligne d'historique précédente

[ID] [int] IDENTITY(1,1) NOT NULL,
[UserID] [int] NULL,
[EventDate] [datetime] NOT NULL,
[TableName] [varchar](50) NOT NULL,
[RecordID] [varchar](20) NOT NULL,
[FieldName] [varchar](50) NULL,
[NewValue] [varchar](5000) NULL

Disons que vous avez une table avec 20 colonnes. De cette façon, vous n'avez qu'à stocker la colonne exacte qui a changé au lieu d'avoir à stocker la ligne entière.


14

Évitez la conception 1; ce n'est pas très pratique une fois que vous aurez besoin, par exemple, de revenir aux anciennes versions des enregistrements - automatiquement ou "manuellement" à l'aide de la console des administrateurs.

Je ne vois pas vraiment les inconvénients de Design 2. Je pense que le second, la table History devrait contenir toutes les colonnes présentes dans la première, la table Records. Par exemple, dans mysql, vous pouvez facilement créer une table avec la même structure qu'une autre table ( create table X like Y). Et, lorsque vous êtes sur le point de modifier la structure de la table Records dans votre base de données en direct, vous devez alter tablequand même utiliser des commandes - et il n'y a pas de gros effort à exécuter ces commandes également pour votre table Historique.

Remarques

  • La table des enregistrements contient uniquement la dernière révision;
  • La table Historique contient toutes les révisions précédentes des enregistrements dans la table Records;
  • La clé primaire de la table d'historique est une clé primaire de la table Records avec une RevisionIdcolonne ajoutée ;
  • Pensez aux champs auxiliaires supplémentaires tels que ModifiedBy- l'utilisateur qui a créé une révision particulière. Vous pouvez également avoir un champ DeletedBypour suivre qui a supprimé une révision particulière.
  • Pensez à ce que cela DateModifieddevrait signifier - soit cela signifie où cette révision particulière a été créée, ou cela signifiera quand cette révision particulière a été remplacée par une autre. La première nécessite que le champ soit dans la table Records, et semble être plus intuitive à première vue; la deuxième solution semble cependant plus pratique pour les enregistrements supprimés (date à laquelle cette révision particulière a été supprimée). Si vous optez pour la première solution, vous aurez probablement besoin d'un deuxième champ DateDeleted(seulement si vous en avez besoin bien sûr). Cela dépend de vous et de ce que vous voulez réellement enregistrer.

Les opérations dans Design 2 sont très simples:

Modifier
  • copier l'enregistrement de la table Records vers la table History, lui donner un nouvel RevisionId (s'il n'est pas déjà présent dans la table Records), gérer DateModified (dépend de la façon dont vous l'interprétez, voir les notes ci-dessus)
  • continuer avec la mise à jour normale de l'enregistrement dans la table des enregistrements
Supprimer
  • faites exactement la même chose que dans la première étape de l'opération de modification. Traitez DateModified / DateDeleted en conséquence, selon l'interprétation que vous avez choisie.
Annuler la suppression (ou revenir en arrière)
  • prendre la révision la plus élevée (ou une révision particulière?) de la table Historique et la copier dans la table Enregistrements
Liste de l'historique des révisions pour un enregistrement particulier
  • sélectionnez dans la table Historique et dans la table Enregistrements
  • pensez à ce que vous attendez exactement de cette opération; il déterminera probablement les informations dont vous avez besoin dans les champs DateModified / DateDeleted (voir les notes ci-dessus)

Si vous optez pour Design 2, toutes les commandes SQL nécessaires pour le faire seront très très simples, ainsi que la maintenance! Peut-être que ce sera beaucoup plus facile si vous utilisez les colonnes auxiliaires ( RevisionId, DateModified) également dans la table Records - pour garder les deux tables exactement dans la même structure (sauf pour les clés uniques)! Cela permettra des commandes SQL simples, qui seront tolérantes à tout changement de structure de données:

insert into EmployeeHistory select * from Employe where ID = XX

N'oubliez pas d'utiliser les transactions!

En ce qui concerne la mise à l'échelle , cette solution est très efficace, car vous ne transformez aucune donnée à partir de XML dans les deux sens, il suffit de copier des lignes entières de la table - des requêtes très simples, à l'aide d'indices - très efficace!


12

Si vous devez stocker l'historique, créez une table fantôme avec le même schéma que la table que vous suivez et une colonne «Date de révision» et «Type de révision» (par exemple, «supprimer», «mettre à jour»). Ecrivez (ou générez - voir ci-dessous) un ensemble de déclencheurs pour remplir la table d'audit.

Il est assez simple de créer un outil qui lira le dictionnaire de données système pour une table et générera un script qui crée la table d'ombre et un ensemble de déclencheurs pour la remplir.

N'essayez pas d'utiliser XML pour cela, le stockage XML est beaucoup moins efficace que le stockage de table de base de données natif utilisé par ce type de déclencheur.


3
+1 pour la simplicité! Certains vont sur-concevoir par peur des changements ultérieurs, alors que la plupart du temps, aucun changement ne se produit réellement! De plus, il est beaucoup plus facile de gérer les historiques dans une table et les enregistrements réels dans une autre que de les avoir tous dans une table (cauchemar) avec un indicateur ou un statut. Il s'appelle «KISS» et vous récompensera normalement à long terme.
Jeach

+1 tout à fait d'accord, exactement ce que je dis dans ma réponse ! Simple et puissant!
TMS

8

Ramesh, j'ai été impliqué dans le développement de système basé sur la première approche.
Il s'est avéré que le stockage des révisions sous forme de XML conduit à une énorme croissance de la base de données et ralentit considérablement les choses.
Mon approche serait d'avoir une table par entité:

Employee (Id, Name, ... , IsActive)  

IsActive est un signe de la dernière version

Si vous souhaitez associer des informations supplémentaires à des révisions, vous pouvez créer une table séparée contenant ces informations et la lier à des tables d'entités à l'aide de la relation PK \ FK.

De cette façon, vous pouvez stocker toutes les versions des employés dans une table. Avantages de cette approche:

  • Structure de base de données simple
  • Aucun conflit car la table devient uniquement en ajout
  • Vous pouvez revenir à la version précédente en changeant simplement l'indicateur IsActive
  • Pas besoin de jointures pour obtenir l'historique des objets

Notez que vous devez autoriser la clé primaire à ne pas être unique.


6
J'utiliserais une colonne "RevisionNumber" ou "RevisionDate" au lieu ou en plus de IsActive, afin que vous puissiez voir toutes les révisions dans l'ordre.
Sklivvz

J'utiliserais un "parentRowId" parce que cela vous donne un accès facile aux versions précédentes ainsi que la possibilité de trouver rapidement la base et la fin.
chacham15

6

La façon dont j'ai vu cela fait dans le passé est d'avoir

Employees (EmployeeId, DateModified, < Employee Fields > , boolean isCurrent );

Vous ne «mettez jamais à jour» sur cette table (sauf pour changer la validité de isCurrent), insérez simplement de nouvelles lignes. Pour tout EmployeeId donné, une seule ligne peut avoir isCurrent == 1.

La complexité de la maintenance peut être masquée par des vues et des déclencheurs "au lieu de" (dans oracle, je présume des choses similaires dans d'autres SGBDR), vous pouvez même accéder à des vues matérialisées si les tables sont trop grandes et ne peuvent pas être gérées par des index) .

Cette méthode est correcte, mais vous pouvez vous retrouver avec des requêtes complexes.

Personnellement, j'aime beaucoup votre façon de faire Design 2, c'est ainsi que je l'ai fait dans le passé également. C'est simple à comprendre, simple à mettre en œuvre et simple à entretenir.

Cela crée également très peu de frais généraux pour la base de données et l'application, en particulier lors de l'exécution de requêtes de lecture, ce que vous ferez probablement 99% du temps.

Il serait également assez facile d'automatiser la création des tables d'historique et des déclencheurs à maintenir (en supposant que cela se fasse via des déclencheurs).


4

Les révisions des données sont un aspect du concept de « temps de validité » d'une base de données temporelle. De nombreuses recherches ont été menées à ce sujet, et de nombreux modèles et lignes directrices ont émergé. J'ai écrit une longue réponse avec un tas de références à cette question pour les personnes intéressées.


4

Je vais partager avec vous ma conception et elle est différente de vos deux conceptions en ce qu'elle nécessite une table pour chaque type d'entité. J'ai trouvé que la meilleure façon de décrire toute conception de base de données est via ERD, voici la mienne:

entrez la description de l'image ici

Dans cet exemple, nous avons une entité nommée employé . La table utilisateur contient les enregistrements de vos utilisateurs et entity et entity_revision sont deux tables qui contiennent l'historique des révisions pour tous les types d'entités que vous aurez dans votre système. Voici comment fonctionne cette conception:

Les deux champs de entity_id et revision_id

Chaque entité de votre système aura son propre identifiant d'entité. Votre entité peut subir des révisions, mais son entity_id restera le même. Vous devez conserver cet identifiant d'entité dans la table de vos employés (en tant que clé étrangère). Vous devez également stocker le type de votre entité dans la table des entités (par exemple «employé»). Maintenant, comme pour le revision_id, comme son nom l'indique, il garde une trace de vos révisions d'entité. Le meilleur moyen que j'ai trouvé pour cela est d'utiliser le employee_id comme votre revision_id. Cela signifie que vous aurez des identifiants de révision en double pour différents types d'entités, mais ce n'est pas un plaisir pour moi (je ne suis pas sûr de votre cas). La seule remarque importante à faire est que la combinaison de entity_id et de revision_id doit être unique.

Il existe également un champ d' état dans la table entity_revision qui indique l'état de la révision. Il peut avoir l' un des trois états: latest, obsoleteou deleted(ne pas se fier à la date de révisions vous aide beaucoup pour booster vos requêtes).

Une dernière remarque sur revision_id, je n'ai pas créé de clé étrangère connectant employee_id à revision_id car nous ne voulons pas modifier la table entity_revision pour chaque type d'entité que nous pourrions ajouter à l'avenir.

INSERTION

Pour chaque employé que vous souhaitez insérer dans la base de données, vous ajouterez également un enregistrement à entity et entity_revision . Ces deux derniers enregistrements vous aideront à savoir par qui et quand un enregistrement a été inséré dans la base de données.

METTRE À JOUR

Chaque mise à jour d'un enregistrement d'employé existant sera implémentée sous forme de deux insertions, une dans la table des employés et une dans entity_revision. Le second vous aidera à savoir par qui et quand le dossier a été mis à jour.

EFFACEMENT

Pour supprimer un employé, un enregistrement est inséré dans entity_revision indiquant la suppression et terminée.

Comme vous pouvez le voir dans cette conception, aucune donnée n'est jamais modifiée ou supprimée de la base de données et, plus important encore, chaque type d'entité ne nécessite qu'une seule table. Personnellement, je trouve cette conception très flexible et facile à utiliser. Mais je ne suis pas sûr de vous car vos besoins peuvent être différents.

[METTRE À JOUR]

Ayant pris en charge les partitions dans les nouvelles versions de MySQL, je pense que ma conception est également dotée de l'une des meilleures performances. On peut partitionner la entitytable en utilisant le typechamp tout en partitionnant en entity_revisionutilisant son statechamp. Cela stimulera SELECTde loin les requêtes tout en gardant la conception simple et propre.


3

Si effectivement une piste d'audit est tout ce dont vous avez besoin, je pencherais vers la solution de table d'audit (complète avec des copies dénormalisées de la colonne importante sur d'autres tables, par exemple UserName). Gardez à l'esprit, cependant, que l'expérience amère indique qu'une seule table d'audit sera un énorme goulot d'étranglement sur la route; cela vaut probablement la peine de créer des tables d'audit individuelles pour toutes vos tables auditées.

Si vous avez besoin de suivre les versions historiques (et / ou futures) réelles, la solution standard consiste à suivre la même entité avec plusieurs lignes en utilisant une combinaison de valeurs de début, de fin et de durée. Vous pouvez utiliser une vue pour faciliter l'accès aux valeurs actuelles. Si c'est l'approche que vous adoptez, vous pouvez rencontrer des problèmes si vos données versionnées font référence à des données mutables mais non versionnées.


3

Si vous souhaitez effectuer le premier, vous pouvez également utiliser XML pour la table Employés. La plupart des bases de données les plus récentes vous permettent d'interroger des champs XML, ce n'est donc pas toujours un problème. Et il peut être plus simple d'avoir un moyen d'accéder aux données des employés, qu'il s'agisse de la dernière version ou d'une version antérieure.

J'essaierais cependant la deuxième approche. Vous pouvez simplifier cela en n'ayant qu'une seule table Employés avec un champ DateModified. EmployeeId + DateModified serait la clé primaire et vous pouvez stocker une nouvelle révision en ajoutant simplement une ligne. De cette façon, l'archivage des anciennes versions et la restauration des versions à partir des archives sont également plus faciles.

Une autre façon de faire cela pourrait être le modèle de datavault de Dan Linstedt. J'ai fait un projet pour le bureau de statistique néerlandais qui a utilisé ce modèle et cela fonctionne assez bien. Mais je ne pense pas que ce soit directement utile pour une utilisation quotidienne des bases de données. Vous aurez peut-être des idées en lisant ses articles.


2

Que diriez-vous:

  • EmployeeID
  • Date modifiée
    • et / ou numéro de révision, selon la façon dont vous souhaitez le suivre
  • ModifiedByUSerId
    • plus toute autre information que vous souhaitez suivre
  • Champs des employés

Vous créez la clé primaire (EmployeeId, DateModified), et pour obtenir les enregistrements "actuels", il vous suffit de sélectionner MAX (DateModified) pour chaque employeeid. Le stockage d'un IsCurrent est une très mauvaise idée, car tout d'abord, il peut être calculé, et deuxièmement, il est beaucoup trop facile pour les données de se désynchroniser.

Vous pouvez également créer une vue qui répertorie uniquement les derniers enregistrements et l'utiliser principalement lorsque vous travaillez dans votre application. La bonne chose à propos de cette approche est que vous n'avez pas de doublons de données et que vous n'avez pas à collecter des données à partir de deux endroits différents (actuels dans Employees et archivés dans EmployeesHistory) pour obtenir tout l'historique ou la restauration, etc.) .


Un inconvénient de cette approche est que la table se développera plus rapidement que si vous utilisez deux tables.
cdmckay

2

Si vous souhaitez vous fier aux données d'historique (pour des raisons de rapport), vous devez utiliser une structure comme celle-ci:

// Holds Employee Entity
"Employees (EmployeeId, FirstName, LastName, DepartmentId, .., ..)"

// Holds the Employee revisions in rows.
"EmployeeHistories (HistoryId, EmployeeId, DateModified, OldValue, NewValue, FieldName)"

Ou solution globale pour l'application:

// Holds Employee Entity
"Employees (EmployeeId, FirstName, LastName, DepartmentId, .., ..)"

// Holds all entities revisions in rows.
"EntityChanges (EntityName, EntityId, DateModified, OldValue, NewValue, FieldName)"

Vous pouvez également enregistrer vos révisions au format XML, vous n'avez alors qu'un seul enregistrement pour une révision. Ce sera ressemble à:

// Holds Employee Entity
"Employees (EmployeeId, FirstName, LastName, DepartmentId, .., ..)"

// Holds all entities revisions in rows.
"EntityChanges (EntityName, EntityId, DateModified, XMLChanges)"

1
Mieux: utilisez le sourcing d'événements :)
dariol

1

Nous avons eu des exigences similaires, et ce que nous avons constaté, c'est que souvent, l'utilisateur veut simplement voir ce qui a été changé, pas nécessairement annuler les changements.

Je ne sais pas quel est votre cas d'utilisation, mais nous avons créé une table d'audit qui est automatiquement mise à jour avec les modifications apportées à une entité commerciale, y compris le nom convivial de toutes les références et énumérations de clés étrangères.

Chaque fois que l'utilisateur enregistre ses modifications, nous rechargeons l'ancien objet, exécutons une comparaison, enregistrons les modifications et enregistrons l'entité (tout est effectué dans une seule transaction de base de données en cas de problème).

Cela semble très bien fonctionner pour nos utilisateurs et nous évite le casse-tête d'avoir une table d'audit complètement séparée avec les mêmes champs que notre entité commerciale.


0

Il semble que vous souhaitiez suivre les modifications d’entités spécifiques au fil du temps, par exemple ID 3, «bob», «123 main street», puis un autre ID 3, «bob» «234 elm st», etc. pour sortir un historique des révisions montrant chaque adresse à laquelle "bob" a été.

La meilleure façon de faire est d'avoir un champ "est en cours" sur chaque enregistrement, et (probablement) un horodatage ou FK à une table de date / heure.

Les insertions doivent ensuite définir le "est en cours" et également annuler le "est en cours" sur l'enregistrement précédent "est en cours". Les requêtes doivent spécifier le "est en cours", sauf si vous voulez tout l'historique.

Il y a d'autres ajustements à cela s'il s'agit d'un très grand tableau ou si un grand nombre de révisions est attendu, mais c'est une approche assez standard.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.