Schreiben und Lesen in PHP

Schreiben und Lesen in PHP: Nicht die gleichen Modelle verwenden

vg

Beim Schreiben und Lesen in PHP sind Modelle ein wertvolles Werkzeug, um mit einem Datenspeicher wie einer Datenbank zu kommunizieren. Sie ermöglichen es, die Datenstruktur klar zu definieren und stellen sicher, dass die Daten konsistent verarbeitet werden. Während ein Modell häufig zum Validieren und Speichern von Eingaben dient, ist es verlockend, es auch für das Auslesen von Daten zu verwenden. Doch gerade diese Praxis kann in PHP-Anwendungen zu Sicherheitsrisiken, unnötigen Validierungen und Performanceproblemen führen. In diesem Beitrag zeigen wir, warum es sinnvoll ist, für das Schreiben und Lesen in PHP unterschiedliche Modelle zu verwenden – und wie du es besser machst.

Einrichten eines Modells, mit dem gearbeitet werden soll

Nehmen wir ein einfaches Benutzermodell und die Schnittstelle eines Repositorys, wir brauchen die Details hier nicht wirklich. Nehmen wir jedoch an, wir haben eine Assertionsbibliothek, die wir verwenden, um zu überprüfen, ob jedes erstellte Modell gültig ist.

class User
{
public function __construct(
public string $email,
public string $name,
public string $password,
) {
Assert::email($email);
Assert::notEmpty($name);
Assert::password($password, strength: 3);
}
}

interface UserRepository
{
public function save(User $user): void;
}

Der Hauptanwendungsfall ist also, wir erhalten Daten für einen neuen Benutzer, es wird überprüft, ob der Name nicht leer ist, dass die E-Mail eine gültige E-Mail ist und dass das Passwort dem entspricht, was wir als Stärkestufe 3 definiert haben. Dann senden wir es an das Repository und speichern es. Job erledigt.

$user = new User(
$request->get('email'),
$request->get('name'),
$request->get('password'),
);

$repository->save($user);

Problem: Modelleigenschaften, die nicht gelesen werden sollen

Jetzt möchten wir also einen Benutzer per E-Mail aus der Datenbank lesen, um eine JSON-Darstellung davon zurückzugeben, damit ein Client ein Benutzerprofil präsentieren kann. Was passiert, wenn wir unserem Repository eine read-Methode hinzufügen, die dasselbe Modell wiederverwendet?

interface UserRepository
{
public function save(User $user): void;
public function get(string $email): User;
}

// Inside some controller class
return new Response(
json_encode(
$repository->get($request->get('email'))
),
);

Also, was bekommen wir hier?

{
  "email": "klaus@noreturn.com",
  "name": "Klaus Mustermann",
  "password": "$2y$10$OEaTphGkW0HQv4QNxtptQOE.BSQDnpwrB.\/VGqIgjMEhvIr22jnFK"
}

Das erste, was uns in den Sinn kommen sollte, wenn wir uns das ansehen, ist, dass Passwörter, auch verschlüsselte, niemals in irgendeiner Art von Kommunikation vom Server gesendet werden sollten. Dies ist also ein wichtiges Sicherheitsproblem.

Auch wenn dies wahrscheinlich der schlimmstmögliche Fall eines Informationslecks ist, das durch die Verwendung eines Schreibmodells als Lesemodell verursacht wird, ist es nicht der einzige. Ein weiteres häufiges Problem besteht darin, irrelevante Informationen an den Client zu senden. Zum Beispiel könnten wir einen booleschen Wert haben, den wir zum Aktivieren oder Deaktivieren von Benutzern verwenden können, der für den Client nutzlos wäre, denn wenn der Benutzer nicht aktiv ist, antwortet die Anfrage mit einem . Irrelevante Daten bedeuten, dass wir Bytes senden, die nie verbraucht werden, was die Leistung beeinträchtigt. Es mag wenig sein, aber alles fügt sich zusammen und es gibt eine einfache Lösung.active404 Not Found

Was machen wir also? Stellen Sie eine Rückgabe mit einer eingeschränkten Liste von Daten bereit? Dies könnte diese Probleme lösen.

class User
{
// ...

public function read(): array
{
return [
'email' => $this->email,
'name' => $this->name,
];
}
}

Aber es gibt noch mehr Probleme zu lösen, mal sehen.

Problem: Unnötige Validierungen

Apropos Leistung: Wir haben Validierungen im Modellkonstruktor, aber sind diese erforderlich, wenn wir Daten abrufen, die sich bereits in der Datenbank befinden? Sie müssen in dem Moment, in dem sie gespeichert wurden, gültig gewesen sein, so dass argumentiert werden kann, dass das erneute Ausführen dieser Validierungen eine Verschwendung ist.

