Conception de modèle de référentiel appropriée en PHP?

291

Préface: J'essaie d'utiliser le modèle de référentiel dans une architecture MVC avec des bases de données relationnelles.

J'ai récemment commencé à apprendre TDD en PHP, et je me rends compte que ma base de données est couplée beaucoup trop étroitement avec le reste de mon application. J'ai lu sur les référentiels et sur l'utilisation d'un conteneur IoC pour "l'injecter" dans mes contrôleurs. Des trucs très cool. Mais nous avons maintenant quelques questions pratiques sur la conception du référentiel. Prenons l'exemple suivant.

<?php

class DbUserRepository implements UserRepositoryInterface
{
    protected $db;

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

    public function findAll()
    {
    }

    public function findById($id)
    {
    }

    public function findByName($name)
    {
    }

    public function create($user)
    {
    }

    public function remove($user)
    {
    }

    public function update($user)
    {
    }
}

Problème n ° 1: trop de champs

Toutes ces méthodes de recherche utilisent une SELECT *approche de sélection de tous les champs ( ). Cependant, dans mes applications, j'essaie toujours de limiter le nombre de champs que j'obtiens, car cela ajoute souvent des frais généraux et ralentit les choses. Pour ceux qui utilisent ce modèle, comment gérez-vous cela?

Problème n ° 2: trop de méthodes

Bien que cette classe soit agréable en ce moment, je sais que dans une application réelle, j'ai besoin de beaucoup plus de méthodes. Par exemple:

  • findAllByNameAndStatus
  • findAllInCountry
  • findAllWithEmailAddressSet
  • findAllByAgeAndGender
  • findAllByAgeAndGenderOrderByAge
  • Etc.

Comme vous pouvez le voir, il pourrait y avoir une très, très longue liste de méthodes possibles. Et puis, si vous ajoutez le problème de sélection de champ ci-dessus, le problème s'aggrave. Dans le passé, je mettais normalement toute cette logique dans mon contrôleur:

<?php

class MyController
{
    public function users()
    {
        $users = User::select('name, email, status')
            ->byCountry('Canada')->orderBy('name')->rows();

        return View::make('users', array('users' => $users));
    }
}

Avec mon approche de référentiel, je ne veux pas me retrouver avec ceci:

<?php

class MyController
{
    public function users()
    {
        $users = $this->repo->get_first_name_last_name_email_username_status_by_country_order_by_name('Canada');

        return View::make('users', array('users' => $users))
    }

}

Problème n ° 3: impossible de faire correspondre une interface

Je vois l'avantage d'utiliser des interfaces pour les référentiels, donc je peux échanger mon implémentation (à des fins de test ou autre). Ma compréhension des interfaces est qu'elles définissent un contrat qu'une implémentation doit suivre. C'est formidable jusqu'à ce que vous commenciez à ajouter des méthodes supplémentaires à vos référentiels comme findAllInCountry(). Maintenant, je dois mettre à jour mon interface pour avoir également cette méthode, sinon, d'autres implémentations peuvent ne pas l'avoir, et cela pourrait casser mon application. Par cela se sent fou ... un cas de la queue qui remue le chien.

Modèle de spécification?

Cela conduit à me croire que dépôt ne devrait avoir un nombre fixe de méthodes (comme save(), remove(), find(), findAll(), etc.). Mais alors comment exécuter des recherches spécifiques? J'ai entendu parler du modèle de spécification , mais il me semble que cela ne réduit qu'un ensemble complet d'enregistrements (via IsSatisfiedBy()), ce qui présente clairement des problèmes de performances majeurs si vous tirez d'une base de données.

Aidez-moi?

De toute évidence, je dois repenser un peu les choses lorsque je travaille avec des référentiels. Quelqu'un peut-il nous éclairer sur la meilleure façon de gérer cela?

Jonathan
la source

Réponses:

208

J'ai pensé que je prendrais une fissure pour répondre à ma propre question. Ce qui suit n'est qu'une façon de résoudre les problèmes 1 à 3 de ma question initiale.

Avertissement: je ne peux pas toujours utiliser les bons termes lors de la description des modèles ou des techniques. Désolé.

Les objectifs:

  • Créez un exemple complet d'un contrôleur de base pour l'affichage et l'édition Users.
  • Tout le code doit être entièrement testable et moquable.
  • Le contrôleur ne devrait avoir aucune idée de l'endroit où les données sont stockées (ce qui signifie qu'elles peuvent être modifiées).
  • Exemple pour montrer une implémentation SQL (la plus courante).
  • Pour des performances optimales, les contrôleurs ne doivent recevoir que les données dont ils ont besoin, pas de champs supplémentaires.
  • La mise en œuvre doit tirer parti d'un certain type de mappeur de données pour faciliter le développement.
  • L'implémentation doit pouvoir effectuer des recherches de données complexes.

La solution

Je divise mon interaction de stockage persistant (base de données) en deux catégories: R (lecture) et CUD (création, mise à jour, suppression). D'après mon expérience, les lectures sont vraiment ce qui ralentit une application. Et bien que la manipulation des données (CUD) soit en réalité plus lente, elle se produit beaucoup moins fréquemment et est donc beaucoup moins préoccupante.

