Page 1 sur 1

Cas pratique : blog en POO

Posté : 13 avr. 2009, 09:16
par supercanard
Bonjour,

Voilà, imaginons un blog super basique conçu en objet dans lequel nous aurions au moins 3 objets :
- les billets
- les catégories
- les commentaires

J'ai un petit problème de réflexion... On pourrait imaginer la chose de deux façon :

- Les commentaires appartiennent aux billets. De ce fait, la classe billet aurait une propriété "liste commentaire" qui contiendrait une liste d'objets commentaires qui lui sont associés. On accéderais aux commentaires de cette façon :
$listeCom->commentaire->propriete
Cette façon de faire ne me parait pas fausse, mais je me demande si nous nous retrouverions pas très vite avec un énorme objet billet si j'avais prit un exemple plus complexe qu'un blog basique ?

- Les commentaires et les billets serraient deux choses complètement distinctes. Ce serrait le controller et non la classe qui se chargerait de rassembler ces 2 informations :
$billet->get($idBillet)
$listeCom->getListe($idBillet)

Idem pour la catégorie... on pourrait imaginer que la classe billet comporte une propriété catégorie, qui serait en fait un objet catégorie. Mais je rejoint ma première impression : On se retrouverait vite avec un objet "maitre", et je n'ai pas assez de connaissances en poo pour dire si cela est correct ou non

Voilà
J'espère que vous allez pouvoir m'éclairer ;)

Posté : 13 avr. 2009, 13:42
par naholyr
En terme de modélisation, on a une triviale relation 1-n entre billet et commentaire.

Cela se représente en base de données par une FK not null de commentaire vers billet :

Code : Tout sélectionner

billet: id: ... ... commentaire: billet_id: foreign_key(billet, id) ...
Et cela se représente en terme d'objet par une méthode "Billet::getCommentaires()" pour récupérer la liste des commentaires d'un billet, et éventuellement une méthode "Commentaire::getBillet()" pour récupérer le billet "parent".
Ainsi qu'une méthode "Billet::addCommentaire()" et éventuellement "Commentaire::setBillet()" pour lier un commentaire et un billet.
Et évidemment on ajoutera une méthode "Billet::removeCommentaire()" qui casse ce lien.

Le plus compliqué étant de maintenir bien à jour ces relations sans forcément surcharger la base de requêtes. Et surtout de savoir profiter de ce lien entre commentaire et billet pour maintenir un modèle cohérent grâce à des sauvegardes en cascade.

Je te laisse avec ces bouts de code (non testés, et qui de toute façon ne marchent pas tel quel du fait de l'utilisation d'une hypothétique classe d'abstraction SQL, ce n'est que pour te donner une idée) pour voir un exemple concret (*) :
class Billet
{

...

  // Liste des commentaires
  protected $commentaires = null;

  // Retourner la liste des commentaires
  public function getCommentaires()
  {
    if (is_null($this->commentaires)) {
      $this->commentaires = Commentaire::selectWhere('billet_id = :id', array('id' => $this->getId()));
    }

    return $this->commentaires;
  }

  // Ajouter un commentaire à la liste
  public function addCommentaire(Commentaire $commentaire)
  {
    if (!is_null($this->commentaires)) {
      // Si on a déjà interrogé la liste des commentaires, on la maintient à jour
      $this->commentaires[] = $commentaire;
    }

    $commentaire->setBillet($this);
  }

  // Retirer un commentaire de la liste
  public function removeCommentaire(Commentaire $commentaire, $delete = true)
  {
    if (!is_null($this->commentaires)) {
      foreach ($this->commentaires as $i => $a_commentaire) {
        if ($commentaire === $a_commentaire) {
          unset($this->commentaires[$i]);
        }
      }
    }

    $commentaire->setBillet(null);

    if ($delete) {
      $commentaire->delete();
    }
  }

}

class Commentaire
{

...

  protected $billet = null;

