ZURÜCK

Performance Tests #1 - DTOs

Geschwindigkeitstests und Analyse von Data Transfer Objects in PHP

Datum: 2021-04, Tags: php, dto, transfer, performance, test

The 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:

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.php
Array 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

TestTransfer.php
<?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:

AArray.php
<?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;
    }
}
BArray.php
<?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.

index-Array.php
<?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:

ATransfer.php
<?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;
    }
}
BTransfer.php
<?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:

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:

index-Array.php
...
$b->import($a->export());
...

auf:

index-Array.php
...
$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.