CUD (Créer, mettre à jour, supprimer) est facile. Cela impliquera de travailler avec des modèles réels , qui sont ensuite transmis à mon Repositoriespour la persistance. Remarque, mes référentiels fourniront toujours une méthode de lecture, mais simplement pour la création d'objets, pas d'affichage. Plus sur cela plus tard.

R (Lire) n'est pas si facile. Pas de modèles ici, juste des objets de valeur . Utilisez des tableaux si vous préférez . Ces objets peuvent représenter un seul modèle ou un mélange de nombreux modèles, n'importe quoi vraiment. Celles-ci ne sont pas très intéressantes en elles-mêmes, mais comment elles sont générées. J'utilise ce que j'appelle Query Objects.

Le code:

Modèle utilisateur

Commençons simplement avec notre modèle utilisateur de base. Notez qu'il n'y a aucun élément d'extension ou de base de données ORM. Juste pure gloire du modèle. Ajoutez vos getters, setters, validation, peu importe.

class User
{
    public $id;
    public $first_name;
    public $last_name;
    public $gender;
    public $email;
    public $password;
}

Interface de référentiel

Avant de créer mon référentiel d'utilisateurs, je souhaite créer mon interface de référentiel. Cela définira le «contrat» que les référentiels doivent suivre pour être utilisé par mon contrôleur. N'oubliez pas que mon contrôleur ne saura pas où les données sont réellement stockées.

Notez que mes référentiels ne contiendront chacun que ces trois méthodes. La save()méthode est responsable à la fois de la création et de la mise à jour des utilisateurs, simplement selon que l'objet utilisateur possède ou non un ensemble d'ID.

interface UserRepositoryInterface
{
    public function find($id);
    public function save(User $user);
    public function remove(User $user);
}

Implémentation du référentiel SQL

Maintenant, pour créer mon implémentation de l'interface. Comme mentionné, mon exemple allait être avec une base de données SQL. Notez l'utilisation d'un mappeur de données pour éviter d'avoir à écrire des requêtes SQL répétitives.

class SQLUserRepository implements UserRepositoryInterface
{
    protected $db;

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

    public function find($id)
    {
        // Find a record with the id = $id
        // from the 'users' table
        // and return it as a User object
        return $this->db->find($id, 'users', 'User');
    }

    public function save(User $user)
    {
        // Insert or update the $user
        // in the 'users' table
        $this->db->save($user, 'users');
    }

    public function remove(User $user)
    {
        // Remove the $user
        // from the 'users' table
        $this->db->remove($user, 'users');
    }
}

Interface d'objet de requête

Maintenant, avec CUD (Créer, Mettre à jour, Supprimer) pris en charge par notre référentiel, nous pouvons nous concentrer sur le R (Lire). Les objets de requête sont simplement une encapsulation d'un certain type de logique de recherche de données. Ce ne sont pas des générateurs de requêtes. En l'abstrait comme notre référentiel, nous pouvons changer son implémentation et la tester plus facilement. Un exemple d'un objet de requête peut être un AllUsersQueryou AllActiveUsersQuery, ou même MostCommonUserFirstNames.

Vous pensez peut-être "ne puis-je pas simplement créer des méthodes dans mes référentiels pour ces requêtes?" Oui, mais voici pourquoi je ne fais pas ça:

  • Mes référentiels sont destinés à travailler avec des objets de modèle. Dans une application du monde réel, pourquoi aurais-je besoin d'obtenir le passwordchamp si je cherche à répertorier tous mes utilisateurs?
  • Les référentiels sont souvent spécifiques à un modèle, mais les requêtes impliquent souvent plusieurs modèles. Alors, dans quel référentiel mettez-vous votre méthode?
  • Cela rend mes référentiels très simples, pas une classe de méthodes gonflée.
  • Toutes les requêtes sont désormais organisées dans leurs propres classes.
  • Vraiment, à ce stade, les référentiels existent simplement pour abstraire ma couche de base de données.

Pour mon exemple, je vais créer un objet de requête pour rechercher "AllUsers". Voici l'interface:

interface AllUsersQueryInterface
{
    public function fetch($fields);
}

Implémentation d'un objet de requête

C'est là que nous pouvons à nouveau utiliser un mappeur de données pour accélérer le développement. Notez que j'autorise un ajustement à l'ensemble de données renvoyé - les champs. C'est à peu près autant que je veux aller avec la manipulation de la requête effectuée. N'oubliez pas que mes objets de requête ne sont pas des générateurs de requêtes. Ils effectuent simplement une requête spécifique. Cependant, comme je sais que j'utiliserai probablement celui-ci beaucoup, dans un certain nombre de situations différentes, je me donne la possibilité de spécifier les champs. Je ne veux jamais retourner les champs dont je n'ai pas besoin!

class AllUsersQuery implements AllUsersQueryInterface
{
    protected $db;

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

    public function fetch($fields)
    {
        return $this->db->select($fields)->from('users')->orderBy('last_name, first_name')->rows();
    }
}

