Performance Tests #1 - DTOs
Geschwindigkeitstests und Analyse von Data Transfer Objects in PHP
Datum: 2021-04, Tags: php, dto, transfer, performance, testThe sources are available on gitlab: https://gitlab.com/jlue/php-performance-tests/-/tree/master/test-1-dto.
Update 2021-05-02: Es sind zwei Tests für Model-Klassen hinzugefügt und die Messungen erneuert worden.
In meinen letzten Projekten habe ich viel mit Datentransferobjekten, DTO oder auch nur "Transfers" gearbeitet. Das sind Klassen oder Objekte, die einfache
Datentypen oder andere Transferobjekte enthalten. Sie sind dazu gedacht, Daten zwischen verteilten Systemen oder Komponenten auszutauschen
und sollen möglichst einfach gehalten werden. DTOs wurden von Martin Fowler in
Patterns of Enterprise Application Architecture beschrieben.
Das Pattern wurde und wird oft fälschlich nicht für entfernte Schnittstellen benutzt, woraufhin der Author eine
Stellungnahme verfasst hat, in der er betont, dass die DTOs im Lokalen- oder
Domänen-Kontext nicht so gut geeignet sind wie für aufwendige und zeitintensive entfernte Schnittstellen, weil die Konvertierung der Daten sehr aufwendig
werden kann.
Diese DTOs werden gerne aus einfachen hierarchischen Strukturen automatisch generiert, z.B. aus XML-Dateien.
Wie performant sind derartige Objekte im Vergleich zu einfachen PHP Arrays? Sowohl DTOs als auch Arrays haben Vor- und Nachteile:
- DTOs sind objektorientiert, das fördert die Stabilität der Programmierung.
- Mit DTOs können unterschiedlich strukturierte Prozesse verbunden werden (single- vs. multi-threading, message queues).
- Arrays sind in PHP einfache Datentypen, die in PHP nicht per Referenz übergeben werden, sondern als Kopie.
- DTOs hingegen sind komplexe Objekte, die in PHP per Referenz übergeben werden.
- PHP kennt viele interne Methoden zur Array-Manipulation, die vermutlich alle sehr performant sind.
Die Übergabe von Objekten als Referenz in PHP wird hier beschrieben:
https://www.php.net/manual/de/language.references.pass.php.
Jedoch ist die Übergabe von Parametern als Referenz mit &
wie beschrieben seit PHP 5.4 veraltet und löst einen fatalen Fehler aus!
Die Übergabe von Arrays (stellvertretend für andere einfache Objekte) als Kopie wird hier beschrieben: https://www.php.net/manual/de/language.types.array.php
https://www.php.net/manual/en/language.types.array.phpArray assignment always involves value copying.
Das bedeutet, dass immer dann, wenn Arrays an eine Funktion übergeben werden, diese kopiert werden. Das kann bei großen Datenmengen durchaus
zu deutlichen Performance-Einbrüchen führen. Das will ich jedoch in Teil 2 dieser Performance Test Serie untersuchen. Hier soll es darum gehen,
Transfers mit Arrays als Übergabeformat im Punkto Geschwindigkeit zu vergleichen.
Für komplexe Objekte gilt,
dass sie an Methoden per Referenz übergeben werden. Dabei können sie als "Side Effect" innerhalb der verarbeitenden Methode manipuliert werden
und müssen nicht als Return Value zurückgegeben werden.
Test Szenario
Der Geschwindigkeitstest wird so aufgesetzt, dass zwei Klassen verwendet werden,
A
und B
. A
erzeugt im Konstruktor 10 Klassenvariablen mit dem Wert "sample".
Diese werden dann über eine export
Methode an Klasse B
übergeben, die dann mit diesen Werten ihre eigenen 10 Klassenvariablen über eine
import
Methode einzeln befüllt. Das soll einmal mit einfachen Arrays getestet werden und einmal mit Transfer Objekten.
Das Transfer Objekt basiert ebenfalls auf 10 Klassenvariablen, die mit Getter- und Setter-Methoden angesprochen werden. Zusätzlich
führt der Transfer noch eine Variable modifiedProperties
mit, um zu erkennen, ob sich eine der Variablen geändert hat.
So sieht das Transfer-Objekt prinzipiell aus. Der vollständige Sourcecode für diesen Test liegt auf gitlab.com: https://gitlab.com/jlue/php-performance-tests/-/tree/master/test-1-dto
<?php namespace DtoTest; class TestTransfer { /** * @var array */ protected $modifiedProperties = []; public const ONE = 'one'; /** * @var string|null */ protected $one; /** * @param string|null $one * * @return $this */ public function setOne($one) { $this->one = $one; $this->modifiedProperties[self::ONE] = true; return $this; } /** * @return string|null */ public function getOne() { return $this->one; } ...
Doch der Transfer soll später getestet werden, zunächst die Geschwindigkei einfacher Arrays.
Performance Test Array
Für den Test werden zwei Klassen erzeugt, siehe auch das sourcecode repository auf gitlab.com:
<?php namespace DtoTest; require_once 'ExportInterface.php'; class AArray implements ExportInterface { protected array $data; public function __construct() { $this->data = [ 'one' => 'sample', 'two' => 'sample', 'three' => 'sample', 'four' => 'sample', 'five' => 'sample', 'six' => 'sample', 'seven' => 'sample', 'eight' => 'sample', 'nine' => 'sample', 'ten' => 'sample', ]; } public function export() { return $this->data; } }
<?php namespace DtoTest; require_once 'ImportInterface.php'; class BArray implements ImportInterface { protected array $data; public function __construct() { $this->data = []; } public function import($data): void { $this->data['one'] = $data['one']; $this->data['two'] = $data['two']; $this->data['three'] = $data['three']; $this->data['four'] = $data['four']; $this->data['five'] = $data['five']; $this->data['six'] = $data['six']; $this->data['seven'] = $data['seven']; $this->data['eight'] = $data['eight']; $this->data['nine'] = $data['nine']; $this->data['ten'] = $data['ten']; } }
Der Testlauf wird über ein kleines PHP Script gestartet. Darin werden lediglich die Klassen geladen und innerhalb eines Timers und einer anzugebenen Anzahl von Iterationen die Klassen instanziiert, sowie die Zeit gemessen, in der der Ex- und Import abgearbeitet wird.
<?php namespace DtoTest; $iterations = intval($argv[1] ?? 0); $start = microtime(true); require_once 'AArray.php'; require_once 'BArray.php'; for ($i = 0; $i < $iterations; $i++) { $a = new AArray(); $b = new BArray(); $b->import($a->export()); } $end = microtime(true); echo sprintf("%s: %01.2f s\n", $iterations, ($end - $start));
Ich habe herausgefunden, dass ich auf meinem Rechner aussagekräftige Ergebnisse erhalte, wenn ich mit Iterationen zwischen 10.000 und 10.000.000 teste. Ich erhalte dann folgende Zeiten:
host:~/php-performance-tests/test-1-dtofor i in 10000 100000 1000000 5000000 10000000 ; do php index-Array.php $i ; done 10000: 0.03 s 100000: 0.27 s 1000000: 2.61 s 5000000: 19.29 s 10000000: 40.62 s
Performance Test DTO
Für den DTO Test werden die Klassen leicht umgeschrieben. Das Test-Script instanziiert dann die Klassen ATransfer
und BTransfer
, macht aber
ansonsten das Gleiche:
<?php namespace DtoTest; require_once 'ExportInterface.php'; require_once 'TestTransfer.php'; class ATransfer implements ExportInterface { protected TestTransfer $data; public function __construct() { $this->data = new TestTransfer(); $this->data ->setOne('sample') ->setTwo('sample') ->setThree('sample') ->setFour('sample') ->setFive('sample') ->setSix('sample') ->setSeven('sample') ->setEight('sample') ->setNine('sample') ->setTen('sample'); } public function export() { return $this->data; } }
<?php namespace DtoTest; require_once 'ImportInterface.php'; require_once 'TestTransfer.php'; class BTransfer implements ImportInterface { protected TestTransfer $data; public function __construct() { $this->data = new TestTransfer(); } /** * @param TestTransfer $data */ public function import($data): void { $this->data ->setOne($data->getOne()) ->setTwo($data->getTwo()) ->setThree($data->getThree()) ->setFour($data->getFour()) ->setFive($data->getFive()) ->setSix($data->getSix()) ->setSeven($data->getSeven()) ->setEight($data->getEight()) ->setNine($data->getNine()) ->setTen($data->getTen()); } }
Der Test mit den gleichen Iterationen ergibt dann folgende Zeiten:
host:~/php-performance-tests/test-1-dtofor i in 10000 100000 1000000 5000000 10000000 ; do php index-Transfer.php $i ; done 10000: 0.16 s 100000: 1.56 s 1000000: 22.28 s 5000000: 117.71 s 10000000: 238.35 s
Das ist ein unerwartet deutlicher Unterschied! Die gemessenen Zeiten sind die bei der Verwendung des DTOs mehr als fünf mal so hoch, wie bei der Verwendung einfacher Arrays. Vor einer Auswertung komme, werden die Testfälle noch um vier Variationen erweitert.
Json
Für die Übergabe wird beim Export das PHP Array nach Json serialisiert sowie beim Import wieder deserialisiert. Das ist für das Test-Szenario nicht notwendig und erzeugt eigentlich nur Overhead. DTOs sind jedoch wie schon erwähnt für weit verteilte Systeme vorgesehen, in denen ein Datenaustausch in dieser oder ähnlicher Form erforderlich ist.
ArrayObject
Anstelle eines Arrays werden sowohl beim Export
als auch beim Import
ein ArrayObject verwendet. Wie anfangs beschrieben wird das ArrayObject per Referenz übergeben, nicht als Kopie, wie
bei dem Array. In dem Test gibt es jedoch nur einen Punkt pro Durchlauf, bei dem das zum Tragen kommt, nämlich bei der Import-Methode
import(...)
. Ich erwarte daher für diesen Test nicht so große Performance-Vorteile. In meiner geplanten zweiten Folge der PHP Performance Tests
werde ich jedoch genau diesen Vorteil einmal anders testen.
Transfer 2
Der Transfer hat zwei Methoden,
mit denen er "am Stück" in Arrays konvertiert und aus Arrays erzeugt werden kann. Die Methoden heißen
fromArray
und toArray
. Diese Methoden werden
beim Import benutzt,
um nicht jeden Wert einzeln, sondern alle zusammen zu konvertieren. Ich erwarte
jedoch auch hier keine besonders auffälligen Performance-Vorteile, weil strukturell genau des Gleiche passiert.
Transfer 3
Für den Test wird der TestTransfer
so umgeschrieben,
dass die Variable modifiedProperties
anstelle eines Arrays ein ArrayObject ist.
Davon erhoffe ich mir einen Vorteil, da sie in jeder Setter-Methode des Transfers benutzt wird. Jedoch nicht als Funktions-Parameter,
was den Vorteil der Übergabe als Referenz
mit sich bringen würde, sondern als Klassenvariable. Daher ... dürfte der Vorteil nun auch wieder nicht so deutlich ausfallen, wenn überhaupt einer erkennbar ist.
Model 1
Anstelle eines Transferobjekts, dass die Variablen in Arrays hält, wird das DTO so umgeschrieben, dass direkt öffentliche Klassenvariablen benutzt werden. Ich vermute, dass das ein sehr schlankes und effizientes Transferobjekt ergibt. Allerdings kann es so nicht über verteilte Systeme übertragen werden, dafür muss es noch einmal serialisiert werden.
Model 2
Das Model-DTO belibt wie in dem vorigen Test erhalten, jeodch werden der export und der import so umgeschrieben, dass die Nutzlast nach json serialisiert und desearialisiert werden. Das wird vermutlich etwas performance kosten, würde sich aber wie gesagt bei verteilten Systemen nicht vermeiden lassen.
Auswertung
Ich habe die Zeitauswertungen ein paar mal laufen lassen und festgestellt, dass auf meinem Rechner kaum Abweichungen in den Zeitmessungen entstehen, wenn man sie öfter laufen lässt. Natürlich sind die gemessenen Zeiten abhängig von der Kapazität und Auslastung des Testrechners im Allgemeinen, aber werden wahrscheinlich relativ zueinander die gleichen Ergebnisse hervorbringen. Hier die Messungen:
Iterationen \ Sekunden | Transfer | Array | Json | ArrayObject | Transfer 2 | Transfer 3 | Model 1 | Model 2 |
---|---|---|---|---|---|---|---|---|
10000 | 0,21 | 0,03 | 0,06 | 0,05 | 0,16 | 0,18 | 0,04 | 0,08 |
100000 | 2,13 | 0,35 | 0,57 | 0,51 | 1,57 | 1,83 | 0,38 | 0,79 |
1000000 | 22,82 | 3,46 | 5,54 | 5,05 | 16,03 | 17,86 | 3,93 | 8,11 |
5000000 | 107,59 | 17,05 | 28,57 | 24,84 | 77,61 | 91,30 | 18,95 | 40,71 |
10000000 | 233,49 | 36,10 | 55,61 | 51,84 | 156,44 | 181,09 | 40,59 | 78,87 |
Oder anders dargestellt als Geschwindigkeit "Transfers pro Sekunde" (Ungenauigkeiten aufgrund von Rundungen bei kleinen Werten):
Es wird deutlich:
- Transfer: Die Nutzung von Array-basierten Data Transfer Objects ist deutlich langsamer als die Nutzung von einfachen Arrays.
- Array: Die Nutzung von reinen PHP Arrays ist am schnellsten, teilweise 8-mal so schnell wie bei Nutzung von DTOs.
- Json: Auch das bringt für den Testfall im Vergleich zur Nutzung von Arrays eine Verschlechterung, das war auch zu erwarten. Jedoch fallen die Zeitmessungen immer noch deutlich besser aus als alle Messungen für DTOs.
- ArrayObject: Das bringt für den Testfall wie erwartet nichts, eher eine Verschlechterung.
- Transfer 2: Die Umstellung auf die "fromArray" und "toArray" Methoden bringt einen deutlichen Geschwindigkeitsvorteil.
- Transfer 3: Die Nutzung von ArrayObject für die "modifiedProperties" in Verbindung mit der Nutzung von "fromArray" und "toArray" wirkt sich sogar negativ auf die Performance aus.
- Model 1: Das ist wie erwartet sehr performant, fast so performant wie direkt Arrays.
- Model 2: Das Ergebnis zeigt einen deutlichen Performanceverlust, auch im Verhältnis zu dem Json Test.
Abschließende Betrachtung
Ich habe den reinen Export für sowohl den Array- als auch den DTO-Test ausprobiert, um herauszufinden, welchen Anteil der Export allein (inklusive des Konstruktors) an der Zeit für den jeweiligen Test hat. Dazu habe ich die Test-Scripts abgeändert und den Import herausgenommen:
... $b->import($a->export()); ...
auf:
... $a->export(); ...
Ich habe folgende durchschnittliche Anteile ermittelt:
Transfer | Array | Json | ArrayObject | Transfer 2 | Transfer 3 | Model 1 | Model 2 | |
---|---|---|---|---|---|---|---|---|
Anteil Export | 41 % | 53 % | 46 % | 59 % | 50 % | 55 % | 66 % | 43 % |
Anteil Import | 59 % | 47 % | 54 % | 41 % | 50 % | 45 % | 34 % | 57 % |
Zusammenfassung
In diesem Test resultiert die Verwendung von Array-basierten DTOs in einer sehr deutlich schlechteren Geschwindigkeit beim Datenaustausch zwischen den beteiligten Komponenten. Bei Objekt-DTOs mit einfachen Klassenvariablen lassen sich ähnliche Geschwindigkeiten feststellen, wie wenn direkt Arrays benutzt werden, jedoch mit dem Vorteil der Objektorientierung. Für die Übertragung zwischen verteilten Systemen müssen die Daten immer angemessen serialisiert werden.
Es gibt jedoch noch weitere Aspekte, die man im Zusammenhang mit diesem Test beachten sollte. Am Wichtigsten vielleicht, dass dieser Test einen sehr vereinfachten Anwendungsfall darstellt. Transferobjekte werden in der Praxis eher über viele Instanzen verarbeitet, was bei der Nutzung von einfachen Arrays wieder eher schlechtere Ergebnisse erzielen wird, insbesondere bei großen Datenmengen. In diese Richtung zu testen ist einen eigenen Artikel wert.
Stark verschachtelte Transfer Objekte sind hier ebenfalls nicht berücksichtigt. Vermutlich würde dabei die Performance der DTOs noch deutlicher sinken.
Auch Code Style, Design Prinzipien oder Ähnliches können gute Gründe sein, sich für den einen oder anderen Ansatz zu entscheiden. Also wahrscheinlich in der Regel eher für den einen - die DTOs. Teilweise spielt jedoch auch Komplexität eine vorrangige Rolle, welcher Ansatz auch immer dann präferiert wird.
Als Nächstes folgt ein Performance Vergleich von Arrays zu ArrayObjects.
Schickt Kommentare oder Anmerkungen bitte über Twitter an @limitland oder @jlue.