Controller-Models

Eine Controller-Klasse enthält die Logik, die für die Verarbeitung einer Anfrage nötig ist. Hierfür benötigt sie natürlich Zugriff auf externe Datenquellen wie z.B. eine Datenbank, den HTTP-Request o.a. Diese Abfragen werden in vielen Anwendungen innerhalb des Controllers erledigt. Dadurch wird das Single-Responsibility-Prinzip im Controller verletzt, da der Controller sowohl die Logik zur Verarbeitung der Anfrage als auch Funktionen für den Zugriff auf externe Daten enthält. Ich habe mir deshalb überlegt, wie eine Trennung von Business-Logik, Datenzugriff und ORM ermöglicht wird.

Mittlerweile habe ich selbst festgestellt, dass derartige Controller-Models n der Praxis nicht wirklich gut einsetzbar sind. Das Ansprechen der Datenbank aus dem Controller sollte deshalb lieber mithilfe von Service- bzw. Repository-Klassen, die zu einem bestimmten Domain-Model gehören, erfolgen.

Separation of Concerns / Single Responsibility

Zuerst möchte ich anhand eines Beispiels zeigen, wie leicht das Single-Responsibility-Prinzip im Controller verletzt werden kann. Hierzu dient ein Beispiel aus dem Symfony Book. Symfony ermöglicht den Einsatz unterschiedlicher Object Relational Mapper (ORM). In der Dokumentation wird der Einsatz von Doctrine und Propel beschrieben.
Zuerst ein Controller-Beispiel mit Doctrine:

class MyController {
    public function showAction($id) {
        $product = $this->getDoctrine()
                        ->getRepository('AppBundle:Product')
                        ->find($id);
 
        // ... do something, like pass the $product object into a template
    }
}

Es wird einfach eine ID übergeben, dessen zugehöriger Product-Datensatz anschließend durch Doctrine in der Datenbank gesucht und in ein Objekt umgewandelt wird. Anschließend würde die Business-Logik folgen, sodass schließlich eine Antwort an den Nutzer gesendet werden kann.
Das gleiche Beispiel mit Propel als ORM sieht folgendermaßen aus:

class MyController {
    public function showAction($id) {
        $product = ProductQuery::create()
                               ->findPk($id);
 
        // ... do something, like pass the $product object into a template
    }
}

Angenommen in beiden Beispielen wäre die gleiche Logik enthalten (der Teil nach dem eigentlichen Datenholen, also „… do something …“), erkennt man sofort, dass der Controller nun an das jeweilige ORM gekoppelt wurde. Das bedeutet, wenn der Zugriff auf das ORM geändert werden muss, müsste immer auch der Controller mit angepasst werden. Außerdem erschwert eine solche Implementierung das automatisierte Testen enorm, weil stets gegen eine Datenbank getestet werden müsste. Die PHPUnit-Dokumentation bringt es auf den Punkt:

Many beginner and intermediate unit testing examples in any programming language suggest that it is perfectly easy to test your application’s logic with simple tests. For database-centric applications this is far away from the reality. Start using WordPress, TYPO3 or Symfony with Doctrine or Propel, for example, and you will easily experience considerable problems with PHPUnit: just because the database is so tightly coupled to these libraries.

Normalerweise wäre der Ansatz in diesem Fall ein ORM-Interface sowie dieses Interface implementierende Adapter-Klassen zu erstellen, die alle Methoden enthalten, die zum Zugriff auf ein ORM notwendig sind. Dies ist aber aufgrund der unterschiedlichen Design Patterns, die Doctrine (Data Mapper Pattern mit dem Entity Manager) und Propel (Active Record Pattern) nutzen, schwierig bis unmöglich (obwohl in Propel derzeit an einer Umsetzung des Data Mapper Patterns gearbeitet wird).

Ich habe deshalb überlegt, wie man die Kopplung des ORM an den Controller noch umgehen kann. Mein Vorschlag ist, dass die Business-Logik von allen Operationen, die auf externe Datenquellen zugreifen, separiert wird: Der Controller kümmert sich allein um die Logik und kann bei Bedarf aber ein speziell zu diesem Controller gehörendes Controller-Model fragen, das sämtliche Methoden enthält, die der Controller benötigt, um auf externe Daten zuzugreifen, ohne aber direkt das ORM anzusprechen (damit das ORM austauschbar bleibt).