Avant de passer au contrôleur, je veux montrer un autre exemple pour illustrer sa puissance. J'ai peut-être un moteur de création de rapports et je dois créer un rapport pour AllOverdueAccounts. Cela pourrait être délicat avec mon mappeur de données, et je souhaiterais peut-être écrire des données réelles SQLdans cette situation. Pas de problème, voici à quoi pourrait ressembler cet objet de requête:

class AllOverdueAccountsQuery implements AllOverdueAccountsQueryInterface
{
    protected $db;

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

    public function fetch()
    {
        return $this->db->query($this->sql())->rows();
    }

    public function sql()
    {
        return "SELECT...";
    }
}

Cela conserve bien toute ma logique pour ce rapport dans une seule classe, et c'est facile à tester. Je peux me moquer de mon contenu, ou même utiliser une implémentation complètement différente.

Le controlle

Maintenant, la partie amusante - réunir toutes les pièces. Notez que j'utilise l'injection de dépendance. Généralement, les dépendances sont injectées dans le constructeur, mais je préfère en fait les injecter directement dans mes méthodes de contrôleur (routes). Cela minimise le graphique d'objet du contrôleur, et je le trouve plus lisible. Notez que si vous n'aimez pas cette approche, utilisez simplement la méthode traditionnelle du constructeur.

class UsersController
{
    public function index(AllUsersQueryInterface $query)
    {
        // Fetch user data
        $users = $query->fetch(['first_name', 'last_name', 'email']);

        // Return view
        return Response::view('all_users.php', ['users' => $users]);
    }

    public function add()
    {
        return Response::view('add_user.php');
    }

    public function insert(UserRepositoryInterface $repository)
    {
        // Create new user model
        $user = new User;
        $user->first_name = $_POST['first_name'];
        $user->last_name = $_POST['last_name'];
        $user->gender = $_POST['gender'];
        $user->email = $_POST['email'];

        // Save the new user
        $repository->save($user);

        // Return the id
        return Response::json(['id' => $user->id]);
    }

    public function view(SpecificUserQueryInterface $query, $id)
    {
        // Load user data
        if (!$user = $query->fetch($id, ['first_name', 'last_name', 'gender', 'email'])) {
            return Response::notFound();
        }

        // Return view
        return Response::view('view_user.php', ['user' => $user]);
    }

    public function edit(SpecificUserQueryInterface $query, $id)
    {
        // Load user data
        if (!$user = $query->fetch($id, ['first_name', 'last_name', 'gender', 'email'])) {
            return Response::notFound();
        }

        // Return view
        return Response::view('edit_user.php', ['user' => $user]);
    }

    public function update(UserRepositoryInterface $repository)
    {
        // Load user model
        if (!$user = $repository->find($id)) {
            return Response::notFound();
        }

        // Update the user
        $user->first_name = $_POST['first_name'];
        $user->last_name = $_POST['last_name'];
        $user->gender = $_POST['gender'];
        $user->email = $_POST['email'];

        // Save the user
        $repository->save($user);

        // Return success
        return true;
    }

    public function delete(UserRepositoryInterface $repository)
    {
        // Load user model
        if (!$user = $repository->find($id)) {
            return Response::notFound();
        }

        // Delete the user
        $repository->delete($user);

        // Return success
        return true;
    }
}

Dernières pensées:

Les points importants à noter ici sont que lorsque je modifie (crée, met à jour ou supprime) des entités, je travaille avec des objets de modèle réels et j'effectue la persistance via mes référentiels.

Cependant, lorsque j'affiche (sélectionne des données et les envoie aux vues), je ne travaille pas avec des objets de modèle, mais plutôt avec des objets de valeur ancienne. Je sélectionne uniquement les champs dont j'ai besoin et il est conçu pour que je puisse maximiser mes performances de recherche de données.

Mes référentiels restent très propres, et à la place, ce "désordre" est organisé dans mes requêtes de modèle.

J'utilise un mappeur de données pour aider au développement, car il est tout simplement ridicule d'écrire du SQL répétitif pour les tâches courantes. Cependant, vous pouvez absolument écrire SQL si nécessaire (requêtes compliquées, rapports, etc.). Et quand vous le faites, il est bien caché dans une classe correctement nommée.

J'adorerais entendre votre point de vue sur mon approche!


Mise à jour de juillet 2015:

On m'a demandé dans les commentaires où je me suis retrouvé avec tout cela. Eh bien, pas si loin en fait. Honnêtement, je n'aime toujours pas vraiment les dépôts. Je les trouve exagérés pour les recherches de base (surtout si vous utilisez déjà un ORM), et désordonnés lorsque vous travaillez avec des requêtes plus compliquées.

Je travaille généralement avec un ORM de style ActiveRecord, donc le plus souvent je vais simplement référencer ces modèles directement dans mon application. Cependant, dans les situations où j'ai des requêtes plus complexes, j'utiliserai des objets de requête pour les rendre plus réutilisables. Je dois également noter que j'injecte toujours mes modèles dans mes méthodes, ce qui les rend plus faciles à simuler dans mes tests.

