Lead developer web


#PHP #Symfony #RabbitMQ #Redis #Git #Nginx
#Performance #Quality #Testing

Cas concret - créer une commande pour récupérer les compétitions de running

PHP   Symfony   Run  

Dans un précédent post, je vous avais indiqué que Symfony n'était pas seulement un framework mais aussi un ensemble de composants.

Dans cet article, je vais vous expliquer un petit cas concret d'utilisation de plusieurs composants Symfony pour créer une petite application.

Explication fonctionnelle

D'abord, quel est le sujet ? L'objectif de ce projet est simplement de récupérer la liste des compétitions de course à pied disponibles par le biais d'une commande.

Pour cela, il faut bien entendu se baser sur quelque chose, ce sera sur le site http://bases.athle.com/. Sur celui-ci il, est possible de réaliser une recherche et en cliquant sur un des résultats, des informations complémentaires sont affichées (distance, heure du départ, organisateur etc.).

L'objectif est donc de faire une recherche et d'exporter les résultats dans un fichier.

Et techniquement ?

Pour commencer, j'ai choisi de créer un dépôt public sur github dont voici l'adresse: https://github.com/nicolasdewez/competRunning.

Côté technos, vous pouvez deviner facilement mais c'est assez simple :

En pré requis pour utiliser l'application, il suffit donc d'avoir PHP 7.1 et composer.

Dans la suite de cette article, nous allons expliquer globalement comment le projet est structuré.

Le fichier composer.json

Côté dependances
{
    "require": {
        "php": "^7.1.0",
        "fabpot/goutte": "^3.2",
        "incenteev/composer-parameter-handler": "^2.1",
        "symfony/config": "^3.3",
        "symfony/console": "^3.3",
        "symfony/css-selector": "^3.3",
        "symfony/debug": "^3.3",
        "symfony/dom-crawler": "^3.3",
        "symfony/var-dumper": "^3.3",
        "symfony/yaml": "^3.3"
    }
}

Nous détaillerons un peu plus par la suite mais voici quelques explications :

Voilà, ça peut paraitre beaucoup mais 2 dépendances peuvent être retirées ou placées en dépendances de développement.

Configuration supplémentaire
{
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "scripts": {
        "symfony-scripts": [
            "Incenteev\\ParameterHandler\\ScriptHandler::buildParameters"
        ],
        "post-install-cmd": [
            "@symfony-scripts"
        ],
        "post-update-cmd": [
            "@symfony-scripts"
        ]
    },
    "config": {
        "sort-packages": true
    },
    "extra": {
        "incenteev-parameters": {
            "parameter-key": "search",
            "file": "configuration/app.yaml"
        }
    }
}

Concernant la partie autoload, rien de bien différent de d'habitude.

La partie scripts permet d'indiquer à composer d'éxecuter lors de l'install (et lors de l'update) le script Incenteev\ParameterHandler\ScriptHandler::buildParameters. A ce sujet, la rubrique extra permet d'indiquer le fichier que ce script doit traiter.

Enfin la rubrique config permet d'indiquer à composer de trier les dépendances lors de la modification de la configuration par exemple. Ceci n'est donc utilisé que lors des développements.

Le composant symfony/yaml

Dans ce projet, le composant Yaml est simplement utilisé pour lire le contenu du fichier configuration/app.yaml.

Ainsi, dans la méthode App\Service\ConfigLoader::getProcessedConfiguration, un simple appel à la méthode parse permet dé récupérer le contenu du fichier sous forme de tableau.

$config = Yaml::parse(file_get_contents($this->getConfigPath()));

Le composant symfony/config

Là encore, l'utilisation de ce composant est assez simple. La classe App\Configuration\AppConfiguration contient les obligations que doit respecter le fichier configuration/app.yaml.

Voici un extrait :

$treeBuilder = new TreeBuilder();
$rootNode = $treeBuilder->root('search');

$rootNode->children()
    ->scalarNode('year')
        ->isRequired()
        ->cannotBeEmpty()
    ->end()
    // ...
    ->arrayNode('distance')
        ->isRequired()
        ->children()
            ->floatNode('min')
                ->min(self::DISTANCE_MIN)
                ->max(self::DISTANCE_MAX)
                ->defaultValue(self::DISTANCE_MIN)
            ->end()
            // ...
        ->end()
    ->end()
;

Pour faire rapide, il est indiqué que :

Pour valider la configuration chargée par rapport à la configuration requise, la méthode App\Service\ConfigLoader::getProcessedConfiguration contient un petit bout de code :

(new Processor())->processConfiguration(
    new AppConfiguration(),
    $config
)

Ces lignes permettent de retourner la configuration re travaillée et de lever une exception en cas de souci.

Le composant symfony/console

Le composant Console fourni différentes classes permettant de réaliser une interface simple via une ligne de commande.

Pour cela, une classe App\Command\GetCompetitionsCommand a été créée. Voici notamment la méthode indiquant la configuration de celle-ci :

protected function configure()
{
    $this
        ->setName('app:competitions:get')
        ->setDescription('Get competitions')
        ->addOption('startDate', 's', InputOption::VALUE_REQUIRED)
        ->addOption('endDate', 'e', InputOption::VALUE_REQUIRED)
    ;
}

Un nom, une description et des options disponibles, rien de bien compliqué.

Si on détaille un peu plus, il est indiqué que chaque option, si elle est utilisée, doit comporter une valeur.

Cette classe contient également une méthode execute qui permet simplement de définir ce que la commande doit faire. Là encore, rien d'extraordinaire :

Ce qui est intéressant avec ce composant, c'est qu'il est possible de créer un ensemble de commandes (comme dans une application Symfony par exemple) ou une commande unique, comme ici.

Ainsi le fichier bin/app.php ressemble à ceci :

$application = new Application('CompetRunning', '1.0.0');
$command = new GetCompetitionsCommand();

$application->add($command);

$application->setDefaultCommand($command->getName(), true);
$application->run();

Le client fabpot/goutte

L'utilisation du client est on ne peut plus simple. Voici un exemple d'utilisation dans ce projet :

$client = new Client();
$crawler = $client->request('GET', sprintf(self::URL, $parameters));

Ces lignes de code proviennent de la classe App\Service\GetResults. Une simple requête HTTP suivant la méthode GET.

Un objet Crawler est récupéré. Celui-ci peut ensuite être traité avec les composants suivants.

Les composants symfony/dom-crawler et symfony/css-selector

Ces composants sont très utiles pour réussir à naviguer facilement dans des contenus de réponses HTTP.

C'est bien entendu le coeur de cette petite application. Ils sont donc utilisés dans les classes App\Service\GetResults et App\Service\GetCompetition.

Voici quelques extraits :

$crawler->filter('#ctnCalendrier tr td:nth-child(7) a')->extract(['href', '_text']);
$linesOrganizer = $blocks->eq(0)->filter('tr > td > table > tr')->reduce(function(Crawler $node) {
    return in_array($node->filter('td:nth-child(1)')->text(), self::ORGANIZER);
});

En résumé, le premier extrait retourne un tableau comportant l'url et le libellé d'un lien et le second filtre des noeuds de type tr par rapport au libellé de son premier td.

Et le reste ?

Concernant le reste du code, rien de bien compliqué. Juste un découpage simple des traitements.

Utilisation

C'est assez facile. En suivant le README.md, il faut :

./bin/app.php

La suite ?

Le projet peut bien entendu être amélioré. Si vous souhaitez contribuer ou me contacter pour avoir plus d'explications ou pour toutes autres demandes, n'hésitez pas !

Fonctionnellement

La partie recherche pourrait peut-être être optimisée car en fonction des critères le traitement peut être assez long (compter 4-5min pour récupérer toutes les compétitions des Hauts de France en 2018).

L'export est actuellement fait dans un fichier CSV pour que l'utilisateur puisse l'ouvrir avec un tableur et appliquer s'il le souhaite des filtres ou trier les résultats. Mais on pourrait imaginer que le format PDF soit proposé par exemple.

Et bien entendu, il y a peut-être des bugs...

Techniquement

D'abord, il n'y a aucun test ! Oui je sais, ce n'est pas bien. Honte sur moi mais ... c'est un projet fait rapidement donc j'ai une excuse :-P

On l'a vu au niveau des dépendances, une modification ne coûtant pas grand chose permettrait aux utilisateurs (et non aux développeurs) de ne pas récupérer 2 librairies lors de l'installation.

Au niveau du code, il est globalement "propre" mais peut être amélioré et pourquoi pas réorganisé. En parlant de réorganisation, à certains endroits du code, des objects sont instanciés et ça ce n'est pas top. Pour compléter ce point il pourrait être envisageable d'utiliser un autre composant de Symfony : DependencyInjection. Mais cela reste à voir.