  public function getBillet()
  {
    if (is_null($this->billet)) {
      $this->billet = Billet::selectOneWhere('id = :id', array('id' => $this->getBilletId()));
    }

    return $this->billet;
  }

  public function setBillet(Billet $billet = null)
  {
    if ($this->getBillet()) {
      // je suis déjà lié à un billet : rompre ce lien
      $this->getBillet()->removeCommentaire($this, false);
      // Note : c'est le genre de test qu'il faudrait faire dans la méthode "delete()" aussi
    }

    $this->billet = $billet;
    $this->setBilletId(is_null($billet) ? null : $billet->getId());
  }

}

(*) Dans cet exemple je considère que tes objets ont déjà le minimum vital pour travailler en corrélation avec la base de données :
- Des méthodes statiques pour interroger la base de données (ici au pif un selectWhere() qui renvoie une liste d'objets, et un selectOneWhere() qui renvoie un seul objet).
- Des accesseurs (pour chaque champ "bidule" on a une méthode getBidule() et setBidule()).
- Une méthode save() qui insère ou met à jour l'objet selon s'il est "nouveau" ou pas (en gros, si sa primary key est défini on update, et sinon on insère) et qui renvoie la valeur de sa primary key.
- Une méthode delete() qui fait un delete dans la base, et marque l'objet comme "supprimé" (par exemple un objet marqué comme supprimé ne peut pas être sauvegardé).

Exemple :
class Billet
{

  /*** Elements génériques de manipulation de l'objet dans la base ***/

  // L'objet est-il "virtuel" ou a-t-il été déjà sauvé dans la base ?
  protected $_saved = true;

  // Liste des colonnes modifiées par rapport à la version en base
  protected $_modifiedColumns = array(); 

  // Marqué pour suppression ?
  protected $_deleted = false;

  public function isDeleted()
  {
    return $this->_deleted;
  } 

  public function delete()
  {
    if ($this->isDeleted()) {
      return false;
    }

    SQL::exec('DELETE FROM billet WHERE id=:id', array('id' => $this->getId()));

    $this->_deleted = true;

    return true;
  }

  public function isNew()
  {
    return !$this->_saved;
  }

  public function isModified()
  {
    return count($this->_modifiedColumns) >= 0;
  }

  public function save()
  {
    if ($this->isNew()) {
      SQL::exec('INSERT INTO billet (titre, ...) VALUES (:titre, ...)', array('titre' => $this->getTitre(), ...));
      $this->setId(SQL::lastInsertId());
    } elseif ($this->isModified()) {
      $changes = array();
      $values = array('id' => $this->getId());
      foreach ($this->_modifiedColumns as $column) {
        $changes[] = $column . ' = :' . $column;
        $values[$column] = call_user_func(array($this, 'get'.$column));
      }
      SQL::exec('UPDATE billet SET '.implode(', ', $changes).' WHERE id=:id', $values);
    }

    // Histoire de se simplifier la vie : ici on pourrait parcourir les commentaires attachés mais
    // pas encore sauvegardés, et les sauver
    foreach ($this->getCommentaires() as $commentaire) {
      if ($commentaire->isNew() || $commentaire->isModified()) {
        $commentaire->save();
      }
    }
    // Note : dans Commentaire::save() on fera la même vérification mais au début de la méthode 
    // save() car le billet est représentée par une colonne billet_id not null :
    /*
    Commentaire::save():
      if ($this->getBillet() && ($this->getBillet()->isNew() || $this->getBillet()->isModified())) {
        $this->setBilletId($this->getBillet()->save());
      }
      ...
    */

    // Remise à 0 des modifications
    $this->_saved = true;
    $this->_modifiedColumns = array();

    return $this->getId();
  }

  /*** Elements de l'objet représentant une ligne de la table "billet" ***/

  protected $id = null;

  // billet.id
  public function getId()
  {
    return $this->id;
  }

  // setId n'est pas public : on considère qu'on doit avoir un auto_increment et que c'est via "save" qu'elle doit être définie, et pas manuellement
  protected function setId($v)
  {
    $this->id = $v;
  }

  protected $titre = null;

  public function getTitre()
  {
    return $this->titre;
  }

  public function setTitre($v)
  {
    if ($v !== $this->titre) {
      $this->titre = $v;
      if (!in_array('titre', $this->_modifiedColumns)) {
        $this->_modifiedColumns[] = 'titre';
      }
    }
  }

...

  /*** Méthodes de requêtage ***/

  public static function selectWhere($whereClause, array $values = array(), $fromClause = null)
  {
    $query = 'SELECT id, titre, ... FROM billet';

    if (!is_null($fromClause)) {
      $query .= $fromClause;
    }

    $query .= ' WHERE ' . $whereClause;

    $rows = SQL::exec($query, $values);

    $billets = array();
    foreach ($rows as $row) {
      $billet = new Billet();
      foreach ($row as $column => $value) {
        call_user_func(array($this, 'set'.ucfirst($column)), $value);
      }
      $billets[] = $billet;
    }

    return $billets;
  }

  public static function selectOneWhere($whereClause, array $values = array(), $fromClause = null)
  {
    $billets = self::selectWhere($whereClause . ' LIMIT 1', $values, $fromClause);

    return reset($billets);
  }

}
C'est une version raccourcie et simplifiée de ce que font des ORM comme Propel ou Doctrine.
Il faudrait évidemment y ajouter des méthodes de sélection avec jointure pour récupérer en une seule requête un billet et ses commentaires, ce que font très bien ces deux librairies ;)