Jonathan
la source
4
@PeeHaa Encore une fois, c'était pour garder les exemples simples. Il est très courant de laisser des morceaux de code hors d'un exemple s'ils ne se rapportent pas spécifiquement au sujet traité. En réalité, je passerais dans mes dépendances.
Jonathan
4
Il est intéressant que vous divisiez votre création, mise à jour et suppression de votre lecture. Je pensais qu'il valait la peine de mentionner la séparation des responsabilités de requête de commande (CQRS), qui le fait formellement. martinfowler.com/bliki/CQRS.html
Adam
2
@Jonathan Cela fait un an et demi que vous avez répondu à votre propre question. Je me demandais si vous êtes toujours satisfait de votre réponse et si c'est maintenant votre principale solution pour la plupart de vos projets? Ces dernières semaines, j'ai lu allot sur les référentiels et j'ai vu allot de personnes avoir leur propre interprétation de la façon dont cela devrait être implémenté. Vous l'appelez des objets de requête, mais c'est un modèle existant non? Je pense que je l'ai vu utilisé dans d'autres langues.
Boedy
1
@Jonathan: Comment gérez-vous les requêtes qui ne devraient pas être "ID" mais par exemple "nom d'utilisateur" ou encore des requêtes plus compliquées avec plus d'une condition?
Gizzmo
1
@Gizzmo En utilisant des objets de requête, vous pouvez passer des paramètres supplémentaires pour vous aider avec vos requêtes plus compliquées. Par exemple, vous pouvez le faire dans le constructeur: new Query\ComplexUserLookup($username, $anotherCondition). Ou faites-le via des méthodes de définition $query->setUsername($username);. Vous pouvez vraiment concevoir cela, mais cela a du sens pour votre application particulière, et je pense que les objets de requête laissent beaucoup de flexibilité ici.
Jonathan
48

D'après mon expérience, voici quelques réponses à vos questions:

Q: Comment gérons-nous le retour des champs dont nous n'avons pas besoin?

R: D'après mon expérience, cela se résume vraiment à traiter des entités complètes par rapport aux requêtes ad hoc.

Une entité complète est quelque chose comme un Userobjet. Il a des propriétés et des méthodes, etc. C'est un citoyen de première classe dans votre base de code.

Une requête ad hoc renvoie des données, mais nous ne savons rien de plus. Lorsque les données sont transmises autour de l'application, elles le sont sans contexte. Est-ce un User? A Useravec quelques Orderinformations jointes? Nous ne savons pas vraiment.

Je préfère travailler avec des entités complètes.

Vous avez raison de rapporter souvent des données que vous n'utiliserez pas, mais vous pouvez résoudre ce problème de différentes manières:

  1. Cachez agressivement les entités afin de ne payer le prix de lecture qu'une seule fois à partir de la base de données.
  2. Passez plus de temps à modéliser vos entités afin qu'elles aient de bonnes distinctions entre elles. (Envisagez de diviser une grande entité en deux entités plus petites, etc.)
  3. Envisagez d'avoir plusieurs versions d'entités. Vous pouvez avoir un Userpour le back-end et peut-être un UserSmallpour les appels AJAX. On pourrait avoir 10 propriétés et on a 3 propriétés.

Les inconvénients de travailler avec des requêtes ad hoc:

  1. Vous vous retrouvez avec essentiellement les mêmes données sur de nombreuses requêtes. Par exemple, avec un User, vous finirez par écrire la même chose select *pour de nombreux appels. Un appel obtiendra 8 champs sur 10, un obtiendra 5 sur 10, un obtiendra 7 sur 10. Pourquoi ne pas tout remplacer par un appel qui obtient 10 sur 10? La raison pour laquelle c'est mauvais est que c'est un meurtre à refactoriser / tester / se moquer.
  2. Il devient très difficile de raisonner à un niveau élevé sur votre code au fil du temps. Au lieu de déclarations comme "Pourquoi est-ce Usersi lent?" vous finissez par traquer les requêtes ponctuelles et les corrections de bogues ont donc tendance à être petites et localisées.
  3. Il est vraiment difficile de remplacer la technologie sous-jacente. Si vous stockez tout dans MySQL maintenant et que vous souhaitez passer à MongoDB, il est beaucoup plus difficile de remplacer 100 appels ad hoc que c'est une poignée d'entités.

Q: J'aurai trop de méthodes dans mon référentiel.

R: Je n'ai pas vraiment vu d'autre moyen que de consolider les appels. Les appels de méthode dans votre référentiel correspondent vraiment aux fonctionnalités de votre application. Plus il y a de fonctionnalités, plus les appels sont spécifiques aux données. Vous pouvez repousser les fonctionnalités et essayer de fusionner des appels similaires en un seul.

La complexité à la fin de la journée doit exister quelque part. Avec un modèle de référentiel, nous l'avons poussé dans l'interface du référentiel au lieu de peut-être créer un tas de procédures stockées.

Parfois, je dois me dire: "Eh bien, il fallait donner quelque part! Il n'y a pas de balles d'argent."

