Richtiges Repository-Pattern-Design in PHP?


291

Vorwort: Ich versuche, das Repository-Muster in einer MVC-Architektur mit relationalen Datenbanken zu verwenden.

Ich habe vor kurzem angefangen, TDD in PHP zu lernen, und mir ist klar, dass meine Datenbank viel zu eng mit dem Rest meiner Anwendung verbunden ist. Ich habe über Repositorys gelesen und einen IoC-Container verwendet , um ihn in meine Controller zu "injizieren". Sehr cooles Zeug. Aber jetzt haben Sie einige praktische Fragen zum Repository-Design. Betrachten Sie das folgende Beispiel.

<?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)
    {
    }
}

Problem Nr. 1: Zu viele Felder

Alle diese Suchmethoden verwenden einen Select All Fields ( SELECT *) -Ansatz. In meinen Apps versuche ich jedoch immer, die Anzahl der Felder zu begrenzen, die ich erhalte, da dies häufig zusätzlichen Aufwand verursacht und die Geschwindigkeit verlangsamt. Wie gehen Sie mit denen um, die dieses Muster verwenden?

Problem Nr. 2: Zu viele Methoden

Während diese Klasse im Moment gut aussieht, weiß ich, dass ich in einer realen App viel mehr Methoden brauche. Beispielsweise:

  • findAllByNameAndStatus
  • findAllInCountry
  • findAllWithEmailAddressSet
  • findAllByAgeAndGender
  • findAllByAgeAndGenderOrderByAge
  • Usw.

Wie Sie sehen können, könnte es eine sehr, sehr lange Liste möglicher Methoden geben. Wenn Sie dann das Feldauswahlproblem oben hinzufügen, verschlimmert sich das Problem. In der Vergangenheit habe ich normalerweise nur all diese Logik in meinen Controller eingefügt:

<?php

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

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

Mit meinem Repository-Ansatz möchte ich nicht Folgendes erreichen:

<?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))
    }

}

Problem Nr. 3: Es ist nicht möglich, eine Schnittstelle zu finden

Ich sehe den Vorteil in der Verwendung von Schnittstellen für Repositorys, damit ich meine Implementierung austauschen kann (zu Testzwecken oder zu anderen Zwecken). Mein Verständnis von Schnittstellen ist, dass sie einen Vertrag definieren, dem eine Implementierung folgen muss. Dies ist großartig, bis Sie zusätzliche Methoden zu Ihren Repositorys hinzufügen, wie zfindAllInCountry() . Jetzt muss ich meine Schnittstelle aktualisieren, um auch diese Methode zu haben. Andernfalls verfügen andere Implementierungen möglicherweise nicht über diese Methode, und dies könnte meine Anwendung beschädigen. Dadurch fühlt es sich verrückt an ... ein Fall, in dem der Schwanz mit dem Hund wedelt.

Spezifikationsmuster?

Dies führt mich zu glauben , dass Repository nur eine feste Anzahl von Methoden haben sollte (wie save(), remove(), find(), findAll(), etc.). Aber wie führe ich dann bestimmte Suchvorgänge durch? Ich habe von dem Spezifikationsmuster gehört , aber es scheint mir, dass dies nur einen ganzen Satz von Datensätzen (via IsSatisfiedBy()) reduziert , was eindeutig große Leistungsprobleme aufweist, wenn Sie aus einer Datenbank ziehen.

Hilfe?

Natürlich muss ich bei der Arbeit mit Repositories ein wenig überdenken. Kann jemand aufklären, wie dies am besten gehandhabt wird?

Antworten:


208

Ich dachte, ich würde eine Pause einlegen, um meine eigene Frage zu beantworten. Was folgt, ist nur eine Möglichkeit, die Probleme 1-3 in meiner ursprünglichen Frage zu lösen.

Haftungsausschluss: Ich verwende möglicherweise nicht immer die richtigen Begriffe, wenn ich Muster oder Techniken beschreibe. Das tut mir leid.

Die Ziele:

  • Erstellen Sie ein vollständiges Beispiel eines Basis-Controllers zum Anzeigen und Bearbeiten Users.
  • Der gesamte Code muss vollständig testbar und verspottbar sein.
  • Der Controller sollte keine Ahnung haben, wo die Daten gespeichert sind (was bedeutet, dass sie geändert werden können).
  • Beispiel zum Anzeigen einer SQL-Implementierung (am häufigsten).
  • Für maximale Leistung sollten Controller nur die Daten empfangen, die sie benötigen - keine zusätzlichen Felder.
  • Die Implementierung sollte eine Art Datenmapper nutzen, um die Entwicklung zu vereinfachen.
  • Die Implementierung sollte in der Lage sein, komplexe Datensuchen durchzuführen.