À toi de voir si tu te sens d'attaque de réimplémenter ça "à ta manière" à partir des pistes que j'ai pu te fournir ici, ou si tu veux directement utiliser ces librairies qui ont fait leurs preuves. S'il s'agit d'un projet perso tu peux en profiter pour explorer plusieurs pistes dont le "fait maison" pour comprendre comment ça marche. S'il s'agit d'un projet professionnel : utilise l'une des deux librairies qui sont mures et bien maintenues.

Posté : 13 avr. 2009, 16:32
par supercanard
Merci pour cet exemple
Justement comme tu parlais de surcharger la base de requête...

Jusqu'à maintenant ma méthode était la suivante :
Par exemple j'ai un objet billet, qui communique avec la table billet et un objet categorie qui communique lui avec sa table propre. Sachant qu'un billet et lié à une catégorie par un id, voilà comment je procédait pour créer une instance de billet depuis la base de données :

// Requête simple
$billet->valeur = $row->valeur;
$billet->valeur = $row->valeur;
...
Et lorsque je croise un idCategorie
$billet->categorie = categorie::createFromDb($row->idCategorie); // retourne un objet
... etc


Le gros problème c'est que categorie::createFromDb ouvre une requête supplémentaire alors que tout pourrait se faire dans la première requête avec une simple jointure...

Mais j'ai pourtant l'impression que ce ne serait pas "correct" de faire ceci sans passer par la méthode createFromDb de l'objet categorie ? Même si ce serai moins lourd...

Posté : 13 avr. 2009, 17:04
par naholyr
Le secret dans ce cas là c'est de disposer d'une méthode qui permet "d'hydrater" un objet à partir d'un tableau associatif généré par une requête en base de données.

Exemple :
class Billet
{

...