ryan1234
la source
Merci pour la réponse très complète. Tu me fais réfléchir maintenant. Ma grande préoccupation ici est que tout ce que je lis ne le dit pas SELECT *, sélectionnez plutôt les champs dont vous avez besoin. Par exemple, consultez cette question . Quant à toutes ces requêtes ad-hock dont vous parlez, je comprends certainement d'où vous venez. J'ai une très grande application en ce moment qui en a beaucoup. C'était mon "Eh bien, il fallait donner quelque part!" moment, j'ai opté pour des performances maximales. Cependant, maintenant je traite de BEAUCOUP de requêtes différentes.
Jonathan
1
Une pensée de suivi. J'ai vu une recommandation d'utiliser une approche R — CUD. Étant donné que readsc'est souvent là que des problèmes de performances se posent, vous pouvez utiliser une approche de requête plus personnalisée pour eux, qui ne se traduit pas en objets métier réels. Ensuite, pour create, updateet delete, utiliser un ORM, qui fonctionne avec des objets entiers. Des réflexions sur cette approche?
Jonathan
1
En guise de note pour l'utilisation de "sélectionnez *". Je l'ai fait dans le passé et cela a bien fonctionné - jusqu'à ce que nous atteignions les champs varchar (max). Ceux-ci ont tué nos requêtes. Donc, si vous avez des tableaux avec des entiers, de petits champs de texte, etc. ce n'est pas si mal. Cela ne semble pas naturel, mais le logiciel va dans ce sens. Ce qui était mauvais est soudainement bon et vice versa.
ryan1234
1
L'approche R-CUD est en fait CQRS
MikeSW
2
@ ryan1234 "La complexité à la fin de la journée doit exister quelque part." Merci pour ça. Me fait me sentir mieux.
johnny
20

J'utilise les interfaces suivantes:

  • Repository - charge, insère, met à jour et supprime des entités
  • Selector - recherche des entités basées sur des filtres, dans un référentiel
  • Filter - encapsule la logique de filtrage

Mon Repositoryest indépendant de la base de données; en fait, il ne spécifie aucune persistance; cela peut être n'importe quoi: base de données SQL, fichier xml, service distant, un étranger de l'espace, etc. Pour les capacités de recherche, les Repositoryconstructions Selectorpeuvent être filtrées LIMIT, triées, triées et comptées. Au final, le sélecteur récupère un ou plusieurs Entitiesde la persistance.

Voici un exemple de code:

<?php
interface Repository
{
    public function addEntity(Entity $entity);

    public function updateEntity(Entity $entity);

    public function removeEntity(Entity $entity);

    /**
     * @return Entity
     */
    public function loadEntity($entityId);

    public function factoryEntitySelector():Selector
}


interface Selector extends \Countable
{
    public function count();

    /**
     * @return Entity[]
     */
    public function fetchEntities();

    /**
     * @return Entity
     */
    public function fetchEntity();
    public function limit(...$limit);
    public function filter(Filter $filter);
    public function orderBy($column, $ascending = true);
    public function removeFilter($filterName);
}

interface Filter
{
    public function getFilterName();
}

Ensuite, une implémentation:

class SqlEntityRepository
{
    ...
    public function factoryEntitySelector()
    {
        return new SqlSelector($this);
    }
    ...
}

class SqlSelector implements Selector
{
    ...
    private function adaptFilter(Filter $filter):SqlQueryFilter
    {
         return (new SqlSelectorFilterAdapter())->adaptFilter($filter);
    }
    ...
}
class SqlSelectorFilterAdapter
{
    public function adaptFilter(Filter $filter):SqlQueryFilter
    {
        $concreteClass = (new StringRebaser(
            'Filter\\', 'SqlQueryFilter\\'))
            ->rebase(get_class($filter));

        return new $concreteClass($filter);
    }
}

L'idée est que le générique Selectorutilise Filtermais que l'implémentation SqlSelectorutilise SqlFilter; l' SqlSelectorFilterAdapteradapte un générique Filterà un béton SqlFilter.

Le code client crée des Filterobjets (qui sont des filtres génériques) mais dans l'implémentation concrète du sélecteur ces filtres sont transformés en filtres SQL.

D'autres implémentations de sélecteurs, comme InMemorySelector, se transforment de Filteren InMemoryFilterutilisant leur spécifique InMemorySelectorFilterAdapter; ainsi, chaque implémentation de sélecteur est livrée avec son propre adaptateur de filtre.

En utilisant cette stratégie, mon code client (dans la couche bussines) ne se soucie pas d'une implémentation de référentiel ou de sélecteur spécifique.

/** @var Repository $repository*/
$selector = $repository->factoryEntitySelector();
$selector->filter(new AttributeEquals('activated', 1))->limit(2)->orderBy('username');
$activatedUserCount = $selector->count(); // evaluates to 100, ignores the limit()
$activatedUsers = $selector->fetchEntities();

PS Ceci est une simplification de mon vrai code

Constantin Galbenu
la source
"Référentiel - charge, insère, met à jour et supprime des entités" c'est ce qu'une "couche de service", "DAO", "BLL" peut faire
Yousha Aleayoub
5