Die Lösung

Ich teile meine permanente Speicherinteraktion (Datenbankinteraktion) in zwei Kategorien auf: R (Lesen) und CUD (Erstellen, Aktualisieren, Löschen). Ich habe die Erfahrung gemacht, dass das Lesen wirklich dazu führt, dass eine Anwendung langsamer wird. Und während die Datenmanipulation (CUD) tatsächlich langsamer ist, kommt sie viel seltener vor und ist daher viel weniger besorgniserregend.

CUD (Erstellen, Aktualisieren, Löschen) ist einfach. Dies beinhaltet die Arbeit mit tatsächlichen Modellen , die dann Repositorieszur Persistenz an meine übergeben werden . Beachten Sie, dass meine Repositorys weiterhin eine Lesemethode bereitstellen, jedoch nur zur Objekterstellung und nicht zur Anzeige. Dazu später mehr.

R (Lesen) ist nicht so einfach. Keine Modelle hier, nur Wertobjekte . Verwenden Sie Arrays, wenn Sie dies bevorzugen . Diese Objekte können ein einzelnes Modell oder eine Mischung aus vielen Modellen darstellen, eigentlich alles. Diese sind für sich genommen nicht sehr interessant, aber wie sie erzeugt werden, ist. Ich benutze was ich rufe Query Objects.

Der Code:

Benutzermodell

Beginnen wir einfach mit unserem grundlegenden Benutzermodell. Beachten Sie, dass es überhaupt keine ORM-Erweiterung oder Datenbankmaterial gibt. Nur purer Model-Ruhm. Fügen Sie Ihre Getter, Setter, Validierungen, was auch immer hinzu.

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

Repository-Schnittstelle

Bevor ich mein Benutzer-Repository erstelle, möchte ich meine Repository-Schnittstelle erstellen. Dies definiert den "Vertrag", dem Repositorys folgen müssen, um von meinem Controller verwendet zu werden. Denken Sie daran, mein Controller weiß nicht, wo die Daten tatsächlich gespeichert sind.

Beachten Sie, dass meine Repositorys nur alle diese drei Methoden enthalten. Die save()Methode ist sowohl für das Erstellen als auch für das Aktualisieren von Benutzern verantwortlich, je nachdem, ob für das Benutzerobjekt eine ID festgelegt wurde oder nicht.

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

SQL Repository-Implementierung

Nun erstelle ich meine Implementierung der Schnittstelle. Wie bereits erwähnt, sollte mein Beispiel eine SQL-Datenbank sein. Beachten Sie die Verwendung eines Daten-Mappers , um zu verhindern, dass sich wiederholende SQL-Abfragen geschrieben werden müssen.

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');
    }
}

Objektoberfläche abfragen

Jetzt, da CUD (Erstellen, Aktualisieren, Löschen) von unserem Repository erledigt wird, können wir uns auf das R (Lesen) konzentrieren. Abfrageobjekte sind einfach eine Kapselung einer Art von Datensuchlogik. Sie sind keine Abfrage-Builder. Indem wir es wie unser Repository abstrahieren, können wir seine Implementierung ändern und es einfacher testen. Ein Beispiel für ein Abfrageobjekt kann ein AllUsersQueryoder AllActiveUsersQueryoder sogar sein MostCommonUserFirstNames.

Sie denken vielleicht: "Kann ich nicht einfach Methoden für diese Abfragen in meinen Repositorys erstellen?" Ja, aber hier ist, warum ich das nicht mache:

  • Meine Repositorys sind für die Arbeit mit Modellobjekten gedacht. Warum sollte ich in einer realen App jemals das passwordFeld abrufen müssen, wenn ich alle meine Benutzer auflisten möchte ?
  • Repositorys sind häufig modellspezifisch, Abfragen umfassen jedoch häufig mehr als ein Modell. In welches Repository stellen Sie Ihre Methode?
  • Dies hält meine Repositorys sehr einfach - keine aufgeblähte Klasse von Methoden.
  • Alle Abfragen sind jetzt in eigenen Klassen organisiert.
  • Zu diesem Zeitpunkt existieren wirklich Repositorys, um meine Datenbankebene zu abstrahieren.

