Aufbauend auf dem Artikel zum Thema CRUD in PHP und MySQL mit PDO erstellen möchte ich hier noch ein wenig mehr auf die Strukturierung eines solchen Projekts eingehen.
Ich verwende dabei die Idee des Repository-Patterns unter der Verwendung einzelner Model-Klassen für jede Tabelle.
Als Beispiele verwende ich hier erneut Tabellen aus meiner Kochbuch-Anwendung. Hierbei werden Rezepte (Recipes) und Zutaten (Ingredients) verwaltet.
Model
Zunächst erstellen wir eine Modelklasse zu jeder unserer Tabellen. Haben wir z.B. eine Ingredient-Tabelle mit den Spalten Name, Anzahl, Einheit, Kommentar und Rezept-ID, sieht die Klasse dazu folgendermaßen aus:
1 2 3 4 5 6 7 8 9 10 11 |
<?php namespace model; class Ingredient { private $Id; private $Name; private $Amount; private $Entity; private $Comment; private $RecipeId; [...] |
Zu jedem Feld der Klasse gibt es nun noch passende Getter und Setter.
Hier die Getter und Setter am Beispiel des Felds Name:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public function getName() { return $this->Name; } /** * Set the value of [name] column. * * @param string $Name new value * * @return Ingredient The current object (for fluent API support) */ public function setName($Name) { if ($Name !== null && is_numeric($Name)) { $Name = (string)$Name; } if ($this->Name !== $Name) { $this->Name = $Name; } return $this; } |
Beim Setter wird der übergebene Parameter noch auf den passenden Typ geprüft und ggf. umgewandelt.
Das Model kann derüber hinaus noch weitere hilfreiche Methoden enthalten. Etwa eine Methode clear(), welche alle Werte auf null setzt oder Umwandlungsmethoden wie toArray():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
/** * Exports the object as an array. * * @return array an associative array containing the field names (as keys) and field values */ public function toArray() { $result = array( 'Id' => $this->getId(), 'Name' => $this->getName(), 'Amount' => $this->getAmount(), 'Entity' => $this->getEntity(), 'Comment' => $this->getComment(), 'RecipeId' => $this->getRecipeId() ); return $result; } |
Auch sind Factory-Methoden denkbar:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
/** * Creates the object from an array. * * @param $arr array an associative array containing the field names (as keys) and field values * * @return Ingredient the created Ingredient object */ public static function fromArray($arr) { $ingredient = new Ingredient(); if (isset($arr['Id'])) { $ingredient->setId($arr['Id']); } if (isset($arr['Name'])) { $ingredient->setName($arr['Name']); } if (isset($arr['Amount'])) { $ingredient->setAmount($arr['Amount']); } if (isset($arr['Entity'])) { $ingredient->setEntity($arr['Entity']); } if (isset($arr['Comment'])) { $ingredient->setComment($arr['Comment']); } if (isset($arr['RecipeId'])) { $ingredient->setRecipeId($arr['RecipeId']); } return $ingredient; } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/** * Creates the object from an JSON string. * * @param $jsonString string a JSON string containing the field names (as keys) and field values * * @return Ingredient the object containing the specified values */ public static function fromJson($jsonString) { $array = json_decode($jsonString, true); $model = self::fromArray($array); return $model; } |
Das Model dient allerdings nur dazu, die Daten aus der Datenbanktabelle zu tragen. Es enthält keinerlei Businesslogik oder -funktionen.
Repository
Das Repository ist dafür zuständig, die Daten aus der Datenbank zu lesen oder in ihr zu schreiben, bzw. zu aktualisieren. Dabei arbeitet es entweder mit einfachen Werten (Strings, Integer, …) oder eben mit unseren Model-Klassen.
Die Methoden der Repository-Klassen sollten einem einheitlichen Muster folgen. So sollten alle Methoden, die einen Datensatz aus der Tabelle auslesen find…() heißen, alle die einen Datensatz löschen delete…(), alle die einen Datensatz aktualisieren update…() und das neu Anlegen sollte create() oder save() heißen. Wenn die Methoden in allen verwendeten Repositories einheitlich benannt sind, fällt uns die Verwendung hinterher leichter.
Das Repository für unser Ingredient-Model etwa könnte die folgenden Methoden haben:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
<?php namespace repository; use model\Ingredient; use PDO; class IngredientRepository { /** * Retrieves the ingredients from the database by name. * * @param PDO $con connection to the database * @param string $name The name of the Ingredient * * @return Ingredient The Ingredient found. */ public function findIngredientsByName(PDO $con, $name) { [...] } /** * Retrieves the ingredients from the database by id of the corresponding recipe. * * @param PDO $con connection to the database * @param int $recipeId The id of the recipe the ingredients should be returned for. * * @return array The ingredients found. */ public function findIngredientsByRecipeId(PDO $con, $recipeId) { [...] } /** * Retrieves all ingredients from the database. * * @param PDO $con connection to the database * * @return array The ingredients found. */ public function findAllIngredients(PDO $con) { [...] } /** * Saves the given Ingredient into the database. * When a id is specified, the ingredient is updated. It is created otherwise with a new id. * * @param PDO $con connection to the database * @param Ingredient $ingredient the ingredient to save. * * @return Ingredient The saved ingredient. Contains generated id when created. */ public function save(PDO $con, Ingredient $ingredient) { [...] } /** * Creates the Ingredient in the database. * * @param PDO $con connection to the database * @param Ingredient $ingredient The Ingredient to save. */ private function createIngredient(PDO $con, Ingredient $ingredient) { [...] } /** * Updates the Ingredient in the database. * * @param PDO $con connection to the database * @param Ingredient $ingredient The Ingredient to update. */ private function updateIngredient(PDO $con, Ingredient $ingredient) { [...] } /** * Deletes the Ingredient in the database. * * @param PDO $con connection to the database * @param int $id The name of the Ingredient to delete. */ public function deleteIngredient(PDO $con, $id) { [...] } } |
Ich verwende hier als Schnittstelle save() zum Erstellen oder Aktualisieren eines Datensatzes. Der Aufrufer muss sich dann nicht darum kümmern, ob der Datensatz nun neu ist und create() aufrufen muss oder ob er eine Aktualisierung durch update() anzustoßen hat.
Die save()-Methode sieht dabei folgendermaßen aus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
/** * Saves the given Ingredient into the database. * When a id is specified, the ingredient is updated. It is created otherwise with a new id. * * @param PDO $con connection to the database * @param Ingredient $ingredient the ingredient to save. * * @return Ingredient The saved ingredient. Contains generated id when created. */ public function save(PDO $con, Ingredient $ingredient) { $id = $ingredient->getId(); if (!isset($id) || $id == 0) { $id = $this->generateId($con); $ingredient->setId($id); $this->createIngredient($con, $ingredient); } else { $this->updateIngredient($con, $ingredient); } return $ingredient; } |
Die beiden internen Methoden createIngredient() und updateIngredient() sind private und von außen somit nicht erreichbar.
Verwaltung des PDO-Objekts
Wie wir ein PDO-Objekt für den Zugriff auf die Datenbank erstellen, habeich im Artikel CRUD in PHP und MySQL mit PDO erstellen bereits beschrieben.
Im oben vorgestellten Repository wird das PDO-Objekt als Parameter übergeben. Das ist auch gut so, dann kann es in Unit-Tests einfach durch eine Mock- oder Stub-Implementierung ersetzt werden (wem das jetzt nichts sagt, der sollte das mal auf unserem Blog zum Thema Code-Qualität nachlesen).
Die Frage ist aber, wie der Aufrufer (ein Controller oder besser ein Service) das PDO-Objekt bekommt.
Dafür gibt es zwei Lösungen
- Wir erstellen das PDO-Objekt zentral in unserer index.php und überall dort, wo wir es benötigen, definieren wir es als global.
Hier ein Beispiel:
12345678try{$con = new PDO('mysql:host=localhost;dbname=datenbankname', 'benutzer', 'passwort');}catch(PDOException $e){// Fehlerbehandlung}
123456789public static function saveIngredient($ingredientJSON){global $con;$ingredientRepository = new IngredientRepository();$ingredient = Ingredient::fromJson($ingredientJSON);$ingredientRepository->save($con, $ingredient);[...] - Wir erstellen eine eigene Klasse, z.B. Database. Diese hat eine statische Methode connect(), die die Verbindung herstellt, falls nock keine bestehen sollte. Hier der Code dazu:
123456789101112131415161718192021222324252627282930313233343536373839404142434445<?php namespace repository;use PDO;use PDOException;class Database{private static $dbServer = 'localhost';private static $dbName = 'datenbankname';private static $dbUsername = 'benutzer';private static $dbUserPassword = 'passwort';private static $con = null;private function __construct(){ /* static class should not be instantiated */}public static function connect(){// Only One connection for the applicationif (null == self::$con){$options = array(PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',);try{self::$con = new PDO("mysql:host=" . self::$dbServer . ";" . "dbname=" . self::$dbName, self::$dbUsername, self::$dbUserPassword, $options);self::$con->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);}catch (PDOException $e){die($e->getMessage());}}return self::$con;}public static function disconnect(){self::$con = null;}}
Bisher habe ich die erste Lösung verwendet. Sie funktioniert, fühlt sich aber irgendwie seltsam an. Ich werde nun nur noch die Lösung über die statische Fabrik verwenden.