Aber es ist nicht nur eine Verschwendung, sondern kann ein echtes Problem sein. Validierungen können sich weiterentwickeln, was sich auf die Fähigkeit auswirken kann, Ergebnisse abzurufen, wenn wir ein Schreibmodell verwenden, das Validierungen verwendet. Angenommen, eine Anwendung validiert, dass E-Mails für Benutzer ein gültiges E-Mail-Format haben, aber irgendwann wird eine andere Regel hinzugefügt, um einige Domänen in E-Mail-Adressen auf die schwarze Liste zu setzen. Die Validierung wird aktualisiert, aber die vorhandenen Benutzer können nicht wirklich aktualisiert werden, da sie weiterhin Mitteilungen über diese E-Mail-Adresse erwarten.

Jetzt erhalten wir eine Anfrage für eine Liste von 100 Benutzern, in der einer von ihnen eine Domain auf der schwarzen Liste hat, was passiert? Die gesamte Anforderung wird als Fehler betrachtet. Und was senden wir dem Benutzer? Eine Antwort, als ob eine Benutzereingabe falsch war? Das ist nicht die Schuld des Clients, sondern die des Servers. In diesem Fall sollte es sich um eine Art Fehler handeln.400 Bad Request500

Um dies zu vermeiden, habe ich einige komplexe Lösungen gesehen, bei denen Reflection und eine Instanz ohne Konstruktor verwendet wurden. Wenn wir das Schreibmodell wirklich in Fällen verwenden müssten, die wir nicht validieren möchten, würde ich die Assertions jedoch in einen statischen Konstruktor verschieben, wie hier.

class User
{
public function __construct(
public string $email,
public string $name,
public string $password,
) {}

public static function create(string $email, string $name, string $password): self
{
Assert::email($email);
Assert::notEmpty($name);
Assert::password($password, strength: 3);

return new self($email, $name, $password);
}
}

Auf diese Weise kann ich beim Erstellen eines neuen Modells, das eine Validierung erfordert, tun und den Konstruktor verwenden, wenn ich Daten aus der Datenbank abrufe. Behebt einige Probleme, aber es gibt noch mehr.User::new()

Problem: Hinzufügen zusätzlicher Daten zum Modell

Eine weitere häufige Situation ist, dass der Client mehr Daten für die Ansicht benötigt. In unserem Beispiel muss die Ansicht möglicherweise die Anzahl der Kommentare anzeigen, die ein Benutzer im System erstellt hat. Das ist nicht Teil des Modells, aber es sieht verschwenderisch aus, dies nicht in derselben HTTP-Antwort hinzuzufügen und den Client auf eine zweite warten zu lassen, nur weil die Daten nicht mit dem Schreibmodell übereinstimmen.

Selbst wenn wir versuchen, die Daten in derselben Anforderung hinzuzufügen, bedeutet das Festhalten an diesem Schreibmodell, dass wir nicht eine einzige Datenbankanforderung verwenden können, um den gesamten Datensatz abzurufen, obwohl dies in vielen Fällen mit einem einfachen SQL-Join gelöst werden könnte. Stattdessen rufen wir das Schreibmodell ab und führen dann eine weitere Datenbankanforderung durch, um die fehlenden Daten abzurufen und sie zusammenzustellen, bevor wir sie an den Client senden.

return new Response(
json_encode(
array_merge(
$repository->get($request->get('email')),
['comments' => $commentRepository->count($request->get('email'))]
)

),
);

Es funktioniert, aber es bedeutet eine zusätzliche Datenbankabfrage, was sich auf die Leistung auswirkt. Und es schadet auch der Wiederverwendbarkeit, da Sie das Repository nicht einfach woanders aufrufen können, sondern auch den Kommentarteil kopieren und einfügen müssen.

Problem: Sind Einfügungen und Aktualisierungen wirklich identisch?

Für ein letztes Problem ist dies nicht wirklich ein Schreib- vs. Lesemodell, aber wenn wir ein Modell aktualisieren, können wir wirklich dieselbe Klasse verwenden, die wir beim Erstellen verwenden?

Wenn wir also einen neuen Benutzer mit diesem Modell erstellen, erwarten wir Name, E-Mail und Passwort. Für das Erstellen eines Benutzers ist das in Ordnung, aber in unserem Beispiel erfordert unser Sicherheitsexperte, dass Passwörter auf eine bestimmte Weise aktualisiert werden, was bedeutet, dass der Benutzer eine Passwortänderung anfordert, eine E-Mail mit einem zeitlich begrenzten Token an den Benutzer gesendet wird und dann dieses Token validiert, um das neue Passwort zu akzeptieren.

Das Passwort sollte niemals auf andere Weise aktualisiert werden, was tun wir also, wenn wir das gleiche Modell verwenden, das wir bereits für die Aktualisierung des Benutzers haben? Wir werden zwei verschiedene Stellen im Code haben, an denen wir den Benutzer aktualisieren, eine für das Passwort, eine andere für alles andere.