In meinem Beispiel erstelle ich ein Abfrageobjekt, um nach "AllUsers" zu suchen. Hier ist die Schnittstelle:

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

Implementierung des Abfrageobjekts

Hier können wir wieder einen Data Mapper verwenden, um die Entwicklung zu beschleunigen. Beachten Sie, dass ich eine Änderung am zurückgegebenen Datensatz zulasse - die Felder. Dies ist ungefähr so ​​weit, wie ich mit der Manipulation der ausgeführten Abfrage gehen möchte. Denken Sie daran, dass meine Abfrageobjekte keine Abfrageersteller sind. Sie führen einfach eine bestimmte Abfrage durch. Da ich jedoch weiß, dass ich diese wahrscheinlich in verschiedenen Situationen häufig verwenden werde, gebe ich mir die Möglichkeit, die Felder anzugeben. Ich möchte niemals Felder zurückgeben, die ich nicht brauche!

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();
    }
}

Bevor ich zum Controller übergehe, möchte ich ein weiteres Beispiel zeigen, um zu veranschaulichen, wie leistungsfähig dies ist. Vielleicht habe ich eine Berichts-Engine und muss einen Bericht für erstellen AllOverdueAccounts. Dies kann mit meinem Daten-Mapper schwierig sein, und ich möchte SQLin dieser Situation möglicherweise einige aktuelle Informationen schreiben . Kein Problem, so könnte dieses Abfrageobjekt aussehen:

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...";
    }
}

Dadurch bleibt meine gesamte Logik für diesen Bericht in einer Klasse und es ist einfach zu testen. Ich kann es nach Herzenslust verspotten oder sogar eine ganz andere Implementierung verwenden.

Der Controller

Nun der lustige Teil - alle Teile zusammenbringen. Beachten Sie, dass ich die Abhängigkeitsinjektion verwende. Normalerweise werden Abhängigkeiten in den Konstruktor eingefügt, aber ich ziehe es tatsächlich vor, sie direkt in meine Controller-Methoden (Routen) einzufügen. Dies minimiert das Objektdiagramm des Controllers und ich finde es tatsächlich besser lesbar. Hinweis: Wenn Ihnen dieser Ansatz nicht gefällt, verwenden Sie einfach die traditionelle Konstruktormethode.

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;
    }
}

Abschließende Gedanken:

Die wichtigsten Dinge, die hier zu beachten sind, sind, dass ich beim Ändern (Erstellen, Aktualisieren oder Löschen) von Entitäten mit realen Modellobjekten arbeite und die Persistenz über meine Repositorys durchführe.

Beim Anzeigen (Auswählen von Daten und Senden an die Ansichten) arbeite ich jedoch nicht mit Modellobjekten, sondern mit einfachen alten Wertobjekten. Ich wähle nur die Felder aus, die ich benötige, und sie sind so konzipiert, dass ich meine Daten-Suchleistung maximieren kann.

Meine Repositorys bleiben sehr sauber, und stattdessen ist dieses "Durcheinander" in meinen Modellabfragen organisiert.

Ich verwende einen Daten-Mapper, um bei der Entwicklung zu helfen, da es einfach lächerlich ist, sich wiederholendes SQL für allgemeine Aufgaben zu schreiben. Sie können jedoch bei Bedarf unbedingt SQL schreiben (komplizierte Abfragen, Berichterstellung usw.). Und wenn Sie dies tun, ist es schön in einer richtig benannten Klasse versteckt.

Ich würde gerne Ihre Meinung zu meinem Ansatz hören!


Update Juli 2015:

Ich wurde in den Kommentaren gefragt, wo ich mit all dem gelandet bin. Naja, eigentlich gar nicht so weit weg. Ehrlich gesagt mag ich Repositories immer noch nicht wirklich. Ich finde sie übertrieben für grundlegende Suchvorgänge (insbesondere wenn Sie bereits ein ORM verwenden) und chaotisch, wenn Sie mit komplizierteren Abfragen arbeiten.

Ich arbeite im Allgemeinen mit einem ORM im ActiveRecord-Stil, daher verweise ich meistens nur direkt auf diese Modelle in meiner Anwendung. In Situationen, in denen ich komplexere Abfragen habe, verwende ich Abfrageobjekte, um diese wiederverwendbarer zu machen. Ich sollte auch beachten, dass ich meine Modelle immer in meine Methoden einfüge, damit sie in meinen Tests leichter verspottet werden können.