Je vais ajouter un peu à ce sujet car j'essaie actuellement de saisir tout cela moi-même.

# 1 et 2

C'est un endroit parfait pour que votre ORM fasse le gros du travail. Si vous utilisez un modèle qui implémente une sorte d'ORM, vous pouvez simplement utiliser ses méthodes pour prendre soin de ces choses. Créez vos propres fonctions orderBy qui implémentent les méthodes Eloquent si vous en avez besoin. Utiliser Eloquent par exemple:

class DbUserRepository implements UserRepositoryInterface
{
    public function findAll()
    {
        return User::all();
    }

    public function get(Array $columns)
    {
       return User::select($columns);
    }

Ce que vous semblez rechercher, c'est un ORM. Aucune raison pour laquelle votre référentiel ne peut pas être basé sur un seul. Cela nécessiterait que l'utilisateur soit éloquent, mais personnellement, je ne considère pas cela comme un problème.

Si vous voulez cependant éviter un ORM, vous devrez alors "lancer le vôtre" pour obtenir ce que vous cherchez.

# 3

Les interfaces ne sont pas censées être des exigences strictes et rapides. Quelque chose peut implémenter une interface et y ajouter. Ce qu'il ne peut pas faire, c'est de ne pas implémenter une fonction requise de cette interface. Vous pouvez également étendre des interfaces comme des classes pour garder les choses au SEC.

Cela dit, je commence tout juste à comprendre, mais ces réalisations m'ont aidé.

Volonté
la source
1
Ce que je n'aime pas dans cette méthode, c'est que si vous aviez un MongoUserRepository, celui-ci et votre DbUserRepository retourneraient des objets différents. Db renvoyant un Eloquent \ Model et Mongo quelque chose de propre. Une meilleure implémentation consiste sûrement à ce que les deux référentiels retournent des instances / collections d'une classe Entity \ User distincte. De cette façon, vous ne comptez pas à tort sur les méthodes DB d'Eloquent \ Model lorsque vous passez à l'utilisation de MongoRepository
danharper
1
Je serais certainement d'accord avec vous là-dessus. Ce que je ferais probablement pour éviter cela, c'est de ne jamais utiliser ces méthodes en dehors de la classe nécessitant Eloquent. Ainsi, la fonction get devrait probablement être privée et uniquement utilisée dans la classe car elle, comme vous l'avez souligné, retournerait quelque chose que les autres référentiels ne pourraient pas.
Will
3

Je ne peux que commenter la façon dont nous (dans mon entreprise) traitons cela. Tout d'abord, les performances ne sont pas trop un problème pour nous, mais avoir un code propre / correct l'est.

Tout d'abord, nous définissons des modèles tels qu'un UserModelqui utilise un ORM pour créer des UserEntityobjets. Lorsqu'un UserEntityest chargé à partir d'un modèle, tous les champs sont chargés. Pour les champs faisant référence à des entités étrangères, nous utilisons le modèle étranger approprié pour créer les entités respectives. Pour ces entités, les données seront chargées à la demande. Maintenant, votre réaction initiale pourrait être ... ??? ... !!! permettez-moi de vous donner un exemple un peu d'un exemple:

class UserEntity extends PersistentEntity
{
    public function getOrders()
    {
        $this->getField('orders'); //OrderModel creates OrderEntities with only the ID's set
    }
}

class UserModel {
    protected $orm;

