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 Repositories
pour 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 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:
- 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
password
champ 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 SQL
dans 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.