Umsetzung

Durch die HTTP-Anfrage wird festgelegt, welcher Controller die Verarbeitung der Anfrage übernehmen soll (Routing).
Anschließend würde der Controller geschrieben werden und immer, wenn man bemerkt, dass an dieser oder jener Stelle auf externe Datenquellen zugegriffen werden muss, wird im zu dem Controller gehörenden Controller-Model-Interface eine Methode definiert. Alle Klassen, die dieses Interface implementieren und sich um den Datenzugriff kümmern, bieten somit die gleiche Schnittstelle und werden dadurch austauschbar.
Das Beispiel oben sähe folglich so aus:

// Controller
class MyController {
    protected $controllerModel;
 
    public function __construct(MyControllerModel $controllerModel) {
        $this->controllerModel = $controllerModel;
    }
 
    public function showAction() {
        $product = $this->controllerModel
                        ->getProduct();
 
        // ... do something, like pass the $product object into a template
    }
}
 
// Controller-Model-Interface
interface MyControllerModel {
    public function getProduct();
}
 
// Beispiel-Implementierung eines Controller-Models
class MyPropelControllerModel implements MyControllerModel {
    protected $request;
 
    public function __construct(Request $request) {
        $this->request = $request;
    }    
 
    public function getProduct() {
        $productID = $this->request
                          ->getParameter('product');
 
        return ProductQuery::create()
                           ->findPk($productID);
    }
}

In diesem Beispiel übernimmt das Controller-Model die komplette Datenhaltung. Der Controller selbst kümmert sich ausschließlich um die Logik – in diesem Fall also Verarbeitung des Product-Objekts, das durch die im Request übergebene Product-ID spezifiziert wird.
Wenn nun jemand entscheidet, dass das ORM gewechselt werden soll (sei es im Produktivbetrieb oder nur beim Mocken für einen Test), muss nur eine neue Klasse erstellt werden, die das MyControllerModel-Interface implementiert. Die einzige Änderung am bestehenden Code wäre, dass an den Controller nun das neue Controller-Model übergeben werden müsste.

Unterschiedliche ORM-Objekte

Die Trennung von Logik und Datenhaltung wäre durch oben beschriebene Trennung gewährleistet, allerdings bleibt ein Problem: Das vom Controller-Model zurückgelieferte Objekt – im Beispiel oben also das Product-Objekt – basiert je nach ORM auf unterschiedlichen Klassen, die nicht notwendigerweise die gleichen Methoden aufweisen. Um also wirklich austauschbare Controller-Models zu erstellen, müssen die Domain-Models manuell implementiert werden. Für die fertigen Domain-Models, die Propel generiert, müssten im Zweifelsfall erst Adapter-Klassen geschrieben werden.
Wer Doctrine einsetzt, führt diesen Schritt automatisch aus, da dort die Domain Models sowieso händisch erstellt werden müssen. Dies würde den Vorteil von Propel, das die Model-Klassen automatisch generieren kann, ad absurdum führen.

Wiederverwendbarkeit von Controller-Models

Da Controller-Models speziell auf eine Controller-Klasse zugeschnitten sind, sind sie nicht für andere Controller verwendbar. Trotzdem kann und wird es häufig vorkommen, dass zwei unterschiedliche Controller den Zugriff auf die gleichen Daten benötigen. Im Beispiel oben wird ein Product-Objekt benutzt. Man stelle sich vor, dass in einem Online-Shop das Product-Objekt sowohl auf der Detailseite als auch im Administrationsbereich bei der Pflege der Stammdaten benötigt werden würde. Die zugehörigen Controller-Models hätten deshalb beide die gleiche Methode, die aus einer im Request übergebenen Product-ID das Product-Objekt zurückliefert. Um diesen doppelten Code zu vermeiden, würde ich die Implementierung der Controller-Models mithilfe von Traits umsetzen:

trait ConvertProductIDToProduct {
    public function getProduct() {
        // Access ORM
 
        return $product;
    }
}
 
class MyControllerModelImplementation implements MyControllerModel {
    use ConvertProductIDToProduct;
}
 
class MyAdministrationControllerModelImplementation implements MyAdministrationControllerModel {
    use ConvertProductIDToProduct;
}

Param Converter

Ein alternativer Ansatz für die Umwandlung von Request-Parametern in Objekte wäre (in Symfony) durch Param Converter möglich. Hierbei könnte der Controller direkt mit dem benötigten Product-Objekt aufgerufen werden:

class MyController {
    /**
     * @Route("/blog/{id}")
     * @ParamConverter("product", class="AppBundle:Product")
     */
    public function showAction(Product $product) {
        // ... do something, like pass the $product object into a template
    }
}

Durch den Einsatz von Param Converters spart man enorm viel Code. Dadurch wird der Controller besser lesbar. Dieser Vorteil wird allerdings dadurch erkauft, dass der eigentliche Code für die Datenhaltung in Annotations – also Kommentaren – umgesetzt ist. Ich selbst mag den Einsatz von Annotations oder anderen Ansätzen, den Programmfluss mithilfe von Kommentaren festzulegen nicht. Außerdem ist der Einsatz von Param Converters nicht für komplexere Abfragen sowie für Schreiboperationen möglich, weshalb man sich letztlich trotzdem Gedanken machen muss, wie man den Zugriff auf das ORM aus seinen Controllern heraus hält.

Alternativen?

Es handelt sich hier um einen Vorschlag, wie man die Trennung von Controllern und Models gewährleisten kann. Falls jemand dieses Konzept unter einem anderen Namen kennt, würde ich mich über einen kurzen Kommentar freuen.
Außerdem würde ich mich über einen Austausch in den Kommentaren freuen, wie ihr die Trennung von Logik und Datenhaltung in euren Anwendungen sicherstellt. Vielleicht gibt es ja bessere Ansätze als den hier vorgestellten.

Jan hat 152 Beiträge geschrieben

3 Kommentare zu “Controller-Models

  1. Jan sagt:

    Völlig richtig. Die Aufgabe des Controller-Models definiere ich ja als Schnittstelle zu allen externen Daten. Dazu würde ich neben der Datenbank auch den Request zählen. Sicherlich könnte man den Request auch direkt an den Controller übergeben. Aber da im Request auch Daten von außen stehen, hatte ich mich dazu entschieden Zugriff auf den Request mit über das Controller-Model zu regeln. Zumal Request ein Interface ist, es wird also nicht die konkrete Implementierung gekoppelt sondern das Interface.

    Wie könnte man denn den Controller sonst gestalten, sodass er von den Models getrennt ist? Bzw. dass man den Controller nicht an eine spezifische Implementierung eines DBMS oder ORMs koppelt?

  2. Markus sagt:

    Dein ControllerModel ist jetzt nicht weniger Abhängig vom Request als der Controller, so könntest du dir das auch sparen 😉 Wenn du deine Application Logik jetzt testen möchtest, müsstest du dir ja wieder einen Request zusammenbasteln. Dann kannste auch den Controller nutzen und dort den Request reinstecken. Eine ganz simple Möglichkeit dies zu umgehen wäre im Controller die Daten aus dem Request auszulesen und übre einen Parameter in dein Model zu übergeben. Dann ist der Controller die Schicht zwischen deiner Business Logik und dem Interface (das Web). So sollte es auch sein 😉

    Statt ein eigenes Framework zu entwickeln, lern lieber wie es die erfolgreichen Frameworks getan haben und beschäftige dich mit den Best Practices und lese in der Community, was sie empfehlen. Dadurch lernst du deutlich besser, wie man so etwas aufbaut, als wenn du es selber versuchst.

Eine Antwort schreiben

Ihre E-Mail-Adresse wird nicht veröffentlicht. Benötigte Felder sind markiert mit *

You may use these HTML tags and attributes: <a href=""> <blockquote cite=""> <pre lang=""> <b> <strong> <i> <em>