    public function findUsers(IGetOptions $options = null)
    {
        return $orm->getAllEntities(/*...*/); // Orm creates a list of UserEntities
    }
}

class OrderEntity extends PersistentEntity {} // user your imagination
class OrderModel
{
    public function findOrdersById(array $ids, IGetOptions $options = null)
    {
        //...
    }
}

Dans notre cas, il $dbs'agit d'un ORM capable de charger des entités. Le modèle demande à l'ORM de charger un ensemble d'entités d'un type spécifique. L'ORM contient un mappage et l'utilise pour injecter tous les champs de cette entité dans l'entité. Pour les champs étrangers, cependant, seuls les identifiants de ces objets sont chargés. Dans ce cas, le OrderModelcrée des OrderEntitys avec uniquement les identifiants des commandes référencées. Quand PersistentEntity::getFieldest appelé par l' OrderEntityentité, l'entité demande à son modèle de charger paresseusement tous les champs dans le OrderEntitys. Tous les OrderEntitys associés à une UserEntity sont traités comme un seul jeu de résultats et seront chargés à la fois.

La magie ici est que notre modèle et ORM injectent toutes les données dans les entités et que les entités fournissent simplement des fonctions d'encapsulation pour la getFieldméthode générique fournie par PersistentEntity. Pour résumer, nous chargeons toujours tous les champs, mais les champs référençant une entité étrangère sont chargés lorsque cela est nécessaire. Le simple chargement d'un tas de champs n'est pas vraiment un problème de performances. Charger toutes les entités étrangères possibles serait cependant une énorme diminution des performances.

Passons maintenant au chargement d'un ensemble spécifique d'utilisateurs, basé sur une clause where. Nous fournissons un package de classes orienté objet qui vous permet de spécifier une expression simple qui peut être collée ensemble. Dans l'exemple de code, je l'ai nommé GetOptions. C'est un wrapper pour toutes les options possibles pour une requête de sélection. Il contient une collection de clauses where, une clause group by et tout le reste. Nos clauses where sont assez compliquées, mais vous pourriez évidemment faire une version plus simple facilement.

$objOptions->getConditionHolder()->addConditionBind(
    new ConditionBind(
        new Condition('orderProduct.product', ICondition::OPERATOR_IS, $argObjProduct)
    )
);

Une version la plus simple de ce système serait de passer la partie WHERE de la requête sous forme de chaîne directement au modèle.

Je suis désolé pour cette réponse assez compliquée. J'ai essayé de résumer notre cadre aussi rapidement et clairement que possible. Si vous avez des questions supplémentaires, n'hésitez pas à les poser et je mettrai à jour ma réponse.

EDIT: De plus, si vous ne voulez vraiment pas charger certains champs immédiatement, vous pouvez spécifier une option de chargement différé dans votre mappage ORM. Parce que tous les champs sont éventuellement chargés par la getFieldméthode que vous pouvez charger des champs de dernière minute lorsque cette méthode est appelée. Ce n'est pas un très gros problème en PHP, mais je ne le recommanderais pas pour d'autres systèmes.

TFennis
la source
3

Ce sont des solutions différentes que j'ai vues. Il y a des avantages et des inconvénients à chacun d'eux, mais c'est à vous de décider.

Problème n ° 1: trop de champs

Il s'agit d'un aspect important, en particulier lorsque vous prenez en compte les analyses d'index uniquement . Je vois deux solutions pour résoudre ce problème. Vous pouvez mettre à jour vos fonctions pour accepter un paramètre de tableau facultatif qui contiendrait une liste de colonnes à renvoyer. Si ce paramètre est vide, vous renvoyez toutes les colonnes de la requête. Cela peut être un peu bizarre; sur la base du paramètre, vous pouvez récupérer un objet ou un tableau. Vous pouvez également dupliquer toutes vos fonctions afin d'avoir deux fonctions distinctes qui exécutent la même requête, mais l'une renvoie un tableau de colonnes et l'autre renvoie un objet.

public function findColumnsById($id, array $columns = array()){
    if (empty($columns)) {
        // use *
    }
}

public function findById($id) {
    $data = $this->findColumnsById($id);
}

Problème n ° 2: trop de méthodes

J'ai brièvement travaillé avec Propel ORM il y a un an et cela est basé sur ce dont je me souviens de cette expérience. Propel a la possibilité de générer sa structure de classe à partir du schéma de base de données existant. Il crée deux objets pour chaque table. Le premier objet est une longue liste de fonctions d'accès similaires à ce que vous avez actuellement répertorié; findByAttribute($attribute_value). L'objet suivant hérite de ce premier objet. Vous pouvez mettre à jour cet objet enfant pour intégrer vos fonctions getter plus complexes.

Une autre solution consisterait __call()à mapper des fonctions non définies à quelque chose d'actionnable. Votre __callméthode serait serait en mesure d'analyser les findById et findByName dans différentes requêtes.

public function __call($function, $arguments) {
    if (strpos($function, 'findBy') === 0) {
        $parameter = substr($function, 6, strlen($function));
        // SELECT * FROM $this->table_name WHERE $parameter = $arguments[0]
    }
}

J'espère que cela aide au moins un peu quoi.

Logan Bailey
la source
0

Je suis d'accord avec @ ryan1234 que vous devez faire circuler des objets complets dans le code et utiliser des méthodes de requête génériques pour obtenir ces objets.

Model::where(['attr1' => 'val1'])->get();

Pour une utilisation externe / d'extrémité, j'aime vraiment la méthode GraphQL.

POST /api/graphql
{
    query: {
        Model(attr1: 'val1') {
            attr2
            attr3
        }
    }
}
AVProgrammer
la source
0

Problème n ° 3: impossible de faire correspondre une interface

Je vois l'avantage d'utiliser des interfaces pour les référentiels, donc je peux échanger mon implémentation (à des fins de test ou autre). Ma compréhension des interfaces est qu'elles définissent un contrat qu'une implémentation doit suivre. C'est formidable jusqu'à ce que vous commenciez à ajouter des méthodes supplémentaires à vos référentiels comme findAllInCountry (). Maintenant, je dois mettre à jour mon interface pour avoir également cette méthode, sinon, d'autres implémentations peuvent ne pas l'avoir, et cela pourrait casser mon application. Par cela se sent fou ... un cas de la queue qui remue le chien.

Mon instinct me dit que cela nécessite peut-être une interface qui implémente des méthodes optimisées pour les requêtes aux côtés de méthodes génériques. Les requêtes sensibles aux performances doivent avoir des méthodes ciblées, tandis que les requêtes peu fréquentes ou légères sont traitées par un gestionnaire générique, peut-être aux dépens du contrôleur qui jongle un peu plus.

Les méthodes génériques permettraient à toute requête d'être implémentée et empêcheraient ainsi de casser les changements pendant une période de transition. Les méthodes ciblées vous permettent d'optimiser un appel lorsque cela a du sens et il peut être appliqué à plusieurs fournisseurs de services.

Cette approche s'apparenterait à des implémentations matérielles exécutant des tâches optimisées spécifiques, tandis que les implémentations logicielles font le travail léger ou l'implémentation flexible.

Brian
la source
0

Je pense que graphQL est un bon candidat dans un tel cas pour fournir un langage de requête à grande échelle sans augmenter la complexité des référentiels de données.

Cependant, il existe une autre solution si vous ne voulez pas opter pour le graphQL pour l'instant. En utilisant un DTO où un objet est utilisé pour transporter les données entre les processus, dans ce cas entre le service / contrôleur et le référentiel.

Une réponse élégante est déjà fournie ci-dessus, mais je vais essayer de donner un autre exemple que je pense qu'il est plus simple et pourrait servir de point de départ pour un nouveau projet.

Comme indiqué dans le code, nous n'aurions besoin que de 4 méthodes pour les opérations CRUD. la findméthode serait utilisée pour lister et lire en passant l'argument objet. Les services principaux peuvent créer l'objet de requête défini en fonction d'une chaîne de requête URL ou en fonction de paramètres spécifiques.

L'objet de requête ( SomeQueryDto) pourrait également implémenter une interface spécifique si nécessaire. et est facile à étendre plus tard sans ajouter de complexité.

<?php

interface SomeRepositoryInterface
{
    public function create(SomeEnitityInterface $entityData): SomeEnitityInterface;
    public function update(SomeEnitityInterface $entityData): SomeEnitityInterface;
    public function delete(int $id): void;

