Comment structurer un modèle en MVC? [fermé]


551

Je ne fais que saisir le cadre MVC et je me demande souvent combien de code devrait contenir le modèle. J'ai tendance à avoir une classe d'accès aux données qui a des méthodes comme celle-ci:

public function CheckUsername($connection, $username)
{
    try
    {
        $data = array();
        $data['Username'] = $username;

        //// SQL
        $sql = "SELECT Username FROM" . $this->usersTableName . " WHERE Username = :Username";

        //// Execute statement
        return $this->ExecuteObject($connection, $sql, $data);
    }
    catch(Exception $e)
    {
        throw $e;
    }
}

Mes modèles ont tendance à être une classe d'entités mappée à la table de base de données.

Est-ce que l'objet modèle doit avoir toutes les propriétés mappées de la base de données ainsi que le code ci-dessus ou est-il correct de séparer ce code qui fonctionne réellement la base de données?

Vais-je finir par avoir quatre couches?


133
Pourquoi attrapez-vous des exceptions juste pour les jeter à nouveau?
Bailey Parker

9
@Elias Van Ootegem: vous avez manqué le point. il est inutile de les attraper dans ce cas.
Karoly Horvath

4
@Elias Van Ootegem: hein? si cela fonctionne avec rethrow, cela signifie qu'une couche supérieure intercepte l'exception. Mais s'il y en a un, il l'aurait rattrapé sans ce retour inutile ... (si vous ne l'obtenez toujours pas, veuillez simuler un petit code de test)
Karoly Horvath

3
@Elias Van Ootegem: Je ne sais pas de quoi vous parlez, ne pas gérer une exception sur une couche spécifique ne signifie pas que cela arrêtera l'application. veuillez construire (ou plus précisément: ne pas construire) un exemple de code où cette relance est nécessaire. arrêtons cette conversation hors sujet, s'il vous plaît
Karoly Horvath

6
@drrcknlsn: c'est un argument valide, mais dans ce cas, interceptez au moins l'exception que vous attendez, le générique Exceptionn'a pas beaucoup de valeur documentaire. Personnellement, si je descendais sur cette voie, je choisirais PHPDoc @exception, ou un mécanisme similaire, donc cela apparaît dans la documentation générée.
Karoly Horvath

Réponses:


903

Avertissement: ce qui suit est une description de la façon dont je comprends les modèles de type MVC dans le contexte des applications Web basées sur PHP. Tous les liens externes utilisés dans le contenu sont là pour expliquer les termes et les concepts, et non pour impliquer ma propre crédibilité sur le sujet.

La première chose que je dois clarifier est: le modèle est une couche .

Deuxièmement: il existe une différence entre le MVC classique et ce que nous utilisons dans le développement Web. Voici un peu d'une réponse plus ancienne que j'ai écrite, qui décrit brièvement en quoi elles sont différentes.

Ce qu'un modèle n'est PAS:

Le modèle n'est pas une classe ou un objet unique. C'est une erreur très courante à commettre (moi aussi, bien que la réponse originale ait été écrite quand j'ai commencé à apprendre le contraire) , car la plupart des frameworks perpétuent cette idée fausse.

Ce n'est pas non plus une technique de cartographie relationnelle-objet (ORM) ni une abstraction des tables de base de données. Quiconque vous dit le contraire essaie très probablement de «vendre» un autre ORM flambant neuf ou un framework entier.

Qu'est-ce qu'un modèle:

Dans une adaptation MVC appropriée, le M contient toute la logique métier du domaine et la couche modèle est principalement constituée de trois types de structures:

  • Objets de domaine

    Un objet de domaine est un conteneur logique d'informations purement de domaine; il représente généralement une entité logique dans l'espace du domaine problématique. Communément appelé logique métier .

    Ce serait là que vous définissez comment valider les données avant d'envoyer une facture, ou pour calculer le coût total d'une commande. Dans le même temps, les objets de domaine ignorent complètement le stockage - ni d' (base de données SQL, API REST, fichier texte, etc.) ni même s'ils sont enregistrés ou récupérés.

  • Mappeurs de données

    Ces objets ne sont responsables que du stockage. Si vous stockez des informations dans une base de données, ce serait là que réside le SQL. Ou peut-être utilisez-vous un fichier XML pour stocker des données, et vos Data Mappers analysent à partir et vers des fichiers XML.

  • Prestations de service

    Vous pouvez les considérer comme des "objets de domaine de niveau supérieur", mais au lieu de la logique métier, les services sont responsables de l'interaction entre les objets de domaine et les mappeurs . Ces structures finissent par créer une interface "publique" pour interagir avec la logique métier du domaine. Vous pouvez les éviter, mais à peine de divulguer une logique de domaine dans les contrôleurs .

    Il existe une réponse connexe à ce sujet dans la question sur la mise en œuvre de l' ACL - elle pourrait être utile.

La communication entre la couche modèle et d'autres parties de la triade MVC ne doit se faire que via les services . La séparation claire présente quelques avantages supplémentaires:

  • il aide à faire respecter le principe de responsabilité unique (PRS)
  • offre une «marge de manœuvre» supplémentaire en cas de changement de logique
  • maintient le contrôleur aussi simple que possible
  • donne un plan clair, si vous avez besoin d'une API externe

 

Comment interagir avec un modèle?

Prérequis: regarder les conférences "Global State and Singletons" et "Don't Look For Things!" des Clean Code Talks.

Accéder aux instances de service

Pour que les instances View et Controller (ce que vous pourriez appeler: "couche UI") aient accès à ces services, il existe deux approches générales:

  1. Vous pouvez injecter directement les services requis dans les constructeurs de vos vues et contrôleurs, de préférence à l'aide d'un conteneur DI.
  2. Utilisation d'une fabrique de services comme dépendance obligatoire pour toutes vos vues et contrôleurs.

Comme vous vous en doutez, le conteneur DI est une solution beaucoup plus élégante (tout en n'étant pas la plus simple pour un débutant). Les deux bibliothèques que je recommande de considérer pour cette fonctionnalité seraient le composant DependencyInjection autonome de Syfmony ou Auryn .

Les solutions utilisant une usine et un conteneur DI vous permettent également de partager les instances de divers serveurs à partager entre le contrôleur sélectionné et de visualiser un cycle de demande-réponse donné.

Modification de l'état du modèle

Maintenant que vous pouvez accéder à la couche modèle dans les contrôleurs, vous devez commencer à les utiliser:

public function postLogin(Request $request)
{
    $email = $request->get('email');
    $identity = $this->identification->findIdentityByEmailAddress($email);
    $this->identification->loginWithPassword(
        $identity,
        $request->get('password')
    );
}

Vos contrôleurs ont une tâche très claire: prendre l'entrée utilisateur et, sur la base de cette entrée, changer l'état actuel de la logique métier. Dans cet exemple, les états qui sont modifiés entre "utilisateur anonyme" et "utilisateur connecté".

Le contrôleur n'est pas responsable de la validation de l'entrée de l'utilisateur, car cela fait partie des règles métier et le contrôleur n'appelle certainement pas des requêtes SQL, comme ce que vous verriez ici ou ici (veuillez ne pas les détester, elles sont erronées, pas mal).

Affichage à l'utilisateur du changement d'état.

Ok, l'utilisateur s'est connecté (ou a échoué). Maintenant quoi? Cet utilisateur l'ignore encore. Vous devez donc produire une réponse et c'est la responsabilité d'un point de vue.

public function postLogin()
{
    $path = '/login';
    if ($this->identification->isUserLoggedIn()) {
        $path = '/dashboard';
    }
    return new RedirectResponse($path); 
}

Dans ce cas, la vue a produit l'une des deux réponses possibles, en fonction de l'état actuel de la couche modèle. Pour un cas d'utilisation différent, vous auriez la vue en choisissant différents modèles à restituer, basés sur quelque chose comme "l'article actuellement sélectionné".

La couche de présentation peut en fait être assez élaborée, comme décrit ici: Comprendre les vues MVC en PHP .

Mais je fais juste une API REST!

Bien sûr, il y a des situations où c'est une surpuissance.

MVC est juste une solution concrète pour le principe de séparation des préoccupations . MVC sépare l'interface utilisateur de la logique métier et, dans l'interface utilisateur, elle a séparé la gestion des entrées utilisateur et de la présentation. C'est crucial. Bien que les gens la décrivent souvent comme une "triade", elle n'est pas composée de trois parties indépendantes. La structure ressemble plus à ceci:

Séparation MVC

Cela signifie que, lorsque la logique de votre couche de présentation est presque inexistante, l'approche pragmatique consiste à les conserver en tant que couche unique. Il peut également simplifier considérablement certains aspects de la couche modèle.

En utilisant cette approche, l'exemple de connexion (pour une API) peut être écrit comme suit:

public function postLogin(Request $request)
{
    $email = $request->get('email');
    $data = [
        'status' => 'ok',
    ];
    try {
        $identity = $this->identification->findIdentityByEmailAddress($email);
        $token = $this->identification->loginWithPassword(
            $identity,
            $request->get('password')
        );
    } catch (FailedIdentification $exception) {
        $data = [
            'status' => 'error',
            'message' => 'Login failed!',
        ]
    }

    return new JsonResponse($data);
}

Bien que cela ne soit pas durable, lorsque vous avez une logique compliquée pour le rendu d'un corps de réponse, cette simplification est très utile pour des scénarios plus triviaux. Mais attention , cette approche deviendra un cauchemar, quand on tentera de l'utiliser dans de grandes bases de code avec une logique de présentation complexe.

 

Comment construire le modèle?

Puisqu'il n'y a pas une seule classe "Model" (comme expliqué ci-dessus), vous ne "construisez pas vraiment le modèle". Au lieu de cela, vous commencez à créer des services , qui sont capables d'exécuter certaines méthodes. Et puis implémentez les objets de domaine et les mappeurs .

Un exemple de méthode de service:

Dans les deux approches ci-dessus, il y avait cette méthode de connexion pour le service d'identification. À quoi cela ressemblerait-il réellement? J'utilise une version légèrement modifiée de la même fonctionnalité d' une bibliothèque , que j'ai écrite .. parce que je suis paresseux:

public function loginWithPassword(Identity $identity, string $password): string
{
    if ($identity->matchPassword($password) === false) {
        $this->logWrongPasswordNotice($identity, [
            'email' => $identity->getEmailAddress(),
            'key' => $password, // this is the wrong password
        ]);

        throw new PasswordMismatch;
    }

    $identity->setPassword($password);
    $this->updateIdentityOnUse($identity);
    $cookie = $this->createCookieIdentity($identity);

    $this->logger->info('login successful', [
        'input' => [
            'email' => $identity->getEmailAddress(),
        ],
        'user' => [
            'account' => $identity->getAccountId(),
            'identity' => $identity->getId(),
        ],
    ]);

    return $cookie->getToken();
}

Comme vous pouvez le voir, à ce niveau d'abstraction, il n'y a aucune indication d'où les données ont été extraites. Il peut s'agir d'une base de données, mais il peut également s'agir simplement d'un objet factice à des fins de test. Même les mappeurs de données, qui sont réellement utilisés pour cela, sont cachés dans les privateméthodes de ce service.

private function changeIdentityStatus(Entity\Identity $identity, int $status)
{
    $identity->setStatus($status);
    $identity->setLastUsed(time());
    $mapper = $this->mapperFactory->create(Mapper\Identity::class);
    $mapper->store($identity);
}

Façons de créer des mappeurs

Pour implémenter une abstraction de persistance, les approches les plus flexibles consistent à créer des mappeurs de données personnalisés .

Diagramme de mappeur

De: livre PoEAA

En pratique, ils sont mis en œuvre pour l'interaction avec des classes ou des superclasses spécifiques. Disons que vous avez Customeret Admindans votre code (tous deux héritant d'une Usersuperclasse). Les deux finiraient probablement par avoir un mappeur de correspondance distinct, car ils contiennent des champs différents. Mais vous vous retrouverez également avec des opérations partagées et couramment utilisées. Par exemple: mettre à jour l' heure de la "dernière vue en ligne" . Et au lieu de rendre les mappeurs existants plus compliqués, l'approche la plus pragmatique est d'avoir un "User Mapper" général, qui ne met à jour que l'horodatage.

Quelques commentaires supplémentaires:

  1. Tables et modèle de base de données

    Bien qu'il existe parfois une relation directe 1: 1: 1 entre une table de base de données, un objet de domaine et un mappeur , dans des projets plus importants, il peut être moins courant que prévu:

    • Les informations utilisées par un seul objet de domaine peuvent être mappées à partir de différentes tables, tandis que l'objet lui-même n'a aucune persistance dans la base de données.

      Exemple: si vous générez un rapport mensuel. Cela collecterait des informations à partir de différentes tables, mais il n'y a pas de MonthlyReporttable magique dans la base de données.

    • Un mappeur unique peut affecter plusieurs tables.

      Exemple: lorsque vous stockez des données de l' Userobjet, cet objet de domaine peut contenir une collection d'autres objets de domaine - des Groupinstances. Si vous les modifiez et les stockez User, le Data Mapper devra mettre à jour et / ou insérer des entrées dans plusieurs tables.

    • Les données d'un seul objet de domaine sont stockées dans plusieurs tables.

      Exemple: dans les grands systèmes (pensez: un réseau social de taille moyenne), il peut être pragmatique de stocker les données d'authentification des utilisateurs et les données souvent consultées séparément des gros morceaux de contenu, ce qui est rarement nécessaire. Dans ce cas, il se peut que vous ayez toujours une seule Userclasse, mais les informations qu'elle contient dépendent de l'extraction des détails complets.

    • Pour chaque objet de domaine, il peut y avoir plus d'un mappeur

      Exemple: vous avez un site d'actualités avec un code partagé basé à la fois pour le public et le logiciel de gestion. Mais, alors que les deux interfaces utilisent la même Articleclasse, la gestion a besoin de beaucoup plus d'informations. Dans ce cas, vous auriez deux mappeurs distincts: "interne" et "externe". Chacun effectuant des requêtes différentes, ou même utilise des bases de données différentes (comme en maître ou en esclave).

  2. Une vue n'est pas un modèle

    Les instances de vue dans MVC (si vous n'utilisez pas la variation MVP du modèle) sont responsables de la logique de présentation. Cela signifie que chaque vue jonglera généralement avec au moins quelques modèles. Il acquiert des données de la couche modèle puis, en fonction des informations reçues, choisit un modèle et définit des valeurs.

    L'un des avantages que vous en retirez est la réutilisation. Si vous créez une ListViewclasse, alors, avec un code bien écrit, vous pouvez avoir la même classe en remettant la présentation de la liste d'utilisateurs et des commentaires sous un article. Parce qu'ils ont tous deux la même logique de présentation. Vous changez simplement de modèle.

    Vous pouvez utiliser des modèles PHP natifs ou utiliser un moteur de modélisation tiers. Il peut également exister des bibliothèques tierces, capables de remplacer complètement les instances de View .

  3. Qu'en est-il de l'ancienne version de la réponse?

    Le seul changement majeur est que, ce qui est appelé modèle dans l'ancienne version, est en fait un service . Le reste de "l'analogie de la bibliothèque" se maintient assez bien.

    Le seul défaut que je vois est que ce serait une bibliothèque vraiment étrange, car elle vous renverrait des informations du livre, mais ne vous laisserait pas toucher le livre lui-même, car sinon l'abstraction commencerait à "fuir". Je devrais peut-être penser à une analogie plus appropriée.

  4. Quelle est la relation entre les instances View et Controller ?

    La structure MVC est composée de deux couches: ui et model. Les structures principales de la couche d'interface utilisateur sont les vues et le contrôleur.

    Lorsque vous traitez avec des sites Web qui utilisent le modèle de conception MVC, la meilleure façon est d'avoir une relation 1: 1 entre les vues et les contrôleurs. Chaque vue représente une page entière de votre site Web et dispose d'un contrôleur dédié pour gérer toutes les demandes entrantes pour cette vue particulière.

    Par exemple, pour représenter un article ouvert, vous auriez \Application\Controller\Documentet \Application\View\Document. Cela contiendrait toutes les fonctionnalités principales de la couche d'interface utilisateur, lorsqu'il s'agit de traiter des articles (bien sûr, vous pourriez avoir certains composants XHR qui ne sont pas directement liés aux articles) .


4
@Rinzler, vous remarquerez que nulle part dans ce lien, rien n'est dit sur Model (sauf dans un commentaire). Ce n'est qu'une "interface orientée objet vers les tables de base de données" . Si vous essayez de mouler cela dans un modèle, vous finissez par violer SRP et LSP .
tereško

8
@hafichuk uniquement les situations, quand il est raisonnable d'utiliser le modèle ActiveRecord est pour le prototypage. Lorsque vous commencez à écrire le code destiné à la production, il devient un anti-modèle, car il mélange le stockage et la logique métier. Et puisque Model Layer ignore complètement les autres parties MVC. Cela ne change pas en fonction de la variation du motif d'origine . Même lorsque vous utilisez MVVM. Il n'y a pas de "modèles multiples" et ils ne sont associés à rien. Le modèle est un calque.
tereško

3
Version courte - Les modèles sont des structures de données .
Eddie B

9
Eh bien, voyant qu'il a inventé MVC, l'article peut avoir un certain mérite.
Eddie B

3
... ou même simplement un ensemble de fonctions. MVC ne nécessite pas d'être implémenté dans un style OOP, bien qu'il soit principalement implémenté de cette façon. La chose la plus importante est de séparer les couches et d'établir les bonnes données et contrôler le flux
hek2mgl

37

Tout ce qui relève de la logique métier appartient à un modèle, qu'il s'agisse d'une requête de base de données, de calculs, d'un appel REST, etc.

Vous pouvez avoir l'accès aux données dans le modèle lui-même, le modèle MVC ne vous y empêche pas. Vous pouvez le recouvrir de services, de mappeurs et de quoi d'autre, mais la définition réelle d'un modèle est une couche qui gère la logique métier, rien de plus, rien de moins. Cela peut être une classe, une fonction ou un module complet avec un gazillion d'objets si c'est ce que vous voulez.

Il est toujours plus facile d'avoir un objet séparé qui exécute réellement les requêtes de base de données au lieu de les exécuter directement dans le modèle: cela sera particulièrement utile lors des tests unitaires (en raison de la facilité d'injection d'une dépendance de base de données factice dans votre modèle):

class Database {
   protected $_conn;

   public function __construct($connection) {
       $this->_conn = $connection;
   }

   public function ExecuteObject($sql, $data) {
       // stuff
   }
}

abstract class Model {
   protected $_db;

   public function __construct(Database $db) {
       $this->_db = $db;
   }
}

class User extends Model {
   public function CheckUsername($username) {
       // ...
       $sql = "SELECT Username FROM" . $this->usersTableName . " WHERE ...";
       return $this->_db->ExecuteObject($sql, $data);
   }
}

$db = new Database($conn);
$model = new User($db);
$model->CheckUsername('foo');

De plus, en PHP, vous devez rarement intercepter / renvoyer les exceptions car la trace est préservée, en particulier dans un cas comme votre exemple. Laissez simplement l'exception être levée et rattrapez-la à la place dans le contrôleur.


Ma structure est très similaire, je pense que je la sépare un peu plus. La raison pour laquelle je transmettais la connexion était parce que j'avais besoin d'exécuter des blocs dans les transactions. Je voulais ajouter un utilisateur, puis ajouter l'utilisateur à un rôle, mais revenir en cas de défaillance. La seule façon de résoudre ce problème était de passer la connexion.
Dietpixel

10
-1: il se trouve également qu'il a complètement tort. Le modèle n'est pas une abstraction pour une table.
tereško

1
La Userclasse étend essentiellement le modèle, mais ce n'est pas un objet. L'utilisateur doit être un objet et possède des propriétés telles que: id, nom ... Vous déployez la Userclasse est une aide.
TomSawyer

1
Je pense que vous comprenez MVC mais ne comprenez pas ce qu'est la POO. Dans ce scénario, comme je l'ai dit, Userreprésente un objet, et il devrait avoir les propriétés d'un utilisateur, pas des méthodes comme CheckUsername, que devez-vous faire si vous souhaitez créer un nouvel Userobjet? new User($db)
TomSawyer

@TomSawyer OOP ne signifie pas que les objets doivent avoir des propriétés. Ce que vous décrivez est un modèle de conception, sans rapport avec la question ou la réponse à cette question. La POO est un modèle de langage, pas un modèle de conception.
netcoder

20

Dans Web- "MVC", vous pouvez faire ce que vous voulez.

Le concept original (1) décrivait le modèle comme la logique métier. Il doit représenter l'état de l'application et appliquer une certaine cohérence des données. Cette approche est souvent décrite comme un «modèle gras».

La plupart des frameworks PHP suivent une approche plus superficielle, où le modèle n'est qu'une interface de base de données. Mais à tout le moins, ces modèles devraient encore valider les données et les relations entrantes.

Quoi qu'il en soit, vous n'êtes pas très loin si vous séparez les éléments SQL ou les appels de base de données dans une autre couche. De cette façon, vous n'avez qu'à vous préoccuper des données / comportements réels, pas de l'API de stockage réelle. (Il est cependant déraisonnable d'en faire trop. Par exemple, vous ne pourrez jamais remplacer un backend de base de données par un stockage de fichiers s'il n'a pas été conçu à l'avance.)


8
lien non valide (404)
Kyslik

1
Cela fonctionne à partir de WebArchive: web.archive.org/web/20101229204648/https://stackoverflow.com/…
Tudor

6

Le plus souvent, la plupart des applications auront des données, une partie d'affichage et de traitement et nous les mettons simplement dans les lettres M, Vet C.

Model ( M) -> Possède les attributs qui contiennent l'état d'application et ne savent rien de Vet C.

View ( V) -> A un format d'affichage pour l'application et ne connaît que le modèle de digestion et ne se soucie pas C.

Contrôleur ( C) ----> A une partie de l'application de traitement et agit comme un câblage entre M et V et cela dépend des deux M, Vcontrairement à MetV .

Dans l'ensemble, il existe une séparation des préoccupations entre chacun. À l'avenir, tout changement ou amélioration pourra être ajouté très facilement.


0

Dans mon cas, j'ai une classe de base de données qui gère toutes les interactions directes de base de données telles que l'interrogation, la récupération, etc. Donc, si je devais changer ma base de données de MySQL à PostgreSQL, il n'y aura aucun problème. Il peut donc être utile d'ajouter cette couche supplémentaire.

Chaque table peut avoir sa propre classe et ses méthodes spécifiques, mais pour obtenir réellement les données, elle laisse la classe de base de données les gérer:

Fichier Database.php

class Database {
    private static $connection;
    private static $current_query;
    ...

    public static function query($sql) {
        if (!self::$connection){
            self::open_connection();
        }
        self::$current_query = $sql;
        $result = mysql_query($sql,self::$connection);

        if (!$result){
            self::close_connection();
            // throw custom error
            // The query failed for some reason. here is query :: self::$current_query
            $error = new Error(2,"There is an Error in the query.\n<b>Query:</b>\n{$sql}\n");
            $error->handleError();
        }
        return $result;
    }
 ....

    public static function find_by_sql($sql){
        if (!is_string($sql))
            return false;

        $result_set = self::query($sql);
        $obj_arr = array();
        while ($row = self::fetch_array($result_set))
        {
            $obj_arr[] = self::instantiate($row);
        }
        return $obj_arr;
    }
}

Objet de table classL

class DomainPeer extends Database {

    public static function getDomainInfoList() {
        $sql = 'SELECT ';
        $sql .='d.`id`,';
        $sql .='d.`name`,';
        $sql .='d.`shortName`,';
        $sql .='d.`created_at`,';
        $sql .='d.`updated_at`,';
        $sql .='count(q.id) as queries ';
        $sql .='FROM `domains` d ';
        $sql .='LEFT JOIN queries q on q.domainId = d.id ';
        $sql .='GROUP BY d.id';
        return self::find_by_sql($sql);
    }

    ....
}

J'espère que cet exemple vous aide à créer une bonne structure.


12
"Donc, si je devais changer ma base de données de MySQL à PostgreSQL, il n'y aura aucun problème." Uhhhmmm avec le code ci-dessus, vous auriez un énorme problème à changer quoi que ce soit imo.
PeeHaa

Je vois que ma réponse prend de moins en moins de sens après la modification et au fil du temps. Mais cela devrait rester ici
Ibu

2
Databasedans l'exemple n'est pas une classe. C'est juste un wrapper pour les fonctions. De plus, comment pouvez-vous avoir une "classe d'objets table" sans objet?
tereško

2
@ tereško J'ai lu beaucoup de vos messages et ils sont super. Mais, je ne trouve aucun cadre complet où étudier. Connaissez-vous celui qui "fait bien"? Ou au moins un qui le fait comme vous et quelques autres ici sur SO dites de le faire? Merci.
johnny

Je suis peut-être bien en retard, mais je voudrais souligner que PDO résout presque le problème de la création d'une «couche» de base de données afin de faciliter les changements futurs.
Matthew Goulart
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.