  public static function selectWithCategorieWhere($whereClause, $values = array(), $fromClause = null)
  {
    $query = 'SELECT billet.id AS billet_id, billet.titre AS billet_titre, ..., billet.categorie_id AS categorie_id, categorie.titre AS categorie_titre, ... FROM billet';

    if (!is_null($fromClause)) {
      $query .= ' '.$fromClause;
    }

    $query .= ' LEFT JOIN categorie ON (billet.categorie_id = categorie.id)';

    if (!is_null($whereClause) && $whereClause !== '') {
      $query .= ' WHERE ' . $whereClause;
    }

    $row = SQL::exec($query);

    // Générer un objet Billet à partir du résultat d'une requête (équivalent de new Billet() + les appels à setChamp()...)
    $billet = Billet::fromArray(array(
      'id' => $row->billet_id, 
      'titre' => $row->billet_titre,
      ...
    ));

    // Générer un objet Categorie à partir du résultat d'une requête (idem)
    $categorie = Categorie::fromArray(array(
      'id' => $row->categorie_id,
      'titre' => $row->categorie_titre,
      ...
    ));

    // Lier le billet à sa catégorie (même principe que Commentaire::setBillet() dans l'exemple précédent)
    $billet->setCategorie($categorie); // Lorsqu'on appellera $billet->getCategorie() il n'y aura pas besoin d'une nouvelle requête

    return $billet;
  }

  // Exemple de méthode "fromArray"
  public static function fromArray(array $values)
  {
    $billet = new Billet();
    
    $columns = array('id', 'titre', ...);
    
    foreach ($columns as $column) {
      if (isset($values[$column])) {
        call_user_func(array($billet, 'set'.ucfirst($column)), $values[$column]);
      }
    }

    return $billet;
  }

}
Après c'est encore plus amusant à faire avec des relations n-n ;)


Comme tu peux le voir tout ça fait beaucoup de code à écrire dans chaque classe du modèle, et même s'il est possible d'en externaliser une bonne part le fait de le conserver dans l'objet lui donne une autonomie appréciable. Justement le rôle des librairies que je t'ai cité précédemment est de générer ce code pour toi en grande partie.

Posté : 13 avr. 2009, 17:42
par supercanard
Ah ben j'étais sur une piste car j'avais justement commencé cette méthode de construction par un tableau, mais elle n'était pas tout à fait destiné à cette fonction.
Tu as fini de me mettre sur le voie je crois, voilà mon exemple :

Dans la classe article :
public static function createArticleFromDb($id){
		$val = array(':id'=>$id);

		$req = "SELECT blog_article.*, blog_categorie.libelle AS categorieLibelle, blog_categorie.libelleForUrl AS categorieLibelleForUrl, blog_categorie.description AS categorieDescription FROM blog_article 
		JOIN blog_categorie ON  blog_article.idCategorie = blog_categorie.idCategorie 
		WHERE idArticle = :id";

		$res = pdo2::getInstance()->prepare($req);

		$res->execute($val);
		$row = $res->fetch(pdo2::FETCH_OBJ);
		$arrayCategorie['id'] = $row->idCategorie;
		$arrayCategorie['libelle'] = $row->categorieLibelle;
		$arrayCategorie['libelleForUrl'] = $row->categorieLibelleForUrl;
		$arrayCategorie['description'] = $row->categorieDescription;
		$article = new article($row->idArticle);
		$article->categorie = categorie::createCategorieFromArray($arrayCategorie);
		$article->etat = $row->etat;
		$article->titre = $row->titre;
		$article->titreForUrl = $row->titreForUrl;
		$article->date = $row->date;
		$article->chapo = $row->chapo;
		$article->contenu = $row->contenu;
		return $article;
	}
Dans la classe catégorie :
public static function createCategorieFromArray($array){
		$categorie = new categorie($array['id']);
		$categorie->libelle = $array['libelle'];
		$categorie->libelleForUrl = $array['libelleForUrl'];
		$categorie->description = $array['description'];
		return $categorie;
	}
Bon alors pour l'instant mes proprietes et mes méthodes sont public, mais dans l'idée j'en suis là.

Posté : 13 avr. 2009, 18:38
par naholyr
Je pense donc que tu as bien compris l'idée générale :) Après tout est question de détail d'implémentation et pour ça tu dois à chaque étape te poser la question du maintien des relations, je t'invite à étudier mes exemples pour voir ces pistes ;)