interface UserRepository
{
public function save(User $user): void;
public function update(User $user): void;
}

// Updating name
$user = new User(
$request->get('email'),
$request->get('name'),
'WHAT DO WE DO WITH PASSWORD HERE?',
);

$repository->update($user);

// Updating password
$user = new User(
$request->get('email'),
'WHAT DO WE DO WITH NAME HERE?',
$request->get('password'),
);

$repository->update($user);

Nun haben wir es mit Daten im Modell zu tun, die nicht verarbeitet werden müssen, was unsere Repository-Implementierung unnötig komplexer macht. Außerdem wird die Modellerstellung gezwungen, Daten bereitzustellen, die nicht verfügbar sind und nicht verwendet werden, wodurch der Code viel schwieriger zu verstehen ist. Und schließlich führen wir eine fragile Implementierung ein, die, wenn sie falsch verwendet wird, dazu führen kann, dass etwas aktualisiert wird, das nicht aktualisiert werden sollte, nur weil es sich im Modell befindet. Wenn wir die Änderung des Benutzernamens so verarbeiten, dass eine Passwortaktualisierung ausgelöst wird, ist das ein ernsthaftes Problem.

Lösung: Individuelles Modell für jeden Fall

Wie können wir all die Probleme lösen, die beim Lesen eines Benutzers auftreten? Ein dediziertes Modell reicht aus.

final readonly class UserRead
{
public function __construct(
public string $email,
public string $name,
public int $commentCount,
) {}
}

Wir können ein anderes Repository haben, um es abzurufen.

interface UserReadRepository
{
public function get(string $email): UserRead;
}

Diese Implementierung, die von einer relationalen SQL-Datenbank ausgeht, würde das Kennwort nicht aus der Tabelle auswählen, die sich nicht im Lesemodell befindet, wodurch Problem Nummer 1 gelöst wird. Dieses Lesemodell enthält keine Validierungen zur Lösung von Problem Nummer 2. Und dieses Modell hat einen Platz für die Anzahl der Kommentare, der im neuen Repository implementiert werden kann, indem ein Join in einer einzigen Abfrage verwendet wird, wodurch Problem Nummer 3 gelöst wird.

Mehr noch, wenn wir mehr Darstellungen eines Benutzers haben, sollten wir ein anderes Lesemodell haben, um jeden einzelnen abzudecken. Wir könnten zum Beispiel ein haben.UserWithLastCommentsRead

Und was ist mit den Update-Problemen? Sie haben es wahrscheinlich erraten. Individuelle Modelle für jedes Update.

final readonly class UserDataUpdate
{
public function __construct(
public string $email,
public string $name,
) {
Assert::notEmpty($name);
}
}

final readonly class UserPasswordUpdate
{
public function __construct(
public string $email,
public string $password,
) {
Assert::password($password, strength: 3);
}
}

interface UserRepository
{
public function save(User $user): void;
public function updateData(UserDataUpdate $userDataUpdate): void;
public function updatePassword(UserPasswordUpdate $userPasswordUpdate): void;
}

Jetzt gibt es keine Fehler oder unnötigen Daten mehr. Jedes Update ist isoliert und viel besser vor Fehlern geschützt.

Beachten Sie, dass ich in den Updatemodellen die E-Mail-Validierung nicht hinzugefügt habe. Das ist beabsichtigt, weil es verwendet wird, um den Benutzer zu finden, und wenn wir eine weiterentwickelte Validierung haben, wie zuvor kommentiert, könnten wir ältere Benutzer mit E-Mails, die nicht mehr gültig sind, aber trotzdem noch in der Datenbank sind, nicht mehr gefunden werden.

Fazit

Das ist wirklich nicht so anders, da wir Objekte in der realen Welt modellieren. Wir betrachten nie alles an einem realen Objekt in einem bestimmten Kontext. Zum Beispiel ein Auto.

Wenn ein Auto von einem Fahrer modelliert wird, können wir davon ausgehen, dass die Positionierung des Sitzes und der Rückspiegel sehr wichtig ist, während sie gleichzeitig für einen Mechaniker, der einige Wartungsarbeiten durchführt, irrelevant ist. Der Mechaniker wird sich wahrscheinlich mehr Gedanken über Metriken am Motor machen, die für den Fahrer nicht wichtig sind. Und ein Kind in der Schule, das etwas über Transportmethoden lernt, wird sich wahrscheinlich nur dafür interessieren, dass es sich um ein Landtransportmittel mit 4 Rädern handelt.

Wenn wir unterschiedliche Modelle für dieselben realen Objekte verwenden, können wir das Gleiche definitiv auch für unsere Codemodelle tun.

Weiterer Beitrag für Sie: Kann Mojo Python wirklich ersetzen?

com

Newsletter Anmeldung

Bleiben Sie informiert! Wir informieren Sie über alle neuen Beiträge (max. 1 Mail pro Woche – versprochen)