4
@PeeHaa Wieder war es, die Beispiele einfach zu halten. Es ist sehr üblich, Codeteile aus einem Beispiel herauszulassen, wenn sie sich nicht speziell auf das jeweilige Thema beziehen. In Wirklichkeit würde ich meine Abhängigkeiten weitergeben.
Jonathan

4
Interessant, dass Sie Ihr Erstellen, Aktualisieren und Löschen aus Ihrem Lesevorgang aufteilen. Ich dachte, es wäre erwähnenswert, Command Query Responsibility Segregation (CQRS) zu erwähnen, die genau das formal tut. martinfowler.com/bliki/CQRS.html
Adam

2
@ Jonathan Es ist anderthalb Jahre her, seit du deine eigene Frage beantwortet hast. Ich habe mich gefragt, ob Sie mit Ihrer Antwort immer noch zufrieden sind und ob dies jetzt Ihre Hauptlösung für die meisten Ihrer Projekte ist. In den letzten Wochen habe ich allot on repositories gelesen und ich habe gesehen, dass allot Leute ihre eigene Interpretation haben, wie es implementiert werden sollte. Sie nennen es Abfrageobjekte, aber dies ist ein vorhandenes Muster, oder? Ich glaube, ich habe gesehen, dass es in anderen Sprachen verwendet wird.
Boedy

1
@ Jonathan: Wie gehen Sie mit Abfragen um, bei denen ein Benutzer nicht "ID", sondern z. B. "Benutzername" oder noch kompliziertere Abfragen mit mehr als einer Bedingung sein soll?
Gizzmo

1
@Gizzmo Mithilfe von Abfrageobjekten können Sie zusätzliche Parameter übergeben, um bei Ihren komplizierteren Abfragen zu helfen. Dies können Sie beispielsweise im Konstruktor tun : new Query\ComplexUserLookup($username, $anotherCondition). Oder tun Sie dies über Setter-Methoden $query->setUsername($username);. Sie können dies wirklich entwerfen, aber es ist für Ihre spezielle Anwendung sinnvoll, und ich denke, Abfrageobjekte lassen hier viel Flexibilität.
Jonathan

48

Nach meiner Erfahrung finden Sie hier einige Antworten auf Ihre Fragen:

F: Wie gehen wir damit um, dass wir nicht benötigte Felder zurückbringen?

A: Meiner Erfahrung nach läuft dies wirklich darauf hinaus, sich mit vollständigen Entitäten im Vergleich zu Ad-hoc-Abfragen zu befassen.

Eine vollständige Entität ist so etwas wie ein UserObjekt. Es hat Eigenschaften und Methoden usw. Es ist ein erstklassiger Bürger in Ihrer Codebasis.

Eine Ad-hoc-Abfrage gibt einige Daten zurück, aber darüber hinaus wissen wir nichts. Wenn die Daten in der Anwendung weitergegeben werden, erfolgt dies ohne Kontext. Ist es ein User? A Usermit einigen OrderInformationen im Anhang? Wir wissen es nicht wirklich.

Ich arbeite lieber mit vollständigen Entitäten.

Sie haben Recht, dass Sie häufig Daten zurückbringen, die Sie nicht verwenden, aber Sie können dies auf verschiedene Arten angehen:

  1. Zwischenspeichern Sie die Entitäten aggressiv, sodass Sie den Lesepreis nur einmal aus der Datenbank bezahlen.
  2. Verbringen Sie mehr Zeit damit, Ihre Entitäten zu modellieren, damit sie sich gut voneinander unterscheiden. (Erwägen Sie, eine große Entität in zwei kleinere Entitäten usw. aufzuteilen.)
  3. Erwägen Sie mehrere Versionen von Entitäten. Sie können eine Userfür das Back-End und möglicherweise eine UserSmallfür AJAX-Anrufe haben. Man könnte 10 Eigenschaften haben und man hat 3 Eigenschaften.