Posté : 13 avr. 2009, 22:11
par savageman
Ca a subtilement switché de "commentaire" (n-n) à catégorie (1-n) ce qui n'est pas super marrant. :)
Dans le cas d'une relation N-N, mieux ne vaut-il pas faire 2 requêtes ? Pour reprendre l'exemple d'un billet et de ses commentaires : une pour le billet et une autre pour ses commentaires ? Car Je suis d'accord qu'on peut faire le tout en une seule requête, mais dans ce cas, les informations du billets seraient présentes dans chaque tuple qui contient un "commentaire"...

Posté : 13 avr. 2009, 22:46
par naholyr
Tout-à-fait, c'est pour ça que j'ai dit que c'était plus marrant :P

En fait c'est à tester, on a le choix :

1. Faire une seule requête de N lignes : N fois le même billet, et 1 fois chaque commentaire. On prend les infos du billet de la première ligne pour le construire, puis on construit chaque commentaire. Total : 1 seule requête de N * (X+Y) champs.

2. Faire deux requêtes : 1 pour le billet, 1 pour les N commentaires. Total : 1 requête de X champs, et 1 de N*Y champs.


Difficile de prévoir lequel sera le plus intéressant en terme de performances, il faut tester. Et il est en plus très probable que le résultat change selon la configuration : serveur sql sur la même machine ? ressource bloquante (disque dur, processeur, ou ram) ? etc...


Cependant attention dans son exemple il n'y a pas de n-n : un commentaire est lié à un seul billet (1-n) et un billet appartient à une seule catégorie (encore du 1-n). Le seul endroit où on pourrait avoir du n-n c'est pour du multi-catégories.

Posté : 17 avr. 2009, 13:50
par supercanard
Voilà j'ai essayer de faire mes classes.

Alors bon, j'ai quand même privilégié la simplicité, du coup j'ai fait abstraction de setter et de getter. Je sais que normalement il faudrait que mes attributs soient privés mais bon, es-ce bien raisonnable de faire une usine à gaz pour un simple blog...
J'ai aussi évité de faire trop de méthodes, j'ai dailleur dans le code ci-dessous commenté les deux qui me paraissaient un peu superflu.
J'espère que je n'ai pas tout faut. Voici par exemple ma classe article ( billet ) :
<?php
class article{
	public $id;
	public $categorie;
	public $etat;
	public $titre;
	public $titreForUrl;
	public $date;
	public $chapo;
	public $contenu;
	public $listeCommentaire;
	
	public function __construct($id = null){
		$this->id = $id;
	}
	
	public function validerTitre($value){
		if(!util::stringLenghtBetween($value, 1, 50)){
			throw new Exception('Le titre doit être composé de 1 à 50 caractères.');
		}
		$this->titre = $value;
	}
	public function validerDate($value){
		$this->date = $value;
	}
	public function validerChapo($value){
		$this->chapo = $value;
	}
	public function validerContenu($value){
		$this->contenu = $value;
	}
	
	private function setTitreForUrl(){
		$this->titreForUrl = util::cleanStringForUrl($this->titre);
	}
	