    public function find(SomeEnitityQueryInterface $query): array;
}

class SomeRepository implements SomeRepositoryInterface
{
    public function find(SomeQueryDto $query): array
    {
        $qb = $this->getQueryBuilder();

        foreach ($query->getSearchParameters() as $attribute) {
            $qb->where($attribute['field'], $attribute['operator'], $attribute['value']);
        }

        return $qb->get();
    }
}

/**
 * Provide query data to search for tickets.
 *
 * @method SomeQueryDto userId(int $id, string $operator = null)
 * @method SomeQueryDto categoryId(int $id, string $operator = null)
 * @method SomeQueryDto completedAt(string $date, string $operator = null)
 */
class SomeQueryDto
{
    /** @var array  */
    const QUERYABLE_FIELDS = [
        'id',
        'subject',
        'user_id',
        'category_id',
        'created_at',
    ];

    /** @var array  */
    const STRING_DB_OPERATORS = [
        'eq' => '=', // Equal to
        'gt' => '>', // Greater than
        'lt' => '<', // Less than
        'gte' => '>=', // Greater than or equal to
        'lte' => '<=', // Less than or equal to
        'ne' => '<>', // Not equal to
        'like' => 'like', // Search similar text
        'in' => 'in', // one of range of values
    ];

    /**
     * @var array
     */
    private $searchParameters = [];

    const DEFAULT_OPERATOR = 'eq';

    /**
     * Build this query object out of query string.
     * ex: id=gt:10&id=lte:20&category_id=in:1,2,3
     */
    public static function buildFromString(string $queryString): SomeQueryDto
    {
        $query = new self();
        parse_str($queryString, $queryFields);

        foreach ($queryFields as $field => $operatorAndValue) {
            [$operator, $value] = explode(':', $operatorAndValue);
            $query->addParameter($field, $operator, $value);
        }

        return $query;
    }

    public function addParameter(string $field, string $operator, $value): SomeQueryDto
    {
        if (!in_array($field, self::QUERYABLE_FIELDS)) {
            throw new \Exception("$field is invalid query field.");
        }
        if (!array_key_exists($operator, self::STRING_DB_OPERATORS)) {
            throw new \Exception("$operator is invalid query operator.");
        }
        if (!is_scalar($value)) {
            throw new \Exception("$value is invalid query value.");
        }

        array_push(
            $this->searchParameters,
            [
                'field' => $field,
                'operator' => self::STRING_DB_OPERATORS[$operator],
                'value' => $value
            ]
        );

        return $this;
    }

    public function __call($name, $arguments)
    {
        // camelCase to snake_case
        $field = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $name));

        if (in_array($field, self::QUERYABLE_FIELDS)) {
            return $this->addParameter($field, $arguments[1] ?? self::DEFAULT_OPERATOR, $arguments[0]);
        }
    }

    public function getSearchParameters()
    {
        return $this->searchParameters;
    }
}

Exemple d'utilisation:

$query = new SomeEnitityQuery();
$query->userId(1)->categoryId(2, 'ne')->createdAt('2020-03-03', 'lte');
$entities = $someRepository->find($query);

// Or by passing the HTTP query string
$query = SomeEnitityQuery::buildFromString('created_at=gte:2020-01-01&category_id=in:1,2,3');
$entities = $someRepository->find($query);
kordy
la source