Writings
Blog
Doctrine ORM Pitfalls
29.09.21Die Arbeit mit einem ORM ist alles andere als trivial. In diesem Blog Post zeige ich 6 Fallstricke/Pitfalls die ich mit dem PHP ORM Doctrine wiederholt gesehen habe. Zu jedem Fallstrick werde ich eine Lösung vorstellen.
ORM
Doctrine ist ein ORM (Object Relational Mapper) für PHP. Ein ORM sorgt dafür, dass Objekte der Programmiersprache in eine Relationale Datenbank abgelegt werden können.
Objektrelationale Abbildung (englisch object-relational mapping, ORM) ist eine Technik der Softwareentwicklung, mit der ein in einer objektorientierten Programmiersprache geschriebenes Anwendungsprogramm seine Objekte in einer relationalen Datenbank ablegen kann.
Dem Programm erscheint die Datenbank dann als objektorientierte Datenbank, was die Programmierung erleichtert. Implementiert wird diese Technik normalerweise mit Klassenbibliotheken, wie beispielsweise Entity Framework f ür .NET-Programmiersprachen, Hibernate für die Programmiersprache Java, Doctrine für PHP, SQLAlchemy für Python oder ActiveRecord für Ruby. Für Java gibt es auch eine standardisierte Schnittstelle, die Java Persistence API.
Quelle: wikipedia
Dabei ist das Abbilden von Klassen auf eine relationale Datenbank keine einfach Aufgabe. Das Problem entsteht durch die Verwendung von objektorientierten Programmiersprachen in Verbindung mit Daten, die in relationalen Datenbanken gespeichert werden. Objektorientierte Anwendungen stellen ihre Daten durch Objekte dar. Sollen die Daten gespeichert werden, so liegt es nahe, die Objekte an sich in einer Datenbank zu speichern. Es stellt sich allerdings heraus, dass das relationale Datenbankmodell grundlegende Unterschiede zum objektorientierten Modell aufweist. Diese Unverträglichkeit wird seit Anfang der 1980er Jahre als Impedance Mismatch bezeichnet
Quelle: wikipedia
Nachfolgend möchte ich einige Fallstricke und allgemeine Fehler bei der Nutzung eines ORM anhand von Doctrine für PHP vorstellen.
Doctrine Grundlagen verstehen
Um die Probleme nachvollziehen zu können, werde ich erst einige Grundlagen von Doctrine erläutern. Um einen reibungslosen Ablauf zu gewährleisten, nutzt ein ORM mehrere Software Pattern. Das Wissen um die Softwarepattern ist unerlässlich, um die Probleme, welche bei der Nutzung auftreten können, zu verstehen und beheben zu können.
Data Mapper
Es gibt verschiedene Wege, wie Daten aus einer relationalen Datenbank in eine Applikation kommen können. Viele dieser Muster wurden von Martin Fowler in dem Buch Patterns for Enterprise Application Architecture beschrieben.
Doctrine nutzt grundlegend das Data Mapper Pattern, welches zu Pattern der Kategorie Data Source Architektur Muster gehört.
Im Gegensatz zu anderen Mustern aus dieser Kategorie zieht das Data Mapper Pattern darauf ab, beliebige Objektstrukturen in einer Datenbank abzubilden. Damit unterscheidet sich der Data Mapper von anderen Mustern dieser Kategorie wie z. B. ActiveRecord.
Bei der Nutzung des Data Mapper Pattern muss den Businessobjekten nicht einmal bekannt sein, dass eine Datenbank existiert oder wie diese beschaffen ist. Im konkreten Fall von Doctrine kann z. B. zwischen verschiedenen Treibern für SQL Datenbanken oder einem NO-SQL Ansatz gewählt werden.
Der Doctrine DBAL (Database Abstraction Layer) bietet in diesem Fall Treiber für:
- SqLite
- MySql
- Postgress
- Oracle
- MsSql
- IbmDb2
Alternativ kann anstelle eines ORM ein ODM (Object Document Mapper) genutzt werden. Doctrine bietet mit MongoDB ODM eine Implementierung für MongoDb an.
With Data Mapper the in-memory objects needn't know even that there's a database present; they need no SQL interface code, and certainly no knowledge of the database schema.
Quelle: P of EAA - Data Mapper
Annotationen/Attribute
Um also von ORM auf ODM umzustellen oder beide parallel zu nutzen, müssen die PHP-Annotationen an den Objekten angepasst werden.
/** * @ORM\Entity * @ORM\Table (name="users") */ class Message { /** * @ORM\Id * @ORM\Column(type="integer") * @ORM\GeneratedValue */ private $id; /** @ORM\Column(length=140) */ private $text; /** @ORM\Column(type="datetime", name="posted_at") */ private $postedAt; } /** * @ODM\Document */ class User { /** @ODM\Id */ private $id; /** @ODM\Field(type="string") */ private $name; /** @ODM\Field(type="string") */ private $email; /** @ODM\ReferenceMany(targetDocument="BlogPost", cascade="all") */ private $posts = array(); }
Auffällig ist, dass die Annotationen kein Bestandteil der Sprache sind, sondern innerhalb von PHP Kommentaren geschrieben werden und nicht wie z. B. in C# ein Bestandteil der Sprache sind,
Dies hat sich mit PHP 8 geändert. Es gibt nun Attribute, diese werden aber noch nicht in jeder Codebasis genutzt, da PHP 7 immer noch aktiven Support erhält.
Die Kommentare werden von dem Doctrine-Annotation-Modul ausgewertet und verarbeitet. Somit stellt das Doctrine-Annotation-Modul eine Erweiterung von PHP dar.
Da bekannte Entwicklungsumgebungen Kommentare aber nicht wie Quellcode behandeln, ist dies der erste Fallstrick bei der Nutzung von Doctrine.
Abhilfe schaffen diverse IDE Plugins. Exemplarisch sei hier das sehr gute PHP Annotations Plugin für PHP Storm von Daniel Espendiller erwähnt.
PITFALL 1: Annotation werden nicht von der IDE unterstützt.
Lösung 1: Annotation Plugin nutzen.
Lösung 1: Annotation Plug-in nutzen.
Das Doctrine-Annotation-Modul wird nicht ausschließlich für das Doctrine ORM verwendet, sondern auch in vielen anderen Projekten, wie z. B. dem PHP Framework Symfony, Drupal, Typo3. Das Doctrine-Annotation-Modul ist der quasi Standard, um in PHP Attribute zu Klassen hinzuzufügen. In dieser Hinsicht steht PHP 7 moderneren Sprachen wie C# in feinster Weise nach.
Die für das ORM relevanten Annotationen können in der Doctrine-Dokumentation in dem Kapitel "4. Basic Mapping" und Folgenden nachgelesen werden.
Unit of Work
Da Klassen bei einem Data Mapper nicht um Logik erweitert, sondern nur von einer externen Klasse "befüllt" werden, braucht es eine Instanz, welche die Objekte mit der jeweiligen Datenbank abgleicht, den Mapper.
Angenommen bei einem User Objekt wurde der Name angepasst. Die Anpassung könnte mit der User Mapperklasse direkt mit der Datenbank synchronisiert werden.
Wenn ein weiteres Objekt mit der Datenbank synchronisiert wird, würde dies wieder eine Datenbankabfrage erstellen. In einer hinreichend großen Applikation führt das zu viele kleine Datenbankanfragen.
Dies stellt einen großen Umweg im Vergleich mit dem Setzen der Variablen im Speicher dar. Im schlimmsten Fall ist die Datenbank mit einer langsamen Netzwerkverbindung und einer HDD ausgestattet. Selbst wenn die Datenbank sehr schnell ist, ihre Tabellen im Speicher liegen und die Netzwerkverbindung sehr gut ist, muss jede Abfrage über mehrere zusätzliche Abstraktionsebenen gesendet werden.
Mit dem Unit of Work Muster werden Anpassungen gesammelt und in einem Commit in die Datenbank geschrieben.
Bei dem (Unit of Work)[https://www.martinfowler.com/eaaCatalog/unitOfWork.html] Muster werden Anpassungen an den Domain Model Klassen zuerst im Arbeitsspeicher gemacht und die Datenbank wird nicht aktualisiert. Wenn eine sinnvolle Unit of Work (Arbeitseinheit) beendet ist, wird mit dem Dextrine Befehl "flush" der Status der Objekte in die Datenbank geschrieben. Wenn ein Objekt zuvor (z. B. per DQL oder mit der find Methode) in der Datenbank vorhanden war, werden die bekannten Daten mit den aktuellen Daten verglichen und nur die Änderungen übertragen.
Diese Operation ist bei nicht trivialen Datenmengen recht aufwendig.
Weiterhin kann ein Teil der Applikation einen Fehler aufweisen was zu einer Ausnahmebehandlung führt. In diesem Fall sind die Daten in der Datenbank ggf. in einem invaliden Zustand.
Siehe hierzu 7.6.5 The cost of flushing
PITFALL 2: Flush-Methode bei jeder Domain Model Anpassung aufrufen.
Jetzt könnte die Idee aufkommen, flush nur einmal am Ende eines (Web-Consolen) Requests aufzurufen.
Dieser Gedanke ist jedoch aus Gründen der Wiederverwendbarkeit der Software nicht optimal.
Angenommen, die Objekte werden (ohne detach aufzurufen) an eine Template-Engine oder Plug-in-System innerhalb der Applikation übergeben. Dann kann es vorkommen, dass externer Code unvorhergesehene Effekte erzeugt.
Es müssen sinnvolle Arbeitseinheiten (Units of Work) / Business Transactions definiert werden, um einerseits Datenkonsistenz und andererseits Performance sicherzustellen.
PITFALL 3: Arbeitseinheiten sind unklar.
Lösung zu 2-3: Well defined Units of Work
Identity Map
Eine der wichtigsten Aufgaben jedes ORM ist sicherzustellen, dass eine Entität nur einmal im Speicher vorhanden ist.
Dazu wird das Pattern Identity Map genutzt.
Dies hat einerseits Vorteile in Hinsicht auf die Performance. Wird z. B. zwei Mal die "find" Methode aufgerufen, löst dies nur eine Datenbank Abfrage aus. Siehe hierzu die Doctrine Dokumentation 7.1 Entities and the Identity Map.
Andererseits sorgt dieses Verhalten dafür, dass ein Objekt mit einer Identität nur einmal im Speicher vorhanden ist. Wenn ein Objekt an mehreren Stellen im Speicher in unterschiedlichen Status vorhanden wäre könnte bei einem "flush" nicht sichergestellt werden welches dieser Objekte mit der Datenbank abgeglichen werden soll.
Wichtig hierbei ist dass, Anpassungen im Arbeitsspeicher stets Vorrang vor den Daten aus der Datenbank haben.
Angenommen ein Objekt wird mit der "find" Methode geladen und im Speicher modifiziert. Anschließend wird eine List von Objekten per DQL abgerufen, in deren Ergebnismenge sich auch dieses Objekt befindet. Die Anpassungen die bereits im Speicher getätigt wurden Bleiben in diesem Fall erhalten.
Da jede Entity nur einmal im Speicher vorhanden ist, sind Inkonsistenzen an der Entity fatal !
<?php class Post { public function addComment(Comment $comment) { $this->comments->add($comment); } public function removeComment(Comment $comment) { $this->comments->removeElement($comment); } public function getComments() { return $this->comments; } } class Comment { public function setPost(Post $post) { $this->post = $post; } } $post = $postRepo->find(1); $comment = $commentRepo->find(1); $comment->setPost($post); $post->getComments()->count(); // !!!!! 0 !!!!!
Wie in diesem Beispiel zu sehen ist, wird ein inkonsistenter Zustand im Speicher provoziert.
Der Kommentar kennt den Post, aber der Post kennt den Kommentar nicht.
Siehe hierzu: 8.4 Association Management Methods
Das Problem multipliziert sich nochmals wenn, die Identity Map bedacht wird. Ein anderer Teil des Programms bezieht mit der "find" Methode oder per DQL in den meisten Fällen den inkonsistenten Status der Entity.
PITFALL 4: Objekt Assoziationen Inkonsistent
Lösung 4: Assoziation Management in Entity-Klassen
Code wie diesen habe ich leider sehr oft gesehen, wenn Codegeneratoren genutzt wurden und die Grundlagen von Doctrine nicht bekannt waren. Die Probleme, die ein in sich inkonsistenter Status im Speicher hat, sind vielfältig und nicht leicht zu finden.
Die Nutzung von Codegeneratoren verleitet dazu, die Entity-Klassen als eine Form der Darstellung einer Datenbanktabelle zu sehen.
Analog dazu kann auch das Anti-Pattern Anemic Domain Model genannt werden.
Wenn kein Domain Model genutzt werden soll, sind Muster wie z.B. Active Record oder Table Data Gateway zu bevorzugen.
PITFALL 5: Nur autogenerierte Entity-Klassen nutzen.
Im Gegensatz zu C#, wo eine Klasse mit dem partial Keyword in mehrere Source Code Dateien aufgeteilt werden kann, bietet PHP kein analoges Verhalten an.
Meine Erfahrung zeigt mir dass, Entity-Klassen nicht angepasst werden können, da ein Workflow definiert wird, in dem das Datenbankschema führend ist.
Bei diesem Workflow gehen alle Vorteile, die ein ORM mit Domain Model mit sich bringt, verloren. Die Entwickler berauben sich der Möglichkeit, ein Domain Model zu erstellen.
In essence the problem with anemic domain models is that they incur all of the costs of a domain model, without yielding any of the benefits. The primary cost is the awkwardness of mapping to a database, which typically results in a whole layer of O/R mapping. This is worthwhile iff you use the powerful OO techniques to organize complex logic. By pulling all the behavior out into services, however, you essentially end up with Transaction Scripts, and thus lose the advantages that the domain model can bring.
Quelle: Martin Fowler - Amenic Domain Model Anti Pattern
Schema
Eine Alternative zum Erstellen der Entity-Klassen aus dem Datenbankschema ist, die Entity-Klassen selber als Datenquelle zu nutzen. Dieser Ansatz klingt vorerst recht Komplex funktioniert aber in der Praxis besser.
Wenn eine Entity z. B. um ein Feld erweitert wird, muss ein SQL-Befehl geschrieben werden, welcher die Tabelle um eine Spalte erweitert. Dieser SQL-Befehl muss nun auf dem entsprechenden SQL-Server einmalig ausgeführt werden.
Diesen Ansatz wird Code First genannt.
In Doctrine übernehmen dies die Doctrine Migrations.
$ ./doctrine migrations:diff
Generated new migration class to "/path/to/migrations/DoctrineMigrations/Version20100416130459.php" from schema differences.
Erzeugt eine Migrations Datei
namespace doctrinemigrations; use doctrine\dbal\migrations\abstractmigration, doctrine\dbal\schema\schema; class version20100416130459 extends abstractmigration { public function up(schema $schema) { $this->addsql('alter table users add test varchar(255) not null'); } public function down(schema $schema) { $this->addsql('alter table users drop test'); } }
Quelle: Doctrine Migration Dokumentation
PITALL 6: Schema und Entity-Klassen nicht synchron
Lösung zu 5-6: Doctrine Migrationen / Code First Ansatz
Da jede Migration einen eigenen Timestamp besitzt, ist die Reihenfolge der Migrationen vorgegeben. Mehrere Entwickler können Migrationen erstellen. Beim Zusammenführen der Commits in einem Integrations- oder Testsystem können die Migrationen unabhängig ausgeführt werden.
Dieser Workflow skaliert zusammen mit der Nutzung einer CI-Umgebung wie z.B. Gitlab sehr gut mit dem Team.
Ein weiterer positiver Effekt ist die Versionierung der Datenbank.