	public function save(){
		$this->setTitreForUrl();
		if($this->id == null){ // insert
			$val = array(
				':categorie'=>$this->categorie,
				':etat'=>$this->etat,
				':titre'=>$this->titre,
				':titreForUrl'=>$this->titreForUrl,
				':date'=>$this->date,
				':chapo'=>$this->chapo,
				':contenu'=>$this->contenu
			);//print_r($val);
			$req = "INSERT INTO blog_article (idArticle, idCategorie, etat, titre, titreForUrl, date, chapo, contenu) 
			VALUES('', :categorie, :etat, :titre, :titreForUrl, :date, :chapo, :contenu)";
			$res = pdo2::getInstance()->prepare($req);
			$res->execute($val);
			//var_dump($res->execute($val));
			$this->id = pdo2::getInstance()->lastInsertId();
		}
		else{
			$val = array(
				':id'=>$this->id,
				':categorie'=>$this->categorie,
				':etat'=>$this->etat,
				':titre'=>$this->titre,
				':titreForUrl'=>$this->titreForUrl,
				':date'=>$this->date,
				':chapo'=>$this->chapo,
				':contenu'=>$this->contenu
			);//print_r($val);
			$req = "UPDATE blog_article SET idCategorie = :categorie, etat = :etat, titre = :titre, titreForUrl = :titreForUrl, 
			date = :date, chapo = :chapo, contenu = :contenu WHERE idArticle = :id";
			$res = pdo2::getInstance()->prepare($req);
			$res->execute($val);
			//var_dump($res->execute($val));
		}
	}
	public function delete(){
		$val = array(':id'=>$this->id);
		$req = "DELETE FROM blog_article WHERE idArticle = :id";
		$res = pdo2::getInstance()->prepare($req);
		$res->execute($val);
		//var_dump($res->execute($val));
	}
	/*private function getListeCommentaire(){
		$this->listeCommentaire = commentaire::createListeCommentaireFromDb($this->id, 1);
	}
	private function getCategorie(){
		$this->categorie = categorie::createFromDb($this->id, 1);
	}*/
	
	public static function createArticleFromDb($value){
		$val = array(':id'=>$value);
		$req = "SELECT * FROM blog_article WHERE idArticle = :id";
		$res = pdo2::getInstance()->prepare($req);
		$res->execute($val);
		$row = $res->fetch(pdo2::FETCH_OBJ);
		$article = new article($row->idArticle);
		$article->categorie = categorie::createCategorieFromDb($row->idCategorie);
		$article->etat = $row->etat;
		$article->titre = $row->titre;
		$article->titreForUrl = $row->titreForUrl;
		$article->date = $row->date;
		$article->chapo = $row->chapo;
		$article->contenu = $row->contenu;
		//$article->getListeCommentaire();
		$article->listeCommentaire = commentaire::createListeCommentaireFromDb($row->idArticle, 1);
		return $article;
	}
	public static function createListeArticleFromDb($idCategorie, $limitX, $limitY, $etat){
		if($idCategorie == 'all' || $idCategorie == null){ // toute catégories confondues
			$val = array(':etat'=>$etat);
			$req = "SELECT blog_article.*idArticle FROM blog_article 
			JOIN blog_categorie ON  blog_article.idCategorie = blog_categorie.idCategorie 
			WHERE etat = :etat ORDER BY date DESC LIMIT ".$limitX.",".$limitY."";
		}
		else{ // selon catégorie
			$val = array(':idCategorie'=>$idCategorie, ':etat'=>$etat);
			$req = "SELECT blog_article.*idArticle FROM blog_article 
			JOIN blog_categorie ON  blog_article.idCategorie = blog_categorie.idCategorie 
			WHERE blog_article.idCategorie = :idCategorie AND etat = :etat ORDER BY date DESC LIMIT ".$limitX.",".$limitY."";
		}
		$res = pdo2::getInstance()->prepare($req);
		$res->execute($val);
		//var_dump($res->execute($val));
		$liste = array();
		while($row = $res->fetch(pdo2::FETCH_OBJ)){
			$liste[] = article::createArticleFromDb($row->idArticle);
		}
		return $liste;
	}
	public static function countArticle($idCategorie, $etat){
		if($idCategorie == 'all' || $idCategorie == null){ // toute catégories confondues
			$val = array(':etat'=>$etat);
			$req = "SELECT count(idArticle) AS nb FROM blog_article 
			WHERE etat = :etat";
		}
		else{ // selon catégorie
			$val = array(':idCategorie'=>$idCategorie, ':etat'=>$etat);
			$req = "SELECT count(idArticle) AS nb  FROM blog_article 
			WHERE idCategorie = :idCategorie AND etat = :etat";
		}
		$res = pdo2::getInstance()->prepare($req);
		$res->execute($val);
		$row = $res->fetch(pdo2::FETCH_OBJ);
		return $row->nb;
	}
}
?>
[edit]Ajout des balises PHP[/edit]