Une classe de log avec SQLITE

Suite a une question sur le forum de phpfrance je me suis dit tiens pourquoi pas faire une classe, autonome, permettant de faire un log d'action dans le code ?

Le

On veux quoi ?

le but est d'obtenir le suivis d'action sur une ou plusieurs pages (suivis d'utilisation de formulaire, de modification de page etc etc).

qui dit suivis dit stockage. La plusieurs possibilités :

  • Utiliser des fichiers


# Fichiers plat classique

  1. Fichier avec un contenu plus complexe (tableau / objet sérialisé par exemple)


* Utiliser un fichier plat

  1. Mysql
  2. Sqlite
  3. autre :)

Mon choix c'est porté sur SQLITE, parce que cela me permet de faire quelque chose de totalement autonome.

Le code est prévu pour être utilisé a partir de PHP 5.3 (utilisation des espaces de noms).

Modélisation d'un log

un log c'est quoi ?

  1. Un nom utilisateur
  2. Une date
  3. Une action
  4. Et d'autre truc (pour faire générique) il est possible de personnaliser ensuite (en mettant le nom de la page, si ce n'est pas fait dans l'action etc etc).

on peu donc en déduire une classe log comme ceci

<?php
/**
 * Décris un log
 *
 * @author Moogli
 * @version 1.0
 */

namespace logger\log;

class log {

    private $id;
    private $idUser;
    private $dateAction;
    private $action;
    private $infos;
    private $nom;

    /**
     * constructeur permet de préparer l'objet
     * @param int $id
     * @param int $iduser
     * @param date $date
     * @param string $action
     * @param string $infos 
     */

    public final function __contruct($id = null, $iduser = null, $date = null, $action = null, $infos = null) {
        $this->id = $id;
        $this->idUser = $iduser;
        $this->dateAction = $date;
        $this->action = $action;
        $this->infos = $infos;
    }
 
    public function getId() {
        return $this->id;
    }

    public function setId($id) {
        $this->id = $id;
    }

    public function getIdUser() {
        return $this->idUser;
    }

    public function setIdUser($idUser) {
        $this->idUser = $idUser;
    }

    public function getDateAction() {
        return $this->dateAction;
    }

    public function setDateAction($date) {
        $this->dateAction = $date;
    }

    public function getAction() {
        return $this->action;
    }

    public function setAction($action) {
        $this->action = $action;
    }

    public function getInfos() {
        return $this->infos;
    }

    public function setInfos($infos) {
        $this->infos = $infos;
    }
    
    public function getNom() {
        return $this->nom;
    }

    public function setNom($nom) {
        $this->nom = $nom;
    }
}

?>

Que doit faire la classe de log ?

  1. Enregistrer un log
  2. Supprimer un log ou tous
  3. Récupérer la liste des logs (pas de pagination pour le moment ^^)

la base de donnée

Je vais donc utiliser une base de donnée SQLITE dont voici les tables et la vue utilisée

-- Cette table permet la liaison avec votre table utilisateurs histoire de savoir qui fait quoi
CREATE TABLE 'users' ( 
	id INTEGER PRIMARY KEY AUTOINCREMENT not null, 
	idUser INTEGER NOT NULL, 
nom TEXT NOT NULL
);
-- Va contenir les "logs"
CREATE TABLE logs ( 
	idlog integer primary key autoincrement not null, 
	idUser integer not null, 
	dateAction DATE NOT NULL, 
	action varchar(50) not null, 
	infossupp text not null, 
	foreign key (idUser) references users(id)
);
-- La vue permettant le select
CREATE VIEW v_listlogs AS 
SELECT idlog as id, logs.idUser, nom,dateAction, action, infossupp as infos 
from logs 
join users on logs.idUser=users.idUser order by dateAction;';

code de la classe

<?php

/**
 * Cette classe permet le log d'action quelconque
 * Utilisation de sqlite pour le stockage
 *
 * @author Moogli
 * @version 1.0
 */
namespace logger;
include('log.class.php');
class logger {
    /**
     * instance de PDO
     * @var type 
     */
    private $pdo;
    /**
     * Instance du logger
     * @var type 
     */
    private static $instance;

    /**
     *constructeur privé c'est un singleton 
     */
    private function __construct(){
        $this->pdo = new  \PDO('sqlite:logger.sqlite3');
        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
    }
    /**
     * permet de recuperer l'instance du logger et de la creer le cas échéant
     * @return type 
     */
    public static final function getInstance(){
        if(logger::$instance === null){
            logger::$instance = new logger();
        }
        return logger::$instance;
    }
    /**
     * permet d'ajouter une action dans le log
     * @param int $userid       : id de l'utilisateur dans la tabel globale
     * @param string $action    : nom de l'acion sur 50 charactères
     * @param string $infos     : Les informations supplémentaires (requete, avant / après etc
     * @return boolean          : retour 
     * @throws \Exception 
     */
    public function addLog($userid, $action, $infos){
        if(!empty($userid)){
            if (!empty($action)){
                if (!empty($infos)){
                    if (is_int($userid)){
                        // on véfie que l'id utilisateur est connu du système
                        $sql = 'select count(*) as nb from users where idUser='.
                                $this->pdo->quote($userid,\PDO::PARAM_INT);
                        try {
                            $result = $this->pdo->query($sql);
                            $data = $result->fetch(\PDO::FETCH_OBJ);
                            $result->closeCursor();
                            if ($data->nb != 1) {
                                throw new \Exception('L'utilisateur n'existe pas');
                            }
                            else {
                                $sql = 'insert into logs ( idUser,dateAction,action,infossupp) values('.
                                        $this->pdo->quote($userid,\PDO::PARAM_INT).',''.
                                        date('Y/m/d h:i:s')
                                        .'', '.$this->pdo->quote($action).',
                                            '.$this->pdo->quote($infos).'
                                            );';
                                $r = $this->pdo->query($sql);
                                if ($r === false){
                                    $e = $this->pdo->errorInfo(); 
                                    throw new \Exception('Erreur SQL : '. $e[2],$e[1]);
                                }
                                else {
                                    return true;
                                }
                            }
                        }
                        catch (\PDOException $e){
                            throw new \Exception(
                                    'Erreur SQL : '. $e->getMessage()."\r\n".
                                    'Avec la requete : '.$sql, 0, $e->getPrevious());
                        }
                    }
                    else {
                        throw new \Exception('L'id utilisateur doit être un entier !');
                    }
                }
                else {
                    //infos vide, est ce grave ?
                    throw new \Exception('Il faut indiquer les informations liée a l'action');
                }
            }
            else {
                //action vide
                throw new \Exception('L'action ne peu être vide');
            }
        }
        else {
            //userid vide
            throw new \Exception('L'id utilisateur ne peu être vide');
        }
    }
    /**
     * Récupère les logs pour les afficher
     * @return array of \log\log
     * @throws \Exception 
     */
    public function getLog(){
        $sql = 'SELECT * 
FROM 'v_listlogs' order by dateAction DESC';
        try {
            $result = $this->pdo->query($sql);
            if ($result === false){
                $e = $this->pdo->errorInfo();
                throw new \Exception('Erreur SQL : '.$e[2],
                        $this->pdo->errorCode());
            }
            else {
                $ret = $result->fetchAll(\PDO::FETCH_CLASS,  'logger\log\log');
                $result->closeCursor();
                return $ret;
            }
        }
        catch(\PDOException $e){
            throw new \Exception(
                    'Erreur SQL : '.$e->getMessage()."\r\n".
                    $sql,
                    0,
                    $e->getPrevious()
                    );
        }
    }
    /**
     * suppression d'un log ou de tous
     * @param type $id
     * @throws \Exception 
     */
    public final function delLog($id = null){
        $sql = 'delete from logs';
        if ($id !== null && is_numeric($id)){
            $sql .= ' where idlog='. $this->pdo->quote($id,\PDO::PARAM_INT);
        }
        try{
            $r = $this->pdo->query($sql);
            if ($r === false){
            throw new \Exception('Erreur SQL : '.$e->getMessage()."\r\n".
                    $sql);
            }
            else {
                return true;
            }
        }
 catch (\PDOException $e){
            throw new \Exception('Erreur SQL : '.$e->getMessage()."\r\n".
                    $sql,
                    0,
                    $e->getPrevious());
        }
    }
    /**
     * Création des tables dans la base (à utiliser une seule fois
     * @throws \Exception 
     */
    public final function initTables(){
        $user = "CREATE TABLE 'users' ( 'id' INTEGER PRIMARY KEY AUTOINCREMENT not null, 'idUser' INTEGER NOT NULL, 'nom' TEXT NOT NULL );";
        $logs = "CREATE TABLE logs ( idlog integer primary key autoincrement not null, idUser integer not null, dateAction DATE NOT NULL DEFAULT current_date, action varchar(50) not null, infossupp text not null, foreign key (idUser) references users(id) );";
        $vue = 'CREATE VIEW v_listlogs AS SELECT idlog as id, logs.idUser, nom,dateAction, action, infossupp as infos from logs join users on logs.idUser=users.idUser order by dateAction;';
        try {
            $this->pdo->query($user);
            $this->pdo->query($logs);
            $this->pdo->query($vue);
        }
        catch (\PDOException $e){
            throw new \Exception('Erreur SQL : '.$e->getMessage()."\r\n".
                    $sql,
                    0,
                    $e->getPrevious());
        }
    }
}
?>

c'est bien tous ce paté mais ça marche comment ?

Un singleton ? O_o kesako

c'est l'art de rendre de faire en sorte qu'une classe ne puisse avoir qu'une seule instance. Pour cela l'on rend le constructeur privé. Une méthode statique (ici getInstance() ) permet de récupérer un l'instance (ici stockée dans logger::instance).

public static final function getInstance(){
        if(logger::$instance === null){
            logger::$instance = new logger();
        }
        return logger::$instance;
    }

Heu tu viens de dire que l'on ne peux instancier la classe mais y a un new dans le code O_o

hé oui, une méthode ou propiété de classe privée est accessible a l'intérieur de la classe. Donc ici $instance est privé mais utilisable partout dans la classe. Le principe est le même pour l'instanciation de la classe on peux l'instancier dans une de ses méthodes :) Le code dit "statique" est "partagé" entre toutes les instances de la classe, donc a chaque fois que l'on utilisera getInstance on recupérera toujours la même instance. le if permet de créer l'instance lors du permier appel.

nous avons donc une classe de log qui sera toujours la même dans le script (lors de l'execution) quelque soit l'endroit ou l'on se trouve).

Comment ça marche ?

c'est assez simple

<?php
include ('logger.class.php');
try {
	// on récupère l'instance
    $log = logger\logger::getInstance();
    // suppression d'un log
	$log->delLog(412);
	// insertion d'un log
    $log->addLog(52,'test','Il s agit la d un test de log :)');
}
 catch (Exception $e){
     echo '<div class="avertissement erreur">'.nl2br($e->getMessage()).'<br />'.nl2br($e->getTraceAsString()).'</div>';
 }
 // Affichage de la liste des logs dans un tableau
echo '<table style="cellspacing: collapse;">
    <thead>
        <tr>
            <th>id</th>
            <th>idUser</th>
            <th>Date</th>
            <th>action</th>
            <th>Infos supplémentaire</th>
        </tr>
    </thead>
    <tfoot>
    <tr><td colspan="5" style="background-color=#ccc;"></td></tr>
    </tfoot>';
try {
	// on récupère la liste
	$listLog = $log->getLog();
	// $listLog est un tableau d'objet log
    foreach($listLog as $l){
        echo '<tr>
                <td>'.$l->getId().'</td>
                <td>'.$l->getIdUser().'</td>
                <td>'.$l->getDateAction().'</td>
                <td>'.$l->getAction().'</td>
                <td>'.$l->getInfos().'</td>
            </tr>';
    }
}catch (Exception $e){
     echo '<div class="avertissement erreur">'.nl2br($e->getMessage()).'<br />'.nl2br($e->getTraceAsString()).'</div>';
 }
?>

voila c'est pas plus complexe que cela pour un script de base. bien sur la liaison avec la base utilisateur du site n'existe pas, on se retrouve avec un doublon, mais c'est tout a fait utilisable. Il est possible d'adapter le script pour un autre sgbd (en passant en paramètre du getInstance de l'objet PDO, ce paramètre pouvant être facultation, il faut l'initialiser au début du script).

Tests unitaires avec atoum

les tests unitaires c'est quoi ?

Pour faire un gros raccourcis c'est l'art et la manière de tester un composant (une classe) de manière autonome pour s'assurer qu'elle remplisse bien sont rôle.

Si l'on a un objet "test" avec une méthode insert qui retourne un booleen et prend un paramètre "truc".
Cette méthode vérifie si le truc est exploitable et, si c'est le cas, le traite. Si tous ce passe bien on retourne true sinon false :)

Pour tester cela atoum propose des méthodes intuitives :

  • un booleen : boolean()
  • une chaine : string()
  • un objet : object()
  • un tableau : array()

etc etc
Pour la comparaison c'est aussi simple

  • isEqualTo : pour une egalité strict
  • isIntanceOf : être une instance d'une classe
  • isIdenticalTo : pour comparer deux objets

structure du projet : \logger.calss.php \log.class.php \logger.sqlite3 \test \mageekguy.atoum.phar \test.php \logger.sqlite3

Comment utiliser atoum (utilisation minimaliste).

Pré-requis :

  • PHP 5.3 ou plus parce qu'Atoum utilise les espaces de nom
  • Le mode cli
  • Xdebug|http://www.xdebug.org|en|Site de Xdebug]
  • Le module PHAR
  • Le module XML

il faut créer une classe qui hérite de la classe test de atoum

<?php
// on définit un espace de nom de test qui doit être \tests\units dans l'espace de nom de la classe à tester
namespace logger\tests\units;
require_once 'mageekguy.atoum.phar';
// import de l'espace de nom
use mageekguy\atoum;
class detest extends atoum\test {
}
?>

Voila je suis prêt a tester :)

<?php
// on définit un espace de nom de test qui doit être \tests\units dans l'espace de nom de la classe à tester
namespace logger\tests\units;
require_once 'mageekguy.atoum.phar';
// import de l'espace de nom
use mageekguy\atoum;
class detest extends atoum\test {
	public function testInsert(){
		$t = new test();
		$this->assert('un nom ou pas ce paramètre est facultatif')
				->boolean($t->insert('valeur du truc')
				->isEqualTo(true);
	}
}
?>

et comment je test ça ? Atoum n'est utilisable qu'en ligne de commande, il va donc falloir ouvrir une console. déplacer vous dans le répertoire du fichier qui contient la classe ci dessus et lancer php test.php (si test.php est le nom du fichier).

/!\ Lors de mes tests sous windows j'avais droit à un beau message d'erreur m'indiquant qu'atoum ne trouvais pas l'exe de php (même s'il est dans le path :) ). La solution a été d'utiliser l'argument -p d'atoum et de préciser le chemin de l'exe. exemple php test.php -p "e:\xampp\php\php.exe"

Et avec ta classe ça fait quoi ?

Ta classe de test du logger

<?php
/**
 * test unitaire de la classe logger
 */
// Déclaration du namespace
namespace logger\tests\units;
// on inclus atoum
require_once 'mageekguy.atoum.phar';
// on inclus la classe logger
include '../logger.class.php';
// import du namespace "atoum"
use mageekguy\atoum;

class logger extends atoum\test {
    /**
     *Test insertion log 
	 * La méthode retourne un booleen que je compare à true (je veux que ça fonctionne ^^)
     */
	public function testAddLog() {
	// je récupère l'instance du logger, je le fait dans chaque méthode c'est un choix arbitraire
        $log = \logger\logger::getInstance();
        $this->assert()
                ->boolean($log->addLog(52, 'insertion', 'Insertion test unitaire avec atoum' .
                                "\r\n" . __FILE__ .
                                "\r\n" . __CLASS__ .
                                "\r\n" . __METHOD__))->isEqualTo(true);
        unset($log); // suppression de l'objet, heu parce que :)
    }
    /**
     * Test recup instance logger
	 * Je vérifie que l'on retourne toujours la même instance (si le singleton fonctionne correctement en fait)
     */
    public function testGestInstance() {
        $log = \logger\logger::getInstance();
        $this->assert('Test de la méthode getInstance')
                ->object(\logger\logger::getInstance()) // je test l'objet que retourne getInstance
                ->isInstanceOf('\logger\logger') // je vérifie qu'il s'agit bien d'une classe logger
                ->isIdenticalTo(\logger\logger::getInstance()); // je vérifie qu'il est bien identique à un autre appel de getInstance
        unset($log);
    }
    /**
     *Test de la recup de la liste des logs 
	 * Cette méthode retourne un tableau avec les "log"
	 * On vérifie que celui ci n'est pas vide
     */
    public function testGetLog(){
        $log = \logger\logger::getInstance();
        $this->assert('Test de la méthode getLog')
        ->array($log->getLog())->isNotEmpty();
        unset($log);
    }
    /**
     *Test des la suppression des log 
	 * cette méthode retourne un booleen
     */
    public function testDelLog(){
        $log = \logger\logger::getInstance();
        $this->assert('Test de la methode delLog')
        ->boolean($log->delLog())->isEqualTo(true);
        unset($log);
    }
}
?>

Pas de test unitaire de la méthode de création de la base, la version empirique a été utilisée (le fichier existe, tiens je peux inserer, c'est bon ça marche XD ).

bon c'est bien mais ça donne quoi au final ?

ceci

E:\xampp\htdocs\logger\test>php test.php -p "e:\xampp\php\php.exe"
> atoum version nightly-1004-201202271344 by Fr├®d├®ric Hardy (phar://E:/xampp/htdocs/logger/test/mageekguy.atoum.phar/1)
> PHP path: E:\xampp\php\php.exe
> PHP version:
=> PHP 5.4.0 (cli) (built: Feb 29 2012 19:24:02)
Copyright (c) 1997-2012 The PHP Group
Zend Engine v2.4.0, Copyright (c) 1998-2012 Zend Technologies
    with Xdebug v2.2.0-dev, Copyright (c) 2002-2012, by Derick Rethans
> logger\tests\units\logger...
[....________________________________________________________][0/4]
[S...________________________________________________________][1/4]
[SS..________________________________________________________][2/4]
[SSS.________________________________________________________][3/4]
[SSSS________________________________________________________][4/4]
=> Test duration: 0.11 second.
=> Memory usage: 0.00 Mb.
> Total test duration: 0.11 second.
> Total test memory usage: 0.00 Mb.
> Code coverage value: 42.17%
=> Class logger\logger: 42.17%
==> logger\logger::addLog(): 60.71%
==> logger\logger::getLog(): 37.50%
==> logger\logger::delLog(): 35.71%
==> logger\logger::initTables(): 0.00%
> Running duration: 1.69 seconds.
Success (1 test, 4/4 methods, 9 assertions, 0 error, 0 exception) !

En cas d'erreur, les messages sont bien entendus indiqué dans la console.
Un article expliquand le fonctionnement d'atoum et bien sur aoum.org pour le site de référence (redirection vers le dépot github, avec faq / wiki etc