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 le TDD en PHP, et je me rends compte que ma base de données est trop couplée au reste de mon application. J'ai lu des informations 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 maintenant, avez quelques questions pratiques sur la conception du référentiel. Considérez 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)
{
}
}
Toutes ces méthodes de recherche utilisent une approche de sélection de tous les champs (SELECT *
). Cependant, dans mes applications, j'essaie toujours de limiter le nombre de champs que je reçois, 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?
Bien que cette classe ait l'air bien en ce moment, je sais que dans une application du monde réel, j'ai besoin de beaucoup plus de méthodes. Par exemple:
Comme vous pouvez le constater, 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))
}
}
Je vois l’avantage d’utiliser des interfaces pour les référentiels, ce qui me permet d’échanger mon implémentation (à des fins de test ou autres). D'après ce que je comprends des interfaces, elles définissent un contrat qu'une implémentation doit suivre. C'est très bien 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 que cette méthode soit également utilisée. Sinon, d'autres implémentations pourraient ne pas en disposer et cela pourrait endommager mon application. Par ce sentiment fou ... un cas de la queue qui remue le chien.
Cela me porte à penser que le référentiel ne devrait avoir qu'un nombre fixe de méthodes (comme save()
, remove()
, find()
, findAll()
, etc.). Mais alors, comment puis-je lancer des recherches spécifiques? J'ai entendu parler de Specification Pattern , mais il me semble que cela ne fait que réduire un ensemble complet d'enregistrements (via IsSatisfiedBy()
), ce qui pose clairement des problèmes de performances majeurs si vous extrayez d'une base de données.
Il est clair que je dois repenser un peu les choses lorsque je travaille avec des référentiels. Quelqu'un peut-il éclairer sur la meilleure façon de gérer cela?
Je pensais bien répondre à ma propre question. Ce qui suit n’est qu’un des moyens de résoudre les problèmes 1 à 3 de ma question initiale.
Disclaimer: il est possible que je n'utilise pas toujours les bons termes pour décrire des modèles ou des techniques. Désolé pour cela.} _
Users
.Je divise mon interaction de stockage persistant (base de données) en deux catégories: R (Lire) et CUD (Créer, Mettre à jour, Supprimer). Mon expérience a été que les lectures sont vraiment ce qui cause le ralentissement d’une application. Et bien que la manipulation de données (CUD) soit en réalité plus lente, elle se produit beaucoup moins souvent et est donc beaucoup moins préoccupante.
CUD (Créer, Mettre à jour, Supprimer) est facile. Cela impliquera de travailler avec les modèles réels, qui sont ensuite transmis à ma Repositories
pour persistance. Remarque, mes référentiels fourniront toujours une méthode de lecture, mais simplement pour la création d'objet, pas pour l'affichage. Plus sur cela plus tard.
R (Lire) n'est pas si facile. Pas de modèle ici, juste objets de valeur . Utilisez des tableaux si vous préférez . Ces objets peuvent représenter un modèle unique ou un mélange de plusieurs modèles, de tout. Ce ne sont pas très intéressants en eux-mêmes, mais comment ils sont générés est. J'utilise ce que j'appelle Query Objects
.
Commençons simplement avec notre modèle d'utilisateur de base. Notez qu’il n’existe aucune extension ORM ou base de données. Juste pur modèle gloire. Ajoutez vos getters, setters, validation, peu importe.
class User
{
public $id;
public $first_name;
public $last_name;
public $gender;
public $email;
public $password;
}
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 pouvoir ê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 que ces trois méthodes. La méthode save()
est responsable à la fois de la création et de la mise à jour des utilisateurs, en fonction du fait que l'objet utilisateur ait ou non un identifiant défini.
interface UserRepositoryInterface
{
public function find($id);
public function save(User $user);
public function remove(User $user);
}
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 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');
}
}
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 sont des générateurs de requêtes not. En le résumant comme notre référentiel, nous pouvons changer son implémentation et le tester plus facilement. Un exemple d'objet de requête peut être un AllUsersQuery
ou 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:
password
si je souhaite répertorier tous mes utilisateurs?interface AllUsersQueryInterface
{
public function fetch($fields);
}
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();
}
}
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...";
}
}
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;
}
}
Cependant, lorsque je suis en train d'afficher (sélectionner des données et de les envoyer aux vues), je ne travaille pas avec des objets de modèle, mais plutôt avec d'anciens objets de valeur. Je ne sélectionne que les champs dont j'ai besoin et il est conçu pour optimiser les performances de recherche de données.
Mes référentiels restent très propres et, au lieu de cela, ce "désordre" est organisé dans les requêtes de modèle.
J'utilise un mappeur de données pour aider au développement, car il est ridicule d'écrire du SQL répétitif pour des tâches courantes. Cependant, vous pouvez absolument écrire du SQL si nécessaire (requêtes compliquées, rapports, etc.). Et lorsque vous le faites, il est bien rangé dans une classe correctement nommée.
J'aimerais entendre votre point de vue sur mon approche!.
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 excessives pour les recherches de base (surtout si vous utilisez déjà un ORM) et désordonnées lorsque vous travaillez avec des requêtes plus complexes.
Je travaille généralement avec un ORM de type ActiveRecord. Par conséquent, le plus souvent, je référence simplement ces modèles directement dans l'application. Cependant, dans les cas 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 lors de mes tests.
I generally work with an ActiveRecord style ORM, so most often I'll just reference those models directly throughout my application. However, in situations where I have more complex queries, I'll use query objects to make these more reusable. I should also note that I always inject my models into my methods, making them easier to mock in my tests.
Sur la base de 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 revient vraiment à traiter avec des entités complètes plutôt qu'à des requêtes ad-hoc.
Une entité complète est quelque chose comme un objet User
. Il a des propriétés et des méthodes, etc. C'est un citoyen de première classe dans votre code.
Une requête ad-hoc renvoie des données, mais nous ne savons rien au-delà de cela. Lorsque les données sont transmises à l’application, elles le sont sans contexte. Est-ce une User
? Un User
avec quelques informations Order
attachées? Nous ne savons pas vraiment.
Je préfère travailler avec des entités complètes.
Vous avez raison de dire que vous ramènerez souvent des données que vous n'utiliserez pas, mais vous pouvez y remédier de différentes manières:
User
pour le back-end et peut-être une UserSmall
pour 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:
User
, vous finirez par écrire essentiellement le même select *
pour de nombreux appels. Un appel comportera 8 champs sur 10, un autre 5 sur 10 et un autre 7 sur 10. Pourquoi ne pas tout remplacer par un seul appel reçu 10 sur 10? La raison en est que c’est un meurtre qui consiste à repenser/tester/simuler.User
est-elle si lente?" vous finissez par traquer des requêtes ponctuelles et les corrections de bugs ont donc tendance à être petites et localisées. Q: J'aurai trop de méthodes dans mon référentiel.
R: Je n'ai pas vraiment trouvé d'autre moyen de contourner ce problème que de consolider les appels. Les appels de méthode dans votre référentiel mappent vraiment les fonctionnalités de votre application. Plus vous avez de fonctionnalités, plus vous appelez de données spécifiques. Vous pouvez repousser des fonctions 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 inséré dans l'interface de référentiel au lieu de créer un ensemble 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."
J'utilise les interfaces suivantes:
Repository
- charge, insère, met à jour et supprime des entitésSelector
- recherche des entités sur la base de filtres, dans un référentielFilter
- encapsule la logique de filtrageMon Repository
est agnostique à la base de données; en fait, cela ne spécifie aucune persistance; il peut s'agir de n'importe quoi: base de données SQL, fichier XML, service distant, un extraterrestre de l'espace extérieur, etc. .. Pour les capacités de recherche, Repository
construit un Selector
pouvant être filtré, LIMIT
-, trié et compté. En fin de compte, le sélecteur extrait un ou plusieurs Entities
de 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 Selector
utilise Filter
mais que l'implémentation SqlSelector
utilise SqlFilter
; SqlSelectorFilterAdapter
adapte un Filter
générique à un SqlFilter
concret.
Le code client crée des objets Filter
(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électeur, telles que InMemorySelector
, passent de Filter
à InMemoryFilter
en utilisant leur propre InMemorySelectorFilterAdapter
; Ainsi, chaque implémentation de sélecteur est fournie avec son propre adaptateur de filtre.
En utilisant cette stratégie, mon code client (dans la couche bussines) ne s’intéresse pas à un référentiel spécifique ni à l’implémentation du sélecteur.
/** @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();
P.S. Ceci est une simplification de mon vrai code
J'ajouterai quelques mots à ce sujet, car j'essaie actuellement de saisir tout cela moi-même.
C’est un endroit idéal pour que votre ORM puisse faire 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 en charge ces éléments. Créez vos propres fonctions orderBy qui implémentent les méthodes Eloquent si vous en avez besoin. En utilisant 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 est un ORM. Aucune raison pour que votre référentiel ne puisse pas être basé sur un. Cela nécessiterait une extension de l'utilisateur éloquent, mais personnellement, je ne vois pas cela comme un problème.
Si vous voulez toutefois éviter un ORM, vous devrez alors "rouler vous-même" pour obtenir ce que vous recherchez.
Les interfaces ne sont pas supposées être des exigences strictes et rapides. Quelque chose peut implémenter une interface et y ajouter. Ce qu’elle ne peut pas faire, c’est échouer à mettre en œuvre une fonction requise de cette interface. Vous pouvez également étendre des interfaces, telles que des classes, pour garder les choses sèches.
Cela dit, je commence tout juste à comprendre, mais ces réalisations m'ont aidé.
Je ne peux que parler de la façon dont nous (dans mon entreprise) traitons ce problème. Tout d’abord, la performance n’est pas un problème pour nous, mais il est essentiel d’avoir un code propre/propre.
Tout d’abord, nous définissons des modèles tels que UserModel
qui utilise un ORM pour créer des objets UserEntity
. Lorsqu'un UserEntity
est chargé à partir d'un modèle, tous les champs sont chargés. Pour les champs référençant 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… ???… !!! laissez-moi vous donner un exemple un peu d'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, $db
est 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, seuls les identifiants de ces objets sont chargés. Dans ce cas, la OrderModel
crée OrderEntity
s avec uniquement les identifiants des commandes référencées. Lorsque PersistentEntity::getField
est appelé par la variable OrderEntity
, l'entité demande à son modèle de charger tous les champs dans la variable OrderEntity
s. Tous les OrderEntity
s associés à une UserEntity sont traités comme un ensemble de résultats et seront chargés en même temps.
La magie est que notre modèle et ORM injectent toutes les données dans les entités et que celles-ci fournissent simplement des fonctions d'encapsulation pour la méthode générique getField
fournie par PersistentEntity
. Pour résumer, nous chargeons toujours tous les champs, mais les champs faisant référence à une entité étrangère sont chargés si nécessaire. Le chargement de plusieurs champs n'est pas vraiment un problème de performances. Charger toutes les entités étrangères possibles constituerait toutefois une perte de performance énorme.
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 pouvant être collée. Dans l'exemple de code, je l'ai nommé GetOptions
. C'est un wrapper pour toutes les options possibles pour une requête select. 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 en 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 transmettre 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 le plus rapidement et le plus clairement 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. Comme tous les champs sont finalement chargés via la méthode getField
, vous pouvez charger certains champs à la 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.
Ce sont quelques solutions différentes que j'ai vues. Il y a des avantages et des inconvénients pour chacun d’entre eux, mais c’est à vous de décider.
Ceci est un aspect important, en particulier lorsque vous prenez en compte Analyses indexées . Je vois deux solutions pour résoudre ce problème. Vous pouvez mettre à jour vos fonctions pour intégrer un paramètre de tableau facultatif contenant la liste des colonnes à renvoyer. Si ce paramètre est vide, vous renverriez toutes les colonnes de la requête. Cela peut être un peu bizarre. basé sur le paramètre, vous pouvez récupérer un objet ou un tableau. Vous pouvez également dupliquer toutes vos fonctions afin de disposer de deux fonctions distinctes qui exécutent la même requête, mais l'une renvoie un tableau de colonnes et l'autre, un objet.
public function findColumnsById($id, array $columns = array()){
if (empty($columns)) {
// use *
}
}
public function findById($id) {
$data = $this->findColumnsById($id);
}
J'ai brièvement travaillé avec Propel ORM il y a un an et ceci est basé sur ce que je peux me souvenir 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 similaire à celle que vous avez listée; 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 à utiliser __call()
pour mapper des fonctions non définies à une action. Votre méthode __call
serait en mesure d'analyser les propriétés 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 certains quoi.
Je suggère https://packagist.org/packages/prettus/l5-repository en tant que fournisseur pour implémenter des référentiels/critères, etc. ... dans Laravel5: D
Je suis d'accord avec @ ryan1234 sur le fait que vous devez transmettre 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 l’utilisation externe/endpoint, j’aime beaucoup la méthode GraphQL.
POST /api/graphql
{
query: {
Model(attr1: 'val1') {
attr2
attr3
}
}
}