Die Nachteile der Arbeit mit Ad-hoc-Abfragen:

  1. Bei vielen Abfragen erhalten Sie im Wesentlichen dieselben Daten. Mit a Userschreiben Sie beispielsweise select *für viele Anrufe im Wesentlichen dasselbe . Ein Anruf erhält 8 von 10 Feldern, einer 5 von 10, einer 7 von 10. Warum nicht alle durch einen Anruf ersetzen, der 10 von 10 erhält? Der Grund dafür ist, dass es Mord ist, neu zu faktorisieren / zu testen / zu verspotten.
  2. Mit der Zeit wird es sehr schwierig, auf hohem Niveau über Ihren Code nachzudenken. Anstelle von Aussagen wie "Warum ist das Userso langsam?" Am Ende werden einmalige Abfragen aufgespürt, sodass Fehlerkorrekturen in der Regel klein und lokalisiert sind.
  3. Es ist wirklich schwer, die zugrunde liegende Technologie zu ersetzen. Wenn Sie jetzt alles in MySQL speichern und zu MongoDB wechseln möchten, ist es viel schwieriger, 100 Ad-hoc-Aufrufe zu ersetzen, als eine Handvoll Entitäten.

F: Ich werde zu viele Methoden in meinem Repository haben.

A: Ich habe keinen anderen Weg gesehen, als Anrufe zu konsolidieren. Die Methodenaufrufe in Ihrem Repository werden wirklich Funktionen in Ihrer Anwendung zugeordnet. Je mehr Funktionen, desto mehr datenspezifische Anrufe. Sie können Funktionen zurücksetzen und versuchen, ähnliche Anrufe zu einem zusammenzuführen.

Die Komplexität am Ende des Tages muss irgendwo existieren. Mit einem Repository-Muster haben wir es in die Repository-Oberfläche verschoben, anstatt möglicherweise eine Reihe gespeicherter Prozeduren zu erstellen.

Manchmal muss ich mir sagen: "Nun, es musste irgendwo geben! Es gibt keine Silberkugeln."


Danke für die sehr gründliche Antwort. Du hast mich jetzt zum Nachdenken gebracht. Meine große Sorge hier ist, dass alles, was ich lese, nicht sagt SELECT *, sondern nur die Felder auswählt, die Sie benötigen. Siehe zum Beispiel diese Frage . Was all die Ad-Hock-Anfragen betrifft, von denen Sie sprechen, verstehe ich mit Sicherheit, woher Sie kommen. Ich habe gerade eine sehr große App, die viele davon hat. Das war mein "Nun, es musste irgendwo geben!" Moment entschied ich mich für maximale Leistung. Jetzt beschäftige ich mich jedoch mit VIELEN verschiedenen Abfragen.
Jonathan

1
Ein Folgegedanke. Ich habe eine Empfehlung zur Verwendung eines R-CUD-Ansatzes gesehen. Da readshäufig Leistungsprobleme auftreten, können Sie für diese einen benutzerdefinierten Abfrageansatz verwenden, der sich nicht in echte Geschäftsobjekte umsetzt. Dann wird für create, updateund deleteverwenden Sie ein ORM, die mit ganzen Objekten arbeitet. Irgendwelche Gedanken zu diesem Ansatz?
Jonathan

1
Als Hinweis zur Verwendung von "select *". Ich habe es in der Vergangenheit gemacht und es hat in Ordnung funktioniert - bis wir varchar (max) Felder getroffen haben. Diese haben unsere Anfragen getötet. Wenn Sie also Tabellen mit Ints, kleinen Textfeldern usw. haben, ist das nicht so schlimm. Fühlt sich unnatürlich an, aber Software geht diesen Weg. Was schlecht war, ist plötzlich gut und umgekehrt.
Ryan1234

1
Der R-CUD-Ansatz ist eigentlich CQRS
MikeSW

2
@ ryan1234 "Die Komplexität am Ende des Tages muss irgendwo existieren." Danke dafür. Lässt mich besser fühlen.
Johnny

20

Ich benutze folgende Schnittstellen:

  • Repository - Lädt, fügt ein, aktualisiert und löscht Entitäten
  • Selector - findet Entitäten basierend auf Filtern in einem Repository
  • Filter - kapselt die Filterlogik

Mein Repositoryist datenbankunabhängig; Tatsächlich gibt es keine Persistenz an. Es kann alles Mögliche sein: SQL-Datenbank, XML-Datei, Remote-Service, ein Außerirdischer aus dem Weltraum usw. Für Suchfunktionen können die RepositoryKonstrukte Selectorgefiltert LIMIT, sortiert, sortiert und gezählt werden. Am Ende holt der Selektor einen oder mehrereEntities aus der Persistenz.

Hier ist ein Beispielcode:

<?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();
}

Dann eine Implementierung:

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);
    }
}

Die Idee ist, dass die generischen SelectorVerwendungen, Filteraber die Implementierung SqlSelectorverwendet SqlFilter; das SqlSelectorFilterAdapterpasst ein generikum Filteran einen konkreten anSqlFilter .

Der Client-Code erstellt Filter Objekte (die generische Filter sind), aber in der konkreten Implementierung des Selektors werden diese Filter in SQL-Filter transformiert.

Andere Selektorimplementierungen InMemorySelectorwandeln sich von Filterzur InMemoryFilterVerwendung ihrer spezifischen um InMemorySelectorFilterAdapter; Daher wird jede Selektorimplementierung mit einem eigenen Filteradapter geliefert.

Mit dieser Strategie kümmert sich mein Client-Code (in der Bussines-Ebene) nicht um ein bestimmtes Repository oder eine bestimmte Selector-Implementierung.

/** @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 Dies ist eine Vereinfachung meines echten Codes


"Repository - lädt, fügt ein, aktualisiert und löscht Entitäten" Dies ist, was eine "Service-Schicht", "DAO", "BLL" tun kann
Yousha Aleayoub

5

Ich werde ein wenig hinzufügen, da ich gerade versuche, all dies selbst zu erfassen.

# 1 und 2

Dies ist ein perfekter Ort für Ihr ORM, um das schwere Heben zu erledigen. Wenn Sie ein Modell verwenden, das eine Art ORM implementiert, können Sie einfach seine Methoden verwenden, um diese Dinge zu erledigen. Erstellen Sie Ihre eigenen orderBy-Funktionen, die bei Bedarf die Eloquent-Methoden implementieren. Verwenden von Eloquent zum Beispiel:

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

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

Was Sie zu suchen scheinen, ist ein ORM. Kein Grund, warum Ihr Repository nicht auf einem basieren kann. Dies würde eine eloquente Benutzererweiterung erfordern, aber ich persönlich sehe das nicht als Problem.

Wenn Sie jedoch ein ORM vermeiden möchten, müssten Sie "Ihr eigenes rollen", um das zu bekommen, wonach Sie suchen.

#3

Schnittstellen sollten keine festen Anforderungen sein. Etwas kann eine Schnittstelle implementieren und ergänzen. Was es nicht tun kann, ist, eine erforderliche Funktion dieser Schnittstelle nicht zu implementieren. Sie können auch Schnittstellen wie Klassen erweitern, um die Dinge trocken zu halten.

Das heißt, ich fange gerade erst an zu verstehen, aber diese Erkenntnisse haben mir geholfen.


1
Was ich an dieser Methode nicht mag, ist, dass wenn Sie ein MongoUserRepository hätten, dieses und Ihr DbUserRepository unterschiedliche Objekte zurückgeben würden. Db gibt ein Eloquent \ Model zurück und Mongo etwas Eigenes. Eine bessere Implementierung besteht sicherlich darin, dass beide Repositorys Instanzen / Sammlungen einer separaten Entity \ User-Klasse zurückgeben. Auf diese Weise verlassen Sie sich nicht fälschlicherweise auf die DB-Methoden von Eloquent \ Model, wenn Sie zur Verwendung des MongoRepository wechseln
danharper

1
Da würde ich Ihnen definitiv zustimmen. Was ich wahrscheinlich tun würde, um dies zu vermeiden, ist, niemals diese Methoden außerhalb der Eloquent-Klasse zu verwenden. Daher sollte die Funktion get wahrscheinlich privat sein und nur innerhalb der Klasse verwendet werden, da sie, wie Sie bereits betont haben, etwas zurückgeben würde, was andere Repositorys nicht konnten.
Will

3

Ich kann nur kommentieren, wie wir (in meinem Unternehmen) damit umgehen. Zuallererst ist Leistung für uns kein allzu großes Problem, aber sauberer / korrekter Code ist es.

Zunächst definieren wir Modelle wie UserModelein Modell, das mithilfe eines ORM UserEntityObjekte erstellt. Wenn a UserEntityaus einem Modell geladen wird, werden alle Felder geladen. Für Felder, die auf fremde Entitäten verweisen, verwenden wir das entsprechende fremde Modell, um die jeweiligen Entitäten zu erstellen. Für diese Entitäten werden die Daten bei Bedarf geladen. Jetzt könnte Ihre erste Reaktion sein ... ??? ... !!! Lassen Sie mich ein Beispiel geben:

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)
    {
        //...
    }
}

In unserem Fall $dbhandelt es sich um ein ORM, das Entitäten laden kann. Das Modell weist das ORM an, eine Reihe von Entitäten eines bestimmten Typs zu laden. Das ORM enthält eine Zuordnung und verwendet diese, um alle Felder für diese Entität in die Entität einzufügen. Für fremde Felder werden jedoch nur die IDs dieser Objekte geladen. In diesem Fall OrderModelerstellt dasOrderEntity s nur die IDs der referenzierten Bestellungen. Wenn die Entität PersistentEntity::getFieldvon der OrderEntityEntität aufgerufen wird , weist sie ihr Modell an, alle Felder in das OrderEntitys zu laden . Alle OrderEntitys, die einer UserEntity zugeordnet sind, werden als eine Ergebnismenge behandelt und sofort geladen.

Die Magie hier ist, dass unser Modell und ORM alle Daten in die Entitäten einfügen und dass Entitäten lediglich Wrapper-Funktionen für die von getFieldbereitgestellte generische Methode bereitstellenPersistentEntity . Zusammenfassend laden wir immer alle Felder, aber Felder, die auf eine fremde Entität verweisen, werden bei Bedarf geladen. Das Laden einer Reihe von Feldern ist kein wirkliches Leistungsproblem. Das Laden aller möglichen ausländischen Unternehmen wäre jedoch ein RIESIGER Leistungsabfall.

Nun zum Laden einer bestimmten Gruppe von Benutzern, basierend auf einer where-Klausel. Wir bieten ein objektorientiertes Paket von Klassen, mit denen Sie einfache Ausdrücke angeben können, die zusammengeklebt werden können. Im Beispielcode habe ich es benannt GetOptions. Es ist ein Wrapper für alle möglichen Optionen für eine ausgewählte Abfrage. Es enthält eine Sammlung von where-Klauseln, eine group by-Klausel und alles andere. Unsere where-Klauseln sind ziemlich kompliziert, aber Sie könnten natürlich leicht eine einfachere Version erstellen.

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

Eine einfachste Version dieses Systems wäre, den WHERE-Teil der Abfrage als Zeichenfolge direkt an das Modell zu übergeben.

Es tut mir leid für diese ziemlich komplizierte Antwort. Ich habe versucht, unseren Rahmen so schnell und klar wie möglich zusammenzufassen. Wenn Sie weitere Fragen haben, können Sie diese gerne stellen und ich werde meine Antwort aktualisieren.

BEARBEITEN: Wenn Sie einige Felder wirklich nicht sofort laden möchten, können Sie in Ihrer ORM-Zuordnung eine Option zum verzögerten Laden angeben. Da alle Felder schließlich über die getFieldMethode geladen werden , können Sie einige Felder in letzter Minute laden, wenn diese Methode aufgerufen wird. Dies ist kein sehr großes Problem in PHP, aber ich würde es nicht für andere Systeme empfehlen.


3

Dies sind einige verschiedene Lösungen, die ich gesehen habe. Jeder von ihnen hat Vor- und Nachteile, aber Sie müssen selbst entscheiden.

Problem Nr. 1: Zu viele Felder

Dies ist ein wichtiger Aspekt, insbesondere wenn Sie nur Index-Scans berücksichtigen . Ich sehe zwei Lösungen, um mit diesem Problem umzugehen. Sie können Ihre Funktionen aktualisieren, um einen optionalen Array-Parameter aufzunehmen, der eine Liste der zurückzugebenden Spalten enthält. Wenn dieser Parameter leer ist, geben Sie alle Spalten in der Abfrage zurück. Das kann etwas komisch sein; Basierend auf dem Parameter können Sie ein Objekt oder ein Array abrufen. Sie können auch alle Ihre Funktionen duplizieren, sodass Sie zwei unterschiedliche Funktionen haben, die dieselbe Abfrage ausführen. Eine gibt jedoch ein Array von Spalten und die andere ein Objekt zurück.

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

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

Problem Nr. 2: Zu viele Methoden

Ich habe vor einem Jahr kurz mit Propel ORM gearbeitet und dies basiert auf dem, woran ich mich aus dieser Erfahrung erinnern kann. Propel hat die Möglichkeit, seine Klassenstruktur basierend auf dem vorhandenen Datenbankschema zu generieren. Es werden zwei Objekte für jede Tabelle erstellt. Das erste Objekt ist eine lange Liste von Zugriffsfunktionen, die denen ähneln, die Sie derzeit aufgelistet haben. findByAttribute($attribute_value). Das nächste Objekt erbt von diesem ersten Objekt. Sie können dieses untergeordnete Objekt aktualisieren, um Ihre komplexeren Getter-Funktionen einzubauen.

Eine andere Lösung wäre die __call()Zuordnung nicht definierter Funktionen zu etwas Umsetzbarem. Ihre __callMethode wäre in der Lage, findById und findByName in verschiedene Abfragen zu analysieren.

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]
    }
}

Ich hoffe das hilft zumindest einiges was.



0

Ich stimme @ ryan1234 zu, dass Sie vollständige Objekte innerhalb des Codes weitergeben und generische Abfragemethoden verwenden sollten, um diese Objekte abzurufen.

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

Für die externe / Endpunkt-Verwendung mag ich die GraphQL-Methode sehr.

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

0

Problem Nr. 3: Es ist nicht möglich, eine Schnittstelle zu finden

Ich sehe den Vorteil in der Verwendung von Schnittstellen für Repositorys, damit ich meine Implementierung austauschen kann (zu Testzwecken oder zu anderen Zwecken). Mein Verständnis von Schnittstellen ist, dass sie einen Vertrag definieren, dem eine Implementierung folgen muss. Dies ist großartig, bis Sie beginnen, Ihren Repositorys zusätzliche Methoden wie findAllInCountry () hinzuzufügen. Jetzt muss ich meine Schnittstelle aktualisieren, um auch diese Methode zu haben. Andernfalls verfügen andere Implementierungen möglicherweise nicht über diese Methode, und dies könnte meine Anwendung beschädigen. Dadurch fühlt es sich verrückt an ... ein Fall, in dem der Schwanz mit dem Hund wedelt.

Mein Bauch sagt mir, dass dies möglicherweise eine Schnittstelle erfordert, die neben generischen Methoden auch abfrageoptimierte Methoden implementiert. Leistungsempfindliche Abfragen sollten zielgerichtete Methoden haben, während seltene oder leichte Abfragen von einem generischen Handler bearbeitet werden, was möglicherweise die Kosten des Controllers verursacht, der etwas mehr Jonglieren ausführt.

Die generischen Methoden würden die Implementierung einer Abfrage ermöglichen und somit verhindern, dass Änderungen während einer Übergangszeit unterbrochen werden. Mit den gezielten Methoden können Sie einen Anruf optimieren, wenn dies sinnvoll ist, und er kann auf mehrere Dienstanbieter angewendet werden.

Dieser Ansatz ähnelt Hardware-Implementierungen, die bestimmte optimierte Aufgaben ausführen, während Software-Implementierungen die leichte Arbeit oder die flexible Implementierung erledigen.


0

Ich denke graphQL ist in einem solchen Fall ein guter Kandidat, um eine umfangreiche Abfragesprache bereitzustellen, ohne die Komplexität von Datenrepositorys zu erhöhen.

Es gibt jedoch eine andere Lösung, wenn Sie sich vorerst nicht für graphQL entscheiden möchten. Mit einem DTO bei dem ein Objekt zum Übertragen der Daten zwischen Prozessen verwendet wird, in diesem Fall zwischen dem Dienst / Controller und dem Repository.

Eine elegante Antwort ist bereits oben angegeben, ich werde jedoch versuchen, ein anderes Beispiel zu nennen, das meiner Meinung nach einfacher ist und als Ausgangspunkt für ein neues Projekt dienen könnte.

Wie im Code gezeigt, würden wir nur 4 Methoden für CRUD-Operationen benötigen. dasfind Methode wird zum Auflisten und Lesen durch Übergeben eines Objektarguments verwendet. Backend-Services können das definierte Abfrageobjekt basierend auf einer URL-Abfragezeichenfolge oder basierend auf bestimmten Parametern erstellen.

Das Abfrageobjekt ( SomeQueryDto) kann bei Bedarf auch eine bestimmte Schnittstelle implementieren. und kann später leicht erweitert werden, ohne die Komplexität zu erhöhen.

<?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;
    }
}

Anwendungsbeispiel:

$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);
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.