Ho pensato di fare una pausa nel rispondere alla mia domanda. Quello che segue è solo un modo per risolvere i problemi 1-3 nella mia domanda originale.
Disclaimer: potrei non usare sempre i termini giusti per descrivere schemi o tecniche. Scusa per quello.
Gli obiettivi:
- Crea un esempio completo di controller di base per la visualizzazione e la modifica
Users
.
- Tutto il codice deve essere completamente testabile e beffardo.
- Il controller non dovrebbe avere idea di dove siano archiviati i dati (nel senso che possono essere modificati).
- Esempio per mostrare un'implementazione SQL (più comune).
- Per ottenere le massime prestazioni, i controller dovrebbero ricevere solo i dati di cui hanno bisogno, senza campi aggiuntivi.
- L'implementazione dovrebbe sfruttare un qualche tipo di mappatore di dati per facilitare lo sviluppo.
- L'implementazione dovrebbe avere la capacità di eseguire ricerche di dati complessi.
La soluzione
Sto dividendo la mia interazione persistente di archiviazione (database) in due categorie: R (Leggi) e CUD (Crea, Aggiorna, Elimina). La mia esperienza è stata che le letture sono realmente ciò che provoca il rallentamento di un'applicazione. E mentre la manipolazione dei dati (CUD) è in realtà più lenta, accade molto meno frequentemente ed è quindi molto meno preoccupante.
CUD (Crea, Aggiorna, Elimina) è facile. Ciò comporterà il lavoro con modelli reali , che vengono quindi passati al mioRepositories
per persistenza. Nota, i miei repository forniranno comunque un metodo di lettura, ma semplicemente per la creazione di oggetti, non per la visualizzazione. Ne parleremo più avanti.
R (Leggi) non è così facile. Nessun modello qui, solo oggetti di valore . Usa le matrici se preferisci . Questi oggetti possono rappresentare un singolo modello o una miscela di molti modelli, qualsiasi cosa in realtà. Questi non sono molto interessanti da soli, ma come sono generati. Sto usando quello che sto chiamando Query Objects
.
Il codice:
Modello utente
Cominciamo semplice con il nostro modello utente di base. Si noti che non esiste alcuna estensione ORM o elementi del database. Solo pura gloria di modello. Aggiungi i tuoi getter, setter, validazione, qualunque cosa.
class User
{
public $id;
public $first_name;
public $last_name;
public $gender;
public $email;
public $password;
}
Interfaccia del repository
Prima di creare il repository utente, desidero creare l'interfaccia del repository. Ciò definirà il "contratto" che i repository devono seguire per poter essere utilizzato dal mio controller. Ricorda, il mio controller non saprà dove sono effettivamente memorizzati i dati.
Nota che i miei repository conterranno solo questi tre metodi. Il save()
metodo è responsabile sia della creazione che dell'aggiornamento degli utenti, semplicemente a seconda che l'oggetto utente abbia o meno un ID impostato.
interface UserRepositoryInterface
{
public function find($id);
public function save(User $user);
public function remove(User $user);
}
Implementazione del repository SQL
Ora per creare la mia implementazione dell'interfaccia. Come accennato, il mio esempio sarebbe stato con un database SQL. Si noti l'uso di un mappatore di dati per evitare di dover scrivere query SQL ripetitive.
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');
}
}
Interfaccia oggetto query
Ora con CUD (Crea, Aggiorna, Elimina) curato dal nostro repository, possiamo concentrarci sulla R (Leggi). Gli oggetti query sono semplicemente un incapsulamento di un tipo di logica di ricerca dei dati. Sono Non generatori di query. Astrattandolo come il nostro repository, possiamo cambiarne l'implementazione e testarlo più facilmente. Un esempio di un oggetto query potrebbe essere un AllUsersQuery
o AllActiveUsersQuery
, o addirittura MostCommonUserFirstNames
.
Potresti pensare "non posso semplicemente creare metodi nei miei repository per quelle query?" Sì, ma ecco perché non lo sto facendo:
- I miei repository sono pensati per lavorare con oggetti modello. In un'app del mondo reale, perché dovrei mai avere il
password
campo se sto cercando di elencare tutti i miei utenti?
- I repository sono spesso specifici del modello, ma le query spesso coinvolgono più di un modello. Quindi in quale repository metti il tuo metodo?
- Ciò rende i miei repository molto semplici, non una classe di metodi gonfiati.
- Tutte le query sono ora organizzate in proprie classi.
- Davvero, a questo punto, esistono dei repository semplicemente per astrarre il mio livello di database.
Per il mio esempio creerò un oggetto query per cercare "AllUsers". Ecco l'interfaccia:
interface AllUsersQueryInterface
{
public function fetch($fields);
}
Implementazione dell'oggetto query
Qui è possibile utilizzare nuovamente un mapper di dati per accelerare lo sviluppo. Si noti che sto consentendo una modifica al set di dati restituito: i campi. Questo è quanto voglio manipolare la query eseguita. Ricorda, i miei oggetti query non sono generatori di query. Eseguono semplicemente una query specifica. Tuttavia, poiché so che probabilmente lo userò molto, in diverse situazioni, mi sto dando la possibilità di specificare i campi. Non voglio mai restituire campi che non mi servono!
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();
}
}
Prima di passare al controller, voglio mostrare un altro esempio per illustrare quanto sia potente. Forse ho un motore di segnalazione e devo creare un rapporto per AllOverdueAccounts
. Questo potrebbe essere complicato con il mio mappatore di dati e potrei voler scrivere un po 'reale SQL
in questa situazione. Nessun problema, ecco come potrebbe apparire questo oggetto query:
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...";
}
}
Questo mantiene bene tutta la mia logica per questo rapporto in una classe ed è facile da testare. Posso deriderlo sul contenuto del mio cuore, o persino utilizzare una diversa implementazione del tutto.
Il controller
Ora la parte divertente: riunire tutti i pezzi. Si noti che sto usando l'iniezione di dipendenza. In genere le dipendenze vengono iniettate nel costruttore, ma in realtà preferisco iniettarle direttamente nei metodi del mio controller (route). Ciò riduce al minimo il grafico degli oggetti del controller e in realtà lo trovo più leggibile. Nota, se non ti piace questo approccio, usa semplicemente il metodo di costruzione tradizionale.
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;
}
}
Pensieri finali:
Le cose importanti da notare qui sono che quando sto modificando (creando, aggiornando o eliminando) entità, sto lavorando con oggetti modello reali ed eseguendo la persistenza attraverso i miei repository.
Tuttavia, quando sto visualizzando (selezionando i dati e inviandoli alle viste) non sto lavorando con oggetti modello, ma piuttosto oggetti di valore vecchio. Seleziono solo i campi di cui ho bisogno ed è progettato in modo da poter massimizzare le prestazioni di ricerca dei miei dati.
I miei repository rimangono molto puliti, e invece questo "pasticcio" è organizzato nelle mie query modello.
Uso un mapper di dati per aiutare con lo sviluppo, in quanto è ridicolo scrivere SQL ripetitivi per attività comuni. Tuttavia, puoi assolutamente scrivere SQL dove necessario (query complicate, rapporti, ecc.). E quando lo fai, è ben nascosto in una classe ben chiamata.
Mi piacerebbe sentire la tua opinione sul mio approccio!
Aggiornamento luglio 2015:
Mi è stato chiesto nei commenti dove ho finito con tutto questo. Beh, in realtà non molto lontano. Sinceramente, non mi piacciono ancora i repository. Li trovo esagerati per le ricerche di base (specialmente se stai già utilizzando un ORM) e disordinati quando si lavora con query più complicate.
In genere lavoro con un ORM in stile ActiveRecord, quindi molto spesso farò semplicemente riferimento a quei modelli direttamente nella mia applicazione. Tuttavia, nelle situazioni in cui ho query più complesse, userò gli oggetti query per renderli più riutilizzabili. Dovrei anche notare che ho sempre iniettato i miei modelli nei miei metodi, rendendoli più facili da deridere nei miei test.