vendredi, avril 9 2010, 22:26
Interface ArrayAccess : où des objets que l'on utilise comme des array
Par Olivier Hoareau - Méthodologie - Lien permanent
Cette semaine j'ai fait découvrir l'interface ArrayAccess à un des développeurs Parisiens que je coache.
Cette fonctionnalité lui a permis d'appréhender facilement les tests unitaires sur du code legacy, je reviens sur cette "expérience"
Nous travaillons sur une application très populaire du grand public qui vend des produits High Tech, entre autres.
Cette application est en maintenance évolutive, avec de nouveaux développements plutôt réalisés en PHP.
Dans cette application il existe entre autres des objets "entités" qui représentent des "concepts" principalement stockées en base (on peut parler de quelque chose de similaire à un ORM pour le mapping Objet-Relationnel).
Nous avons une entité Product :
class Product
{
protected $id;
public function setId($id)
{
$this->id = $id;
}
public function getId()
{
return $this->id;
}
// ... plein d'autres propriétés du Product
public function uneFonctionQuiFaitLeCafé()
{
// ....
}
}
Notre entité est utilisée par une méthode d'une autre classe, qui liste des produits depuis la base et retourne une liste (array) d'objet Product.
Le problème dans notre cas est que nous souhaitions amener une démarche de tests unitaires type TDD (Test Driven Development ou Test-First) sur de nouveaux développements, mais que nous avions déjà pas mal de code, dont la classe Product, qui était déjà développé sans aucun test.
Notre démarche a donc été de créer une nouvelle classe pour la nouvelle méthode que nous souhaitions créer et de petit à petit porter le code existant dans cette classe au fur et à mesure que nous écrirons les tests unitaires.
Quand on adopte ce type de démarche (intéressante de mon point de vue), on met rapidement le doigt sur les zones existantes du code qui sont "dépendantes" d'autres classes / méthodes par encore testées, et très rapidement on peut être découragé car on tir un fil et c'est toute la pelotte qui vient avec...
Pour tenter de garder "espoir" dans cet effort de "portage" et couverture par les tests du code existant, une technique est de "bouchonner" le code existant qui ne nous intéresse pas (encore) grâce à un code / classe simulateur.
C'est à ce niveau que l'interface ArrayAccess peut s'avérer utile.
En effet, voici comment nous utilisons habituellement notre entité Product :
$product = new Product(); $product->setId(12); $product->setPrice(25.12); ... $description = $product->getDescription(); $product->uneFonctionQuiFaitLeCafé(); ...
Si la méthode qui fait le café ne nous "intéresse" pas pour l'instant (ou que nous souhaitons la "sortir" de la classe Product in fine, et que pour l'instant nous décidons de ne pas travailler sur les tests de cette méthode), alors il ne nous reste plus que des "getters" et "setters" sur l'objet Product, et finalement que penseriez vous de ceci :
$product = new Product(); $product['id'] = 12; $product['price'] = 25.12; ... $description = $product['description']; $product->uneFonctionQuiFaitLeCafé(); ...
Il est en effet possible d'utiliser ce type de syntaxe si l'objet Product implémente l'interface (native) ArrayAccess :
class Product implements ArrayAccess
{
protected $properties = array();
public function setId($id)
{
return $this->offsetSet('id', $id);
}
public function getId()
{
return $this->offsetGet('id');
}
// ... plein d'autres propriétés du Product
public function uneFonctionQuiFaitLeCafé()
{
// ....
}
// implémentation des méthodes "obligatoires" de l'interface ArrayAccess
public function offsetSet($offset, $value)
{
$this->properties[$offset] = $value;
return $value;
}
public function offsetExists($offset)
{
return isset($this->properties[$offset]);
}
public function offsetUnset($offset)
{
unset($this->properties[$offset]);
}
public function offsetGet($offset)
{
return isset($this->properties[$offset]) ? $this->properties[$offset] : null;
}
}
Imaginons maintenant que nous nous passions par une "factory" pour créer de nouvelles entités Product :
// on ne fait plus new Product() mais : $product = createABlankProduct(); ...
Imaginons que nous ne travaillons pas encore sur la méthode qui fait le café mais uniquement que sur les données stockées dans l'entité, à savoir que nous souhaitons tester unitairement une méthode qui "crée" une liste d'entité produit à partir de données en base, c'est à dire qu'elle "rempli" des entités avec les valeurs selon une certaine logique :
/**
* @return array of Product
*/
public function listProductByUniversAndPriceInterval($univers, $minPrice, $maxPrice)
{
// ... sélection des données en base de données via PDO, par exemple, triée dans l'ordre de prix
$productsData = $this->listRawProductsDataByUniversAndPriceInterval($univers, $minPrice, $maxPrice);
// ...
$products = array();
foreach($productsData as $rank => $row) {
$product = $this->createABlankProduct();
$product['id'] = (int)$row['id'];
$product['price'] = (double)$row['price'];
$product['universe'] = $univers;
$product['rank'] = $rank + 1;
// ...
$products[] = $product;
}
return $products;
}
Imaginons maintenant que la creation d'un "blank product" et la récupération des données brutes de la base soient déléguées à un objet "adapter" (i.e. délégué de la classe courante) :
$productsData = $this->getAdapter()->listRawProductsDataByUniversAndPriceInterval($univers, $minPrice, $maxPrice);
...
$product = $this->getAdapter()->createABlankProduct();
...
Imaginons que la classe courante propose un "setter" qui permette de "changer" l'objet adapter courant :
public function setAdapter(MySpecificAdapterInterface $adapter)
{
$this->adapter = $adapter;
return $this;
}
Nous avons donc un objet qui doit implémenter l'interface MySpecificAdapterInterface et qui doit donc implémenter une méthode createABlankProduct() qui doit renvoyer une structure qui doit être accessible via la notation array (pour l'instant nous n'utilisons pas la méthode qui fait le café) :
class MySpecificAdapterStandardVersion implements MySpecificAdapterInterface
{
public function createABlankProduct()
{
return new Product();
}
public function listRawProductsDataByUniversAndPriceInterval($univers, $minPrice, $maxPrice)
{
// faite ce que vous faisiez avant pour récupérer les données brutes en base ici
// ...
return $rows;
}
}
Imaginons maintenant que nous ayons une version "simulateur" de notre adapter qui l'on pourrait paramétrer pour lui indiquer ce qu'elle doit retourner lorsqu'on appelle une de ces 2 méthodes :
class MySpecificAdapterMockVersion implements MySpecificAdapterInterface
{
protected $expectedResult;
public function createABlankProduct()
{
return array();
}
public function setExpectedResult($result)
{
$this->expectedResult = $result;
return $this;
}
public function listRawProductsDataByUniversAndPriceInterval($univers, $minPrice, $maxPrice)
{
return $this->expectedResult;
}
}
Ainsi dans nos tests unitaires nous pourrons facilement "tester" la méthode listProductByUniversAndPriceInterval :
class MyClassTest extends PHPUnit_Framework_TestCase
{
public function testListProductByUniversAndPriceIntervalForExistingProductsInDatabaseReturnProductList()
{
$mock = new MySpecificAdapterMockVersion();
$objectToTest = new MyClass();
$objectToTest->setAdapter($mock);
$mock->setExpectedResult(array(
array('id'=>10, 'price'=>"11.99"),
array('id'=>4, 'price'=>"15.99"),
array('id'=>7, 'price'=>"27.99"),
));
$products = $objectToTest->listProductByUniversAndPriceInterval('games', 10.0, 30.0);
// vérification des données du 1er produit
$this->assertEquals(11.99, $products[0]['price']);
$this->assertEquals(1, $products[0]['rank']);
$this->assertEquals(10, $products[0]['id']);
$this->assertEquals('games', $products[0]['univers']);
// vérification des données du 2ème produit
$this->assertEquals(15.99, $products[1]['price']);
$this->assertEquals(2, $products[1]['rank']);
$this->assertEquals(4, $products[1]['id']);
$this->assertEquals('games', $products[1]['univers']);
// vérification des données du 3ème produit
$this->assertEquals(27.99, $products[2]['price']);
$this->assertEquals(3, $products[2]['rank']);
$this->assertEquals(7, $products[2]['id']);
$this->assertEquals('games', $products[2]['univers']);
}
}
Grâce à l'utilisation de array() et de l'adapter mock, dans un premier temps, nous pouvons donc tester facilement la logique de la méthode listProductByUniversAndPriceInterval() sans devoir "porter" tout l'objet Product (qui vient de notre code legacy).
Maintenant que nous avons portez cette première méthode (voire d'autres), si nous avons besoin de porter d'autres méthodes "qui font le café" de l'entité product nous pouvons alors utiliser l'implémentation de l'interface ArrayAccess présenté au début et les tests unitaires et le contenu de la méthode déjà développé reste toujours valable, à savoir le fait d'utiliser un objet plutôt qu'un array n'a pas d'impact sur le code de la méthode listProductByUniversAndPriceInterval() car notre objet implémente l'interface ArrayAccess qui permet de l'utiliser, quand cela nous intéresse, comme un array() avec des crochets.
En conclusion, nous pouvons retenir que l'utilisation de l'interface ArrayAccess peut nous permettre de commencer à travailler avec des structures de types array() notamment pour introduire des tests unitaires, puis de migrer sans douleur vers une structure plutôt objet qui sera enrichie par des méthodes "qui font le café" mais qui restera compatible avec la notation array()
Et vous, dans quels cas utilisez-vous, ou non, l'interface ArrayAccess ?
2 commentaires
Merci pour l'article Olivier, comme d'habitude c'est intéressant
J'utilise ArrayAccess quand j'ai besoins de l'interface compatible array, à laquelle s'ajoutent généralement Countable et un Iterator. C'est d'une banalité affligeante ;)
Le gros défaut est que php interdit de redéfinir une méthode avec une signature différente. On ne peut donc pas y injecter du type hinting, ou alors on le fait de manière détournée dans le corps de la méthode.
Par exemple un ORM. Une property modélise une relation 1-N vers des types T. Celle-ci expose un "ArrayAccess" pour que l'utilisateur puisse y ajouter des types uniquement compatibles avec T. Ce comportement ne serait pas possible si la property n'était qu'un simple array dans lequel on peut ajouter ce qu'on veut.