Neulich bin ich in ein komplett neues Projekt eingestiegen. Das Team diskutierte über ein mögliches ORM Framework. Die Anforderungen an da ORM-Framework waren: „Wir möchten keine SQL-Statement mehr schreiben“, „Wir möchten nur mit Objekten arbeiten“, „Ein Scaffolding oder ein Reverse-Engineering wäre super, ist aber kein muss“ und „Wir möchten Vor- und Nachteile von Doctrine2 gegenüber MySQL-Functions und PDO haben“. Zuletzt auch noch diese: „Wenn möglich, dann soll es mit nicht relationalen Datenbanken sowie mit relationalen Datenbanken zusammen arbeiten können“.
Für dieses Mamut-Benchmarking habe ich die TestSuite von Roman Borschel [Benchmark of the major PHP ORMs] verwendet. Die TestSuite ist auch eine Art Benchmarking verschiedener ORM-Frameworks. Daraus habe ich mir jeweils das Benchmark für Doctrine2 und PDO rausgezogen und die Tests für meine Testzwecke erweitert. Anschließend habe ich ein neues Benchmark mit den nativen MySQL-Funktionen hinzugefügt. Das PDO- und das MySQL- Benchmark wurden als Active-Record implementiert.
Als Datenbank habe ich MySQL verwendet. Dort habe ich zwei Tabellen „Author“ und „Book“ mit einer 1:n Beziehung, Fremdschlüssel und Referenz-Optionen erstellt. Also ein Autor kann viele Bücher haben. Hier die Tabellendefinition und die SQL-Statements:
Author +-----------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +-----------+--------------+------+-----+---------+----------------+ | id | int(11) | NO | PRI | NULL | auto_increment | | firstName | varchar(128) | NO | | NULL | | | lastName | varchar(128) | NO | | NULL | | | email | varchar(128) | YES | | NULL | | +-----------+--------------+------+-----+---------+----------------+ Book +-----------+---------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +-----------+---------------+------+-----+---------+----------------+ | id | int(11) | NO | PRI | NULL | auto_increment | | author_id | int(11) | YES | MUL | NULL | | | title | varchar(255) | NO | | NULL | | | isbn | varchar(24) | NO | | NULL | | | price | decimal(10,0) | NO | | NULL | | +-----------+---------------+------+-----+---------+----------------+
Hier die CREATE TABLE Statements, die von Doctrine2 generiert werden.
CREATE TABLE `Author` ( `id` int(11) NOT NULL AUTO_INCREMENT, `firstName` varchar(128) NOT NULL, `lastName` varchar(128) NOT NULL, `email` varchar(128) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1701 DEFAULT CHARSET=utf8; CREATE TABLE `Book` ( `id` int(11) NOT NULL AUTO_INCREMENT, `author_id` int(11) DEFAULT NULL, `title` varchar(255) NOT NULL, `isbn` varchar(24) NOT NULL, `price` decimal(10,0) NOT NULL, PRIMARY KEY (`id`), KEY `IDX_6BD70C0FF675F31B` (`author_id`), CONSTRAINT `Book_ibfk_1` FOREIGN KEY (`author_id`) REFERENCES `Author` (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1701 DEFAULT CHARSET=utf8;
Danach habe ich folgende Testszenarien definiert und pro Benchmark entsprechend umgesetzt:
Test-Szenario 1:
Erstellen eines Modell-Objekts, Setzen und Speichern von Attributen. Hier wurde
die Model-Objekt-Geschwindigkeit und die INSERT-Statement-Generierung gemessen. Je 1700 Mal ausgeführt
Test-Szenario 2:
Sucht einen Eintrag anhand des Primärschlüssels. Hier wurden das grundlegende Abfrage-Prinzip und die Objekt-Hydratation gemessen. Je 1700 Mal ausgeführt.
Test-Szenario 3:
Sucht einen Datensatz mit einer komplexen Abfrage. Hier wurde die Objektabfrage-Geschwindigkeit gemessen. Je 1900 Mal ausgeführt.
Test-Szenario 4:
Liefert fünf Datensätze für ein einfaches Kriterium, etwa Book.price=???. Hier wurde die Hydratations Geschwindigkeit gemessen. Je 190 Mal ausgeführt.
Test-Szenario 5:
Liefert einen Datensatz zusammen mit dem dazugehörigen Hydrat-Datensatz aus seiner Referenz-Tabelle. Hier wurde die Geschwindigkeit beim JOIN-Abfragen gemessen. Je 700 Mal ausgeführt.
Jede Abfrage-Möglichkeit wurde isoliert durch die oben beschriebenen Test-Szenarien ausgeführt. Dabei habe ich versucht, einen Zustand wie im Echt-Betrieb zu simulieren.
Testergebnisse und harte Fakten
1 Durchlauf zum aufwärmen:
| insert | find | complex| hydrate| join | memory MB |--------|--------|--------|--------|--------|---------------- MySQL | 471 | 374 | 228 | 292 | 275 | 0.25559375 PDO | 495 | 369 | 220 | 312 | 276 | 0.30880859375 Doctrine2 | 293 | 1260 | 298 | 1478 | 883 | 6.73025390625 |--------|--------|--------|--------|--------|----------------
2 Durchlauf:
| insert | find | complex| hydrate| join | memory MB |--------|--------|--------|--------|--------|---------------- MySQL | 398 | 295 | 196 | 271 | 250 | 0.25559375 PDO | 454 | 336 | 205 | 285 | 269 | 0.30880859375 Doctrine2 | 271 | 1110 | 291 | 1324 | 3390 | 6.67366796875 |--------|--------|--------|--------|--------|----------------
3 Durchlauf:
| insert | find | complex| hydrate| join | memory MB |--------|--------|--------|--------|--------|---------------- MySQL | 411 | 304 | 202 | 275 | 244 | 0.25559375 PDO | 441 | 333 | 208 | 284 | 260 | 0.30880859375 Doctrine2 | 276 | 1158 | 297 | 1341 | 839 | 6.73586328125 |--------|--------|--------|--------|--------|----------------
Hier die jeweiligen SQL-Statements in abgekürzter Form, um sehen zu können, was die obigen Test-Probanden an SQL im Hintergrund erzeugen
SQL-Statements erstellt von MySQL während des Benchmarks
SQL-Statements erstellt von PDO während des Benchmarks
SQL-Statements erstellt von Doctrine2 während des Benchmarks
Erkenntnisse
Für die unten aufgeführten Erkenntnisse habe ich mich mehr auf Doctrine2 beschränkt.
INSERT-Statements
Alle INSERT-Statements werden immer gebündelt und gemeinsam in einer Transaktion ausgeführt. Man bekommt hierfür in etwa ein Gefühl dafür, wie die Geschwindigkeit sich bei einem Massen-Import verhält
Suche anhand des Primärschlüssels
Ohne es mit einen nativen PHP-MySql Abfrage-Art zu vergleichen, möchte ich behaupten, dass Doctrin2 langsamer ist. Hier haben wir jedoch ein ORM mit viel Abstraktion. Letztendlich ging das Abfragen mit DOctrine2 ganz bequem und das erwartete Reslut-Objekt wurde ausgeliefert. Doctrine2 bietet mehrere Möglichkeiten für das Ausliefern eines Abfrage-Ergebnisses: Als Modell-Objekt, als Array-Hydrat, als Scalar-Hydrat, als Modell-Objekt mit Verwendung des internen Array-Cache, als Modell-Objekt ohne Verwendung der internen Proxy-Klassen (zuständig für das Überladen) und als Iterator-Hydrat.
Suche anhand einer komplexen Abfrage
Hierfür wurde ein Statement mit Bedingungen WHERE+OR+CONCAT kombiniert verwendet. Die Suchergebnisse wurden ebenfalls schnell ausgeliefert. Dieselbe Abfrage mitunter Verwendung des internen Array-Cache war wie erwartet – schneller.
Auslieferung von Suchergebnissen als nicht Doctrine2-Model-Objekte
Wie oben im Text beschrieben, können die Suchergebnisse als Array oder Scalar-Object ausgeliefert werden. Diese können wiederum mit Verwendung vom internen Caching optional mitArray, Memory oder APC ausgeführt werden. Zudem hat man die Möglichkeit, das Ausliefern der Suchergebnisse ohne das interne Überladen (lazy-loading) durchzuführen. Diese Möglichkeit (also lazy-loading, überschreiben) sollte man jedoch auslassen, da sich die ausgelieferten Objektmodelle instabil zu ihren Entitäten verhalten können. Nichtsdestotrotz, alle drei Kombinationen haben sich als ähnlich schnell erwiesen, nur bei den JOIN-Statements war ein kleiner, jedoch unwesentlicher, Unterschied zu vermerken.
Caching und Cache-Validation
Doctrine2 bringt mehrere Caching Mechanismen: APC, Memcache, Xcache und ArrayCache. Es bietet die gängigen Caching Operationen. Das Finden und Löschen kann nach Angabe eines Suffixes, Präfixes, Regulären-Ausdrucks und Namespaces erfolgen. Es werden also viele Möglichkeiten geboten, nach bestimmten Kriterien eine Gruppe aus dem Cache zu identifizieren und oder sie zu löschen.
Hierfür habe ich einen ganz einfachen Test ausgeführt:
1. Gib mir das Modellobjekt aus dem Repository mit der id=1
2. Speichere diese in den Cache unter der cacheId=cache_id123 für 3sek.
3. Hole das Modell-Objekt aus dem Cache und vergleiche es mit dem Model-Oobjekt aus dem Repository.
4. Halte den Prozess für vier Sekunden an und versuche, das Objekt aus dem Cache zu laden.
Hier die Ausgabe in der Konsole:
---------- Repository before Cache ---------- Attribute: 1 color blue Attribute: 1 name dog Content: hallo wie geht es dir? ---------- Repository == Cache ------------- bool(false) ---------- Repository --------------------- Attribute: 1 color blue Attribute: 1 name dog Content: hallo wie geht es dir? ---------- Cache -------------------------- Attribute: 1 color blue Attribute: 1 name dog Content: hallo wie geht es dir? ---------- Sleep 4sec, get data Cache ------ bool(false)
Beide Objekte sind von derselben Instanz und haben denselben Inhalt, werden jedoch nicht als gleich identifiziert. Doctrin2 biete also das gängige Caching-Verhalten und sorgt nicht dafür, dass auch die Objekte im Cache konsistent zur Datenbank bleiben.
Weitere und detaillierte Information über den Cache gibt es hier: http://www.doctrine-project.org/docs/orm/2.0/en/reference/caching.html
Massen Inserts & Massen Object Processing
Doctrine2 unterstützt keine multi-INSERTs wie „(insert into (…) values (…), (…), (…),..“. Dafür werden die INSERT-Statements jeweils einzeln ausgeführt. Laut Doctrine2 soll das perfomanter sein. Zudem sind die Single-INSERT-Statements für Doctrine2 leichter in anderen DB-Systemen zu übertragen, als multi-INSERT-Statements. Für eine initiale Massen-Migration ist das Doctrine2 eher nicht gedacht. Jedoch bietet es eine Art von “Batch Processing”. Damit ist es möglich, die Massen-Migration von Daten in fest definierten Chargen abzuarbeiten bzw. in die Datenbank zu migrieren. Die Ausführungszeiten und der Speicherverbrauch sind mehr als akzeptabel. Wenn man oft mit sehr vielen Daten arbeiten muss, dann gibt es die Möglichkeit, sich die Daten als “Iterable-Result” ausliefern zu lassen und darüber zu iterieren. In diesem Fall ist das schneller, als wenn man sich die Daten als Hydrat-Objekt ausliefern lässt. Wem das aber immer noch nicht schnell genug ist, dem rät Doctrine2, interne Datenbank-Optionen für Massen-Importe zu verwenden. Wie bei MySQL “LOAD DATA INFILE”.
Mehr Information und einen ausführlichen Bericht hier:
-http://www.doctrine-project.org/blog/doctrine2-batch-processing
-http://www.doctrine-project.org/docs/orm/2.0/en/reference/batch-processing.html
Arbeiten mit nicht relationalen Datenbanken
Der Vorteil von NoSQL Datenbanken gegenüber MySQL oder jeder anderen RDBMS ist ganz klar die Performance. Für Doctrine2 gibt es PlungIns auf GitHub zum Herunterladen. Diese können laut Dokumentation auch alle gängigen Entitäten abbilden. Der Unterschied liegt in der Deklaration der Annotationen in den Modell-Klassen. Für ein Umsteigen von SQL auf NoSQL Datenbank ist also eine umfassende Anpassung an der Projekt-Konfiguration und der Modell-Klasse notwendig. Abfrage-Methoden wie find*(…) oder getRepository(…) müssen nicht angepasst werden.
Mehr Information:
– http://www.doctrine-project.org/docs/mongodb_odm/1.0/en/index.html
– http://www.doctrine-project.org/blog/mongodb-for-ecommerce
– http://www.doctrine-project.org/docs/couchdb_odm/1.0/en/index.html
– mongodb-odm https://github.com/doctrine/mongodb-odm
– couchdb-odm https://github.com/doctrine/couchdb-odm
Erstellen von Custom Mapping Types
In Doctrine2 ist es möglich, auch eigene Spalten-Typen zu definieren. Ich habe es mit dem Typ “email” getestet. Dafür muss im Projekt ein neues Verzeichnis “Types” erstellt werden. In der Konfigurationsdatei muss der neue Typ bekannt gemacht werden:
//... use Doctrine\DBAL\Types\Type; //... Type::addType('email', 'Types\Email'); //... $conn = $em->getConnection(); $conn->getDatabasePlatform()->registerDoctrineTypeMapping('email', 'email');
Und so sieht die Klasse des neuen email-Types aus. Eine einfache E-Mail-Validierung ist auch eingebaut.
namespace Types; use Doctrine\DBAL\Types\ConversionException, Doctrine\DBAL\Types\Type, Doctrine\DBAL\Platforms\AbstractPlatform; class Email extends Type { const MYTYPE = 'email'; /** @override */ public function convertToDatabaseValue($value, \Doctrine\DBAL\Platforms\AbstractPlatform $platform) { if (false === filter_var($value, FILTER_VALIDATE_EMAIL)) { throw ConversionException::conversionFailed($value, $this->getName()); } return $value; } /** @override */ public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) { return $platform->getVarcharTypeDeclarationSQL($fieldDeclaration); } /** @override */ public function getDefaultLength(AbstractPlatform $platform) { return $platform->getVarcharDefaultLength(); } public function getName() { return self::MYTYPE; } }
Nach dem Setup, wurde die Tabelle Author wie erartet ohne Probleme erweitert und gültige E-Mails konnten abgespeichert werden.
Alle Features in einer Tabelle
ORM | Prepared Statements | Transactions | Caching | Exceptions | Mass insert | Object Hydration | Scaffolding | Reverse Engineering | Drivers |
MySQL & Active Record | + | ++ | + | MySQL | |||||
PDO & Active Record | ++ | + | +- | ++ | + | ++ | +- | MySQL, Cubrid, FreeTDS, Firebird, DB2, Informix, Oracle, ODBC, PostgreSQL, SQLite, 4D, MS SQL | |
Doctrine2 | +++ | ++ | +++ | +++ | +++ | +++ | ++ | +++ | MySQL, Cubrid, FreeTDS, Firebird, DB2, Informix, Oracle, ODBC, PostgreSQL,SQLite, 4D, MS SQL, MongoDB, CouchDB |
Prepared Statements: bei der MySQL Lösung muss man leider selber Methoden schreiben, um Prepared Statements ausführen zu können. PDO und DOctrine2 bringen das von Haus aus mit.
Transactions: alle Test-Probanden unterstützen das. Wobei bei MySQL die Methoden erst geschrieben werden müssen. Lustig wird das bei Nested-Transactions. Doctrine2 kann das, für PDO und MySQL muss das implementiert werden.
Caching: bei dem MySQL- und PDO-Probanden muss das Caching implementiert werden, Doctrine2 hingegen bringt das mit von Haus aus mit.
Mass insert: das unterstützen alle drei Probanden.
Exceptions: bei MySQL muss man die Exceptions implementieren. PDO und Doctrine2 bringen diese von Haus aus mit.
Object Hydration: alle drei Probanden können ein Result-Set als Object zurückliefen. Doctrine2 kann jedoch noch mehr: als Array-Object, Iretable-Object und Model-Object.
Scaffolding: bei den MySQL- und PDO-Probanden muss das erst implementiert werden, Doctrine2 bringt das von Haus aus mit.
Reverse Engineering: nur der Doctrin2-Proband kann das. Ein paar mögliche Operationen wären: SQL-Datei in die Datenbank importieren, internes Caching Operationen, konvertieren von diversen Datenbank-Shema ins PHP, Model- und Mapper-Klassen aus der Datenbank generieren.
Drivers: bei MySQL ist es klar, nur MySQL Datenbank. PDO kann viele SQL basierende Datenbanksysteme unterstützen. Bei Doctrine2 ist es genauso wie bei PDO, da es auf PDO basisiert. Für Doctrine2 gibt es zusätzlich noch Driver als Add-ons, die MongoDB oder CouchDB unterstützen.
Gedanke
Wenn ein Team zum ersten mal mit Modellen/Objekten arbeiten möchte, dann sollte es sich zuerst mit dem Active-Record-Pattern gut vertraut machen. Ist auch leicht zum umsetzen, kontrollieren, erweitern, optimieren und debuggen. Ein typisches ORM hingegen basiert auf das Repository-Pattern. Dieses macht viel Magie unter der Haube. Ist daher nicht einfach zu kontrollieren und debuggen. Man macht sich von ein ORM abhängig. Mann muss das ORM auch nicht komplett verwenden. Es reicht oft auch, wenn man nur das manipulieren der Datenbank dem ORM überlässt. Bei diesem Punkt möchte ich es nicht verallgemeinern, den der genaue Einsatz eines ORM hängt von der Anforderung des Projektes und Teams ab.
Zu deiner Angabe über Cache Driver: Die Funktionen für DeleteBy* und Regex werden mit 2.2 alle entfernt, da Sie sehr ineffizient sind und auf alle Keys im Cache zugreifen und hier potentizell Cache-Slam Probleme auftreten.
Beim Caching von Objekten besteht insgesamt noch eine Menge Arbeit bei Doctrine, das ist vom Umfang aber so viel dass man hier erst mit 3.0 mit krassen Verbesserungen rechnen kann, also in 1-2 Jahren. Bis dahin sind einige kleine Verbesserungen geplannt.
Ich finde es etwas irritierend, ein ORM gegen Data Access Layer zu testen. Logischerweise kann ein PDO-basiertes ORM nicht schneller sein als PDO es ist und klar ist auch das Data Access Layer nicht die ORM-Features haben.
Aus der Tabelle entnehme ich auch, dass ext/mysql und nicht mysqli verwendet wurde. mysqli ist die MySQL extension in PHP, die alle MySQL features (prepared statements, trasaktionen, charsets, …) unterstützt.
Wenn Du Caching willst kannst du http://php.net/mysqlnd_qc ansehen. Das funktioniert transparent mit ext/msql, mysqli und pdo_mysql.
Ansonsten muss man halt klären ob man ein vollwertiges ORM will (wie doctrine2) oder eine halbgare selbstgebaute Lösung. Halbgar weil die doctrine2-Entwickler schon verdammt viel gelernt haben was gut und was schlecht ist und wie man das sinnig macht – eine eigene Lösung hängt da hinterher. Dafür kann die eigene Lösung spezialisierter sein und minimal schneller. Frage dann wieder: Erkauft man sich das minimal schneller sein durch höhere Entwicklungs- und Maintenance-Kosten?
Und warum wird PropelORM immer verschwiegen?
Wirklich Super! Gefaellt mir! Wo ist denn der Facebook-Like-Button?
@Hannes: ich würde sagen dass es eventuell daran liegt, dass Propel lange Zeit nicht weiter entwickelt wurde. Auf GitHub https://github.com/propelorm/Propel entwickelt man jedoch seit einiger Zeit weiter. Mir hat es damals gestört, dass Propel kein Identity Map integriert hatte. Die Objekte waren oft inkonsistent. Wie ist deine Erfahrung? Arbeitest du noch mit Propel?
on August 7, 2006of course, it isn’t *that* weird that PDO is slweor. PDO is, as far as I can see it, more of a complete database abstraction layer than just an interface to mysql. mysql and mysqli are just focussing on mysql connectivity.Thus, when writing an application that is 100% guaranteed to always use mysql, using one of those interfaces will work better than PDO. But when you need the flexibility of a database abstraction layer, PDO will make your